diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c919b1d8..6273ddde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,25 @@ jobs: # default build, so lint it in a dedicated pass. - run: cargo clippy -p exav-core --all-targets --features unstable-internals -- -D warnings - run: cargo clippy -p exav-render --all-targets --features dwg -- -D warnings - - run: cargo clippy -p exav-render --all-targets --no-default-features --features ifc,stl -- -D warnings + - run: cargo clippy -p exav --all-targets --no-default-features --features yara,zip -- -D warnings + - run: cargo clippy -p exav-render --all-targets --no-default-features --features ifc,stl,obj,ply,gltf -- -D warnings + - run: cargo clippy -p exav-render --all-targets --no-default-features --features markdown -- -D warnings + - run: cargo clippy -p exav-render --all-targets --no-default-features --features text-pdf -- -D warnings + - run: cargo clippy -p exav-render --all-targets --no-default-features --features office-sheets -- -D warnings + - run: cargo clippy -p exav-render --all-targets --no-default-features --features office-slides -- -D warnings + - run: cargo clippy -p exav-render --all-targets --no-default-features --features office-docs -- -D warnings + - run: cargo clippy -p exav-render --all-targets --no-default-features --features office,office-test-util -- -D warnings + - run: cargo clippy -p exav-render --all-targets --no-default-features -- -D warnings + - run: cargo clippy -p exav-render --all-targets --no-default-features --features text-office,office-test-util -- -D warnings + - run: cargo clippy -p exav-core --all-targets --features visible-text-office -- -D warnings + - run: cargo clippy -p exav-grep --all-targets --features text-office -- -D warnings + - run: cargo clippy -p exav-unpack --all-targets --features text-pdf -- -D warnings # The full native test matrix, every feature pass. Defined once in the # Makefile so this and a local `make test` can't drift. - run: make test-native - # The promised floor is `rust-version` in Cargo.toml (1.92, imposed by the - # hayro image decoders). `make msrv` reads it there and installs that toolchain on + # The promised floor is `rust-version` in Cargo.toml (1.95, imposed by the + # delharc LHA reader). `make msrv` reads it there and installs that toolchain on # demand, so a bump flows through with no edit here, and a contributor on # stable can't land a newer feature without this going red. msrv: @@ -127,7 +139,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: npm cache-dependency-path: www/package-lock.json # exav.org is the authoritative answer on format coverage and CLI flags, @@ -194,7 +206,7 @@ jobs: # the masked fixtures are unmasked. seeds="$RUNNER_TEMP/fuzz-seeds" scripts/fuzz-seeds.sh "$seeds" - for t in filetype sigs cvd pe unpack analyze bytecode full_pipeline seekable yara pcre clamd icap volumes authenticode database ndb_compile rar3_ppmd pe_emulator x86_decode parser_recursion imagehash drawing cad_dxf cad_dwg ifc; do + for t in filetype sigs cvd pe unpack analyze bytecode full_pipeline seekable yara pcre clamd icap volumes authenticode database ndb_compile rar3_ppmd pe_emulator x86_decode parser_recursion imagehash drawing cad_dxf cad_dwg ifc mesh; do mkdir -p "fuzz/corpus/$t" extra="" [ -d "$seeds/$t" ] && extra="$seeds/$t" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d86142dc..1830849d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -128,7 +128,7 @@ jobs: tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest + type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} - uses: docker/build-push-action@v6 with: context: . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e63d2ac6..732028ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,22 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false + # Everything a release publishes is labelled with a version of its own: + # the binaries with the workspace's, the npm packages with theirs. One + # that was not bumped ships next to the others under another number, and + # nothing else fails. Checked before anything is built. + - name: Check every version matches the tag + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + WORKSPACE=$(sed -n '/^\[workspace.package\]/,/^\[/{s/^version *= *"\(.*\)"/\1/p;}' Cargo.toml | head -1) + VIEWER=$(node -p "require('./crates/exav-viewer/package.json').version") + UNPACK=$(node -p "require('./crates/exav-unpack-wasm/package.json').version") + status=0 + for pair in "Cargo.toml:$WORKSPACE" "crates/exav-viewer/package.json:$VIEWER" "crates/exav-unpack-wasm/package.json:$UNPACK"; do + file="${pair%%:*}"; version="${pair#*:}" + [ "$version" = "$TAG_VERSION" ] || { echo "$file is $version, the tag is $TAG_VERSION"; status=1; } + done + exit $status - uses: taiki-e/create-gh-release-action@v1 with: # The release notes are this tag's section of the changelog diff --git a/.github/workflows/www.yml b/.github/workflows/www.yml index bdf952f5..68659e85 100644 --- a/.github/workflows/www.yml +++ b/.github/workflows/www.yml @@ -49,6 +49,10 @@ jobs: --site "${{ steps.pages.outputs.origin }}" \ --base "${{ steps.pages.outputs.base_path }}" working-directory: ${{ env.BUILD_PATH }} + # Every internal link and #anchor of the built pages must resolve (offline, + # a second or two). The demo's own pages are copied in below. + - name: Check internal links + run: node scripts/check-links.mjs ${{ env.BUILD_PATH }}/dist # The @exav/viewer demo, served at /viewer/demo/: the package and its wasm # modules built from this commit, the archive engine's pkg/ too. - uses: dtolnay/rust-toolchain@stable @@ -71,8 +75,14 @@ jobs: npm ci npm run build:wasm npm run build - # The sidebar's files: github.com/sylvinus/exav-samples, HEAD. + # The sidebar's files, from the network: github.com/sylvinus/exav-samples, HEAD. npm run demo:showcase + # `demo:build` runs `npm run demo:examples` first: the example files + # committed under demo/examples/files/ are copied, and the others (tens + # of MB, from Zenodo, figshare, Wikimedia Commons and the like) are + # downloaded at build time, each checked against its size and SHA-256 in + # demo/examples/examples.json. The build fails when one cannot be fetched + # or has changed. # DEMO_ORIGIN goes into the sandboxed frame's policy, for Safari. DEMO_BASE="${{ steps.pages.outputs.base_path }}/viewer/demo/" DEMO_ORIGIN="${{ steps.pages.outputs.origin }}" npm run demo:build # dist/viewer/ holds the File viewer docs; the demo must not land on them. diff --git a/.oss-scanner/Dockerfile b/.oss-scanner/Dockerfile index 420c5858..97959193 100644 --- a/.oss-scanner/Dockerfile +++ b/.oss-scanner/Dockerfile @@ -4,7 +4,7 @@ # # Build it the way the scanner does, from the repository root: # docker build -f .oss-scanner/Dockerfile -t exav-oss-scanner . -FROM rust:1.92-bookworm +FROM rust:1.95-bookworm # Frame pointers and line tables for a debugger, without the full debug info # that makes the build several times larger. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df55884..02fdf119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,114 @@ follow [semantic versioning](https://semver.org/). ## [Unreleased] +## [0.0.3] - 2026-10-29 + +Many more formats read, in place and in bounded memory; a visible-text layer; +a much larger viewer; and a review round that closed the cases where exav read +a file differently from the program that opens it. + +### Highlights + +- **More formats, read where they lie.** Virtual disks (VHD, VHDX, QCOW2, + VMDK), filesystems (exFAT, SquashFS, cramfs, romfs, EROFS, HFS+, APFS, with + ext, FAT and NTFS) and 7z, WIM and xar are read from a seekable source + without holding them. A file past `--max-object-bytes` inside one is + scanned in full, not skipped. The registry lists 126 formats. +- **A visible-text layer** (`visible-text`, on by default, off under + `--clamav-compat`): Markdown, HTML, Word, PowerPoint, Excel, OpenDocument and + AutoCAD files are also scanned as the text a reader sees, for `Target:0` and + `Target:7` signatures and `--alert-phishing`. PDF text and the text of legacy + and flat office files (DOC, XLS, PPT, RTF, FODT and others) are opt-in + (`visible-text-pdf`, `visible-text-office`). `exav-grep --text` searches it. +- **Office documents converted in safe Rust, in `exav-render`** (features + `office-sheets`, `office-slides`, `office-docs`, `office`; none is a + default): XLS, ODS, FODS, Numbers, Excel 2003 XML, HTML tables and SYLK to + XLSX; PowerPoint 95 to 2003, ODP, FODP and Keynote to PPTX; DOC, ODT, FODT, + RTF, Word 2003 XML and Pages to DOCX; ODG, FODG and Visio (VSDX) to PPTX. + The viewer shows them through `xlsx()`, `pptx()` and `docx()`. Each file is + bounded in input, memory and decompressed size, and an encrypted, oversized + or damaged file is reported with its reason. +- **One reader per format.** The scanner and `exav-render` share the readers + for OLE2 (`exav_unpack::cfb`), Excel decryption, RTF, BIFF and code pages. +- **Bounded memory and time.** Deterministic sweeps (`saturate`, `nesting`, + `office_memory`) and new limits for the block cache, bytecode memory and + archive entries; `exav-grep` searches a file of any size in bounded memory. +- **The viewer** (`@exav/viewer`) opens many more files (RAW cameras, JPEG XL, + SVG, OBJ, PLY, glTF, more IFC, older Office, more archives), draws pages of + documents, 3D models and drawings, can be driven by the host's own UI, and + speaks German, Spanish and Dutch. +- **Daemon**: `--listen-health ADDR`, a listener that answers while the + workers are busy, and a `STATS` that reports the whole pool. + +### Breaking changes + +- The minimum Rust version is 1.95 (it was 1.92) and every crate is edition + 2024. +- A `.cvd` whose header md5 does not match, or that is cut short, is refused. +- A source with no end (`exav /dev/zero`) is an error, not `OK`. +- A YARA rule nested more than 10 parentheses deep is refused (12 before): the + parser's time doubles with each level. +- A one-shot scan that `--max-scan-secs` ends keeps the exit status of what it + found (`1`, then `2`) and otherwise follows `--partial-as`; it was always `3`. +- The `Content-Range` of an HTTP answer is checked; a server that answers + with another range or object is an error. +- `--object-cache-block-bytes` defaults to 1 MiB (it was 64 KiB, fixed). +- The viewer's CSS prefix is `exav-viewer-` (it was `exv-`), the sandboxed + frame's protocol is version 5 (it was 3), and `controllers.viewport` is + computed only while used. +- exav-render's office features depend on `exav-unpack`. `exav-unpack`'s `ole` + feature no longer pulls the `cfb` crate, now a dev-dependency. +- `exav-unpack` ext, FAT, HFS+ and APFS readers are in-tree: member names no + longer begin with `/`, and a damaged volume ends with an error after the + files before the damage. +- Objects already scanned clean in the same place are scanned once per scan. + +### Added + +- Settings: `--max-extracted-bytes` (back), `--max-bytecode-memory-bytes`, + `--object-cache-bytes`, `--object-cache-block-bytes`, `--http-request-bytes`, + `maxArchiveEntries` and `maxEntities` in the viewer. +- Formats: LZFSE, LZVN and InstallShield Z in-tree, camera RAW, DDS, ICO and cursors, PLY, OBJ, glTF, TIFF variants (fax, + CMYK, Zstandard, tiled, planar and more), and an external converter hook for + the viewer. +- `scripts/bench-speed.sh`, a speed benchmark against `clamscan`. +- 243 freely licensed example files for the viewer demo. + +### Changed + +- Dependencies are at their latest versions, except the image decoders pinned + to libclamav 1.5.4. Fewer dependencies and less `unsafe`: 8 crates and + 1,155 `unsafe` uses fewer in a default build. +- `fuzzy_img` JPEGs are decoded by zune-jpeg 0.5.15, not libclamav's 0.5.8: the + hashes are the same except for baseline JPEGs with a scan for each component, + which 0.5.8 drew flat (`8000000000000000`), and damaged files it panicked on. +- A damaged CAB or archive keeps more of what it decoded. + +### Fixed + +The ones that could change a verdict: + +- **Files that a reader opens differently from exav are read as that reader + reads them:** ZIP, PDF, mail, ISO and UDF, `ar`, `cpio`, bzip2, xz, CAB, + NSIS and PowerPoint objects, and files holding a second archive. A message + attached to an email is now scanned. +- **Base64 in scripts and pages:** more archive types are decoded, and a cap + on the number of decoded files is reported (`LIMITS-EXCEEDED`). +- A damaged executable carved from a file no longer turns a detection behind + it into `PARTIAL` or `OK`. +- Large files over HTTP no longer cost a request per executable candidate. +- `exav-unpack` no longer makes symbolic links that lead out of the extraction + directory, and no longer panics on a closed pipe. `VERSIONCOMMANDS` no longer + lists `SHUTDOWN` and `QUIT` on a daemon that refuses them. +- Resource bounds: nested documents, YARA rules, Visio formulas, PPMd, zstd, + JPEG XL, JBIG2, bytecode, 7z headers, RAR key derivation, and the memory of + XML trees, converters and drawings are bounded. Several recursive readers + no longer overflow the stack. +- Rendering and decoding fixes across TIFF, BMP, PNM, ICO, DWG, DXF, IFC, STL, + glTF, JPEG 2000, DDS, Word, Excel and PowerPoint files. +- PDF strings `\b` and `\f`, which made encrypted PDFs that open with an empty + password report password-protected. + ## [0.0.2] - 2026-10-11 The scanner, rebuilt around one streaming scan; three new projects (a browser diff --git a/Cargo.lock b/Cargo.lock index 8737f16c..05bf3aa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -88,20 +97,38 @@ dependencies = [ ] [[package]] -name = "apfs" -version = "0.2.4" +name = "archmage" +version = "0.9.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c1b4ecd96379464ea8b4ef2517c8eb674d8658ad364e9176bf5b8c278aa801e" +checksum = "15785ee3576fbd41276a25f063b1e6b5bf323af9b4924632e25a8de6a3065f06" dependencies = [ - "byteorder", - "thiserror", + "archmage-macros", + "safe_unaligned_simd", + "winarm-cpufeatures", ] [[package]] -name = "arraydeque" -version = "0.4.5" +name = "archmage-macros" +version = "0.9.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0ffd3d69bd89910509a5d31d1f1353f38ccffdd116dd0099bbd6627f7bd8ad8" +checksum = "ce7707714970d5cf476caccbae2d282000c6c14ca5aada878e722a10d8e1c153" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "ascii_tree" @@ -110,16 +137,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca6c635b3aa665c649ad1415f1573c85957dfa47690ec27aebe7ec17efe3c643" [[package]] -name = "autocfg" -version = "1.5.1" +name = "atomic_refcell" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4" [[package]] -name = "base64" -version = "0.22.1" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -128,31 +155,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] -name = "beef" -version = "0.5.2" +name = "bcdec_rs" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +checksum = "f09c37bc0e9f0924b7dae9988265ef3c76c88538f41a3b06caf4bed07cee5226" [[package]] name = "bit-set" -version = "0.8.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] [[package]] name = "bitflags" @@ -187,6 +212,30 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bstr" version = "1.13.1" @@ -209,6 +258,20 @@ name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] [[package]] name = "byteorder" @@ -260,9 +323,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.5.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "shlex", @@ -270,9 +333,9 @@ dependencies = [ [[package]] name = "cfb" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b" dependencies = [ "fnv", "uuid", @@ -343,7 +406,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -376,12 +439,6 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -415,36 +472,15 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.2.2" @@ -456,9 +492,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] @@ -471,20 +507,21 @@ checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] name = "delharc" -version = "0.6.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3658b90877f637514897c3dcbd628e0d290bcc7fee551a398b3994ebdc62d51e" +checksum = "9cfdcd2bee8dc367afd9064f3b0849156a4c29fc70d98370dae62773594d04dd" dependencies = [ - "bitflags 2.13.2", + "bitflags", + "bytemuck", "chrono", "memchr", ] [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "des" @@ -509,9 +546,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "encoding_rs" @@ -527,15 +564,50 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "enough" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877362cb7b70d1c617f0cd3c67708bd58d894a86af5104018e2c10ebaee3c02f" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] [[package]] name = "exav" -version = "0.0.2" +version = "0.0.3" dependencies = [ "clap", "exav-core", @@ -548,14 +620,15 @@ dependencies = [ [[package]] name = "exav-core" -version = "0.0.2" +version = "0.0.3" dependencies = [ - "base64 0.22.1", + "base64", "bit-set", "cab", "cfb", "crc32fast", "exav-imagehash", + "exav-render", "exav-unpack", "exav-x86", "flate2", @@ -586,17 +659,19 @@ dependencies = [ [[package]] name = "exav-grep" -version = "0.0.2" +version = "0.0.3" dependencies = [ "clap", + "exav-render", "exav-unpack", + "memchr", "regex", "walkdir", ] [[package]] name = "exav-imagehash" -version = "0.0.2" +version = "0.0.3" dependencies = [ "crc32fast", "exav-render", @@ -606,7 +681,7 @@ dependencies = [ [[package]] name = "exav-pe-emu" -version = "0.0.2" +version = "0.0.3" dependencies = [ "exav-x86", "rustc-hash 2.1.3", @@ -614,36 +689,41 @@ dependencies = [ [[package]] name = "exav-render" -version = "0.0.2" +version = "0.0.3" dependencies = [ + "bcdec_rs", "bytemuck", + "encoding_rs", "exav-unpack", "fax", "flate2", "gif", - "half", "hayro-ccitt", "hayro-jbig2", "hayro-jpeg2000", "image", + "lzma-rust2", "png", + "pulldown-cmark", "quick-error", + "quick-xml", + "ruzstd", "serde_json", "sha2", "weezl", - "zune-core 0.4.12", - "zune-core 0.5.0", + "zenjxl-decoder", + "zip", + "zune-core 0.5.3", "zune-jpeg 0.4.21", - "zune-jpeg 0.5.8", + "zune-jpeg 0.5.15", ] [[package]] name = "exav-unpack" -version = "0.0.2" +version = "0.0.3" dependencies = [ "aes", - "apfs", - "base64 0.22.1", + "base64", "bitstream-io", "byteorder", "bzip2", @@ -651,7 +731,6 @@ dependencies = [ "cbc", "cfb", "chrono", - "constant_time_eq", "crc32fast", "deflate64", "delharc", @@ -659,14 +738,14 @@ dependencies = [ "digest", "encoding_rs", "exav-pe-emu", - "explode", - "ext4-view", - "fatfs", "flate2", - "hfsplus", + "hayro-interpret", + "hayro-syntax", "hmac", + "html5gum", + "kurbo", "lzfse_rust", - "lzma-rust2 0.21.0", + "lzma-rust2", "lznt1", "lzxd", "mail-parser", @@ -684,14 +763,13 @@ dependencies = [ "tar", "thiserror", "tinyvec", - "unshield", "xz4rust", "zip", ] [[package]] name = "exav-update" -version = "0.0.2" +version = "0.0.3" dependencies = [ "crc32fast", "sha2", @@ -700,40 +778,16 @@ dependencies = [ [[package]] name = "exav-x86" -version = "0.0.2" +version = "0.0.3" dependencies = [ "iced-x86", ] [[package]] -name = "explode" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46695dd0a1b7a2a6c3e0800604bd9738c2699682b9fc325d4886b6c1a78025ac" -dependencies = [ - "arraydeque", -] - -[[package]] -name = "ext4-view" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ecf6379b7cfeace5e109ccc51955afc05f5f7f2466ae0cf2697bda46afd01e6" -dependencies = [ - "bitflags 2.13.2", - "crc", -] - -[[package]] -name = "fatfs" -version = "0.3.6" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05669f8e7e2d7badc545c513710f0eba09c2fbef683eb859fd79c46c355048e0" -dependencies = [ - "bitflags 1.3.2", - "byteorder", - "log", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" @@ -764,6 +818,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" + [[package]] name = "filetime" version = "0.2.29" @@ -776,9 +836,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -797,6 +857,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -865,17 +934,6 @@ dependencies = [ "scroll", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -906,12 +964,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad" +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe" +dependencies = [ + "bitflags", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "moxcms 0.8.1", + "phf", + "rustc-hash 2.1.3", + "siphasher", + "skrifa", + "smallvec", + "yoke", +] + [[package]] name = "hayro-jbig2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9" dependencies = [ + "fearless_simd", "hayro-ccitt", ] @@ -920,23 +1007,38 @@ name = "hayro-jpeg2000" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835" +dependencies = [ + "fearless_simd", +] [[package]] -name = "heck" -version = "0.5.0" +name = "hayro-postscript" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] -name = "hfsplus" -version = "0.2.4" +name = "hayro-syntax" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1003d8eed80d61e8e8ea6a958bbe1afd3aef060882b9d6134e50500dd307c0dc" +checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026" dependencies = [ - "byteorder", - "thiserror", + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "zune-jpeg 0.5.15", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hmac" version = "0.13.0" @@ -946,6 +1048,12 @@ dependencies = [ "digest", ] +[[package]] +name = "html5gum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428502d3ec1742c35e015871aef742bc4722a9acfcb616b8ff79b519922e1c36" + [[package]] name = "http" version = "1.5.0" @@ -1015,7 +1123,7 @@ dependencies = [ "color_quant", "gif", "image-webp", - "moxcms", + "moxcms 0.7.11", "num-traits", "png", "qoi", @@ -1043,9 +1151,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "block-padding", "hybrid-array", @@ -1059,9 +1167,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] @@ -1083,6 +1191,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -1097,9 +1217,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "log" @@ -1109,34 +1229,32 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "logos" -version = "0.15.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" dependencies = [ "logos-derive", ] [[package]] name = "logos-codegen" -version = "0.15.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" dependencies = [ - "beef", "fnv", - "lazy_static", "proc-macro2", "quote", + "regex-automata", "regex-syntax", - "rustc_version", "syn 2.0.119", ] [[package]] name = "logos-derive" -version = "0.15.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" dependencies = [ "logos-codegen", ] @@ -1149,15 +1267,9 @@ checksum = "5edeac3ec657925dececc672bdd58fa2212291886ddcb3e08aa46924c78340c6" [[package]] name = "lzma-rust2" -version = "0.16.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" - -[[package]] -name = "lzma-rust2" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b" +checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" dependencies = [ "sha2", ] @@ -1208,6 +1320,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1238,6 +1359,16 @@ dependencies = [ "pxfm", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -1250,7 +1381,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -1280,6 +1411,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.47" @@ -1310,6 +1452,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.13.0" @@ -1326,6 +1474,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1344,24 +1534,33 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.13.2", + "bitflags", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppmd-rust" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1" +checksum = "cd4acc8d264ea385db64ccfdcdfd5909ee9eda0975666723d59f3bfec80b3611" [[package]] name = "primal-check" @@ -1372,6 +1571,37 @@ dependencies = [ "num-integer", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1381,6 +1611,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -1404,9 +1645,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] @@ -1451,6 +1692,17 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "regex" version = "1.13.1" @@ -1515,12 +1767,13 @@ dependencies = [ [[package]] name = "rowan" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +checksum = "14b574c58582fa59fa43a2feb6608b8744184659f08a2e0117e4b8224d95ed61" dependencies = [ "countme", "hashbrown 0.14.5", + "memoffset", "rustc-hash 1.1.0", "text-size", ] @@ -1537,15 +1790,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustdct" version = "0.7.1" @@ -1571,9 +1815,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -1595,9 +1839,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1612,12 +1856,15 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ruzstd" -version = "0.8.3" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" -dependencies = [ - "twox-hash", -] +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" + +[[package]] +name = "safe_unaligned_simd" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c874fb993656d484725d58fbfddb39dfcf98ebcadc1a1d09568aa6b39bafba" [[package]] name = "salzweg" @@ -1657,15 +1904,9 @@ checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.229" @@ -1693,14 +1934,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -1711,14 +1952,14 @@ dependencies = [ [[package]] name = "sevenz-rust2" -version = "0.20.2" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29225600349ef74beda5a9fffb36ac660a24613c0bde9315d0c49be1d51e9c24" +checksum = "2f0c68c0e8289bfd09e49ef433e8de02c7c464afc1d7f5a275c99749a880e301" dependencies = [ "bzip2", "crc32fast", "js-sys", - "lzma-rust2 0.16.5", + "lzma-rust2", "ppmd-rust", "wasm-bindgen", ] @@ -1736,9 +1977,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures", @@ -1763,12 +2004,40 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strength_reduce" version = "0.2.4" @@ -1811,15 +2080,26 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "tar" version = "0.4.46" @@ -1853,7 +2133,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1887,6 +2167,36 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7184dfffa0d05e10284df327c6cb52f0d62c5aa6c9a8b9236fa151d4361882f1" +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "transpose" version = "0.2.3" @@ -1897,17 +2207,11 @@ dependencies = [ "strength_reduce", ] -[[package]] -name = "twox-hash" -version = "2.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" - [[package]] name = "typed-path" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" [[package]] name = "typenum" @@ -1915,6 +2219,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -1923,15 +2233,15 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -1942,16 +2252,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" -[[package]] -name = "unshield" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dd29bc2ffd8a674b7de402169d86b3c083229373d05c388526d626408416c5c" -dependencies = [ - "byteorder", - "explode", -] - [[package]] name = "untrusted" version = "0.9.0" @@ -1960,11 +2260,11 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ - "base64 0.23.1", + "base64", "flate2", "log", "percent-encoding", @@ -1977,11 +2277,11 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ - "base64 0.23.1", + "base64", "http", "httparse", "log", @@ -2001,9 +2301,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.1" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "wasm-bindgen", @@ -2057,7 +2357,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -2082,9 +2382,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -2104,6 +2404,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winarm-cpufeatures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d107c80dd656e1a2c254dfb1d57ece73b7f515b8a8684c270c23cab68f4a440d" + [[package]] name = "windows-core" version = "0.62.2" @@ -2245,6 +2551,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "xz4rust" version = "0.2.3" @@ -2253,12 +2568,12 @@ checksum = "606930b71bad6379f366994e4e6252a3462fa28bfc3695c33b07bef8f967d6d6" [[package]] name = "yara-x-parser" -version = "1.19.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5564d72253142774903eafc8f4c92bebef40d65c176a11d351258e80cc8a1c7" +checksum = "89313553cc4cf8961c257d282c902da1d5de568cd656b39962bdf9edd1e3935e" dependencies = [ "ascii_tree", - "bitflags 2.13.2", + "bitflags", "bstr", "indexmap", "itertools", @@ -2269,36 +2584,103 @@ dependencies = [ ] [[package]] -name = "zerocopy" -version = "0.8.59" +name = "yoke" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ - "zerocopy-derive", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "zerocopy-derive" -version = "0.8.59" +name = "yoke-derive" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zenjxl-decoder" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d68502daf17b161b6fb73657f4a4c60ba5a0491846aae769e33b34dc5ec513" +dependencies = [ + "aligned-vec", + "array-init", + "atomic_refcell", + "bytemuck", + "byteorder", + "enough", + "num-derive", + "num-traits", + "paste", + "smallvec", + "thiserror", + "zenjxl-decoder-macros", + "zenjxl-decoder-simd", +] + +[[package]] +name = "zenjxl-decoder-macros" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d5f705a56b0a01d283503fbe7d5e3b3e51d7321b5f1bba7b07bdeea7e2c6f3d" +dependencies = [ + "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "zenjxl-decoder-simd" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "237f3740d3d098854c6008515cdff29d8cbadcb6b4e3a1582e28dd3a04e3213e" +dependencies = [ + "archmage", + "paste", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zip" -version = "9.0.1" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "bitstream-io", "crc32fast", @@ -2329,9 +2711,9 @@ checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-core" -version = "0.5.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" @@ -2344,9 +2726,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.8" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.0", + "zune-core 0.5.3", ] diff --git a/Cargo.toml b/Cargo.toml index 8ab9bf75..454dcc33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,25 @@ [workspace] -resolver = "2" +resolver = "3" members = ["crates/exav", "crates/exav-core", "crates/exav-grep", "crates/exav-unpack", "crates/exav-pe-emu", "crates/exav-x86", "crates/exav-update", "crates/exav-imagehash", "crates/exav-render"] exclude = ["fuzz", "crates/exav-unpack-wasm", "crates/exav-viewer"] [workspace.package] -version = "0.0.2" -edition = "2021" +version = "0.0.3" +edition = "2024" license = "MIT" repository = "https://github.com/sylvinus/exav" homepage = "https://exav.org" # Checked by `make msrv` rather than asserted: a floor nothing verifies drifts. # -# The number is set by dependencies, not by this code: exav-render's image -# decoders hayro-jpeg2000 0.4, hayro-jbig2 0.3.1 and hayro-ccitt 0.4 declare -# 1.92 (and `yara-x-parser` 1.91), so cargo refuses to build them on anything -# older. exav's own source needs only 1.88 (`slice::as_chunks`, +# The number is set by dependencies, not by this code: exav-unpack's LHA reader +# delharc 0.8 declares 1.95 (if-let guards), `sevenz-rust2` 0.24 1.93, +# exav-render's image decoders hayro-jpeg2000 0.4, hayro-jbig2 0.3.1 and +# hayro-ccitt 0.4 1.92, and `yara-x-parser` 1.91, so cargo refuses to build them +# on anything older. exav's own source needs only 1.88 (`slice::as_chunks`, # `is_multiple_of`, and the let-chains the `zip` dependency uses). Worth # rechecking when those dependencies move; lowering it means confirming with # `make msrv`, not editing this line. -rust-version = "1.92" +rust-version = "1.95" # crates.io discovery. keywords: max 5, <=20 chars each. categories must be from # https://crates.io/category_slugs (an invalid one fails `cargo publish`). keywords = ["antivirus", "clamav", "malware", "scanner", "security"] @@ -28,7 +29,7 @@ categories = ["command-line-utilities", "filesystem"] # `env` lets every flag name the environment variable it falls back to, so the # precedence (flag, then environment, then default) is one rule the parser # applies and `--help` prints, rather than per-flag code. -clap = { version = "4.5", features = ["derive", "env"] } +clap = { version = "4.6", features = ["derive", "env"] } walkdir = "2.5" # Catch integer overflow in release too: on hostile input a silent wraparound diff --git a/Dockerfile b/Dockerfile index e6c653a1..7f6c4d24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,9 +38,9 @@ ARG BIN_SOURCE=build # of magnitude slower than cross-compiling it. # rust:alpine targets *-unknown-linux-musl and links statically. `build-base` # provides the C toolchain the TLS stack (ring, via the `http` feature's ureq → -# rustls) needs to compile under musl. Pinned to 1.92, the MSRV floor (see the +# rustls) needs to compile under musl. Pinned to 1.95, the MSRV floor (see the # workspace `rust-version`, which says what sets it); bump both together. -FROM rust:1.92-alpine AS build +FROM rust:1.95-alpine AS build RUN apk add --no-cache musl-dev build-base WORKDIR /src COPY . . diff --git a/Makefile b/Makefile index d235de8a..176d763b 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ DBDIR ?= exav-db EXAVDB ?= exav.exavdb .DEFAULT_GOAL := build -.PHONY: build release test test-native test-yara-diff test-wasm test-js test-viewer test-www test-helm wasm-sizes lint fmt msrv av-audit miri publish-check publish fuzz db exavdb cache daily clean www-dev www-build help +.PHONY: build release test test-native test-yara-diff test-wasm test-js test-viewer test-www test-formats formats test-helm wasm-sizes lint fmt msrv stack-frames deps-review av-audit miri publish-check publish fuzz db exavdb cache daily clean www-dev www-build help ## build: compile the release binary build release: @@ -71,7 +71,32 @@ test-native: $(CARGO) test -p exav-render --no-default-features --features dwg # IFC and STL, behind `ifc` and `stl`, as @exav/viewer's model module # builds them. - $(CARGO) test -p exav-render --no-default-features --features ifc,stl + $(CARGO) test -p exav-render --no-default-features --features ifc,stl,obj,ply,gltf + # Markdown, behind `markdown`, as @exav/viewer's text module builds it. + $(CARGO) test -p exav-render --no-default-features --features markdown + # The office converters (spreadsheets, presentations, text documents), + # behind `office-*`, as @exav/viewer's sheets, slides and docs modules + # build them: each alone, since slides compile part of sheets, then all + # of them with the test writers the integration tests build documents with. + $(CARGO) test -p exav-render --no-default-features --features office-sheets + $(CARGO) test -p exav-render --no-default-features --features office-slides,office-test-util + $(CARGO) test -p exav-render --no-default-features --features office-docs,office-test-util + $(CARGO) test -p exav-render --no-default-features --features office,office-test-util + # The text a reader sees, behind `text` (HTML, Office XML parts, drawings) + # and `text-pdf` (PDF, through hayro, which switches flate2 to zlib-rs: its + # own pass, so that the default passes do not run with it). + $(CARGO) test -p exav-unpack --features text + $(CARGO) test -p exav-render --no-default-features --features text + $(CARGO) test -p exav-unpack --features text-pdf --test all text:: + $(CARGO) test -p exav-render --no-default-features --features text-pdf + $(CARGO) test -p exav-core --features visible-text-pdf --test all visible_text + $(CARGO) test -p exav-grep --features text-pdf --test text + # The office formats' text (DOC, XLS, PPT, RTF, flat OpenDocument, iWork...), + # behind `text-office`. + $(CARGO) test -p exav-render --no-default-features --features text-office,office-test-util + $(CARGO) test -p exav-core --features visible-text-office --test all visible_text + $(CARGO) test -p exav-grep --features text-office --test text --test text_office + $(CARGO) test -p exav --features visible-text-office --test office_text ## test-yara-diff: the yara-x A/B differential harness. Compiles the SAME rules ## with both engines and asserts equal matching-rule sets. NOT @@ -119,6 +144,19 @@ test-viewer: test-www: ./scripts/test-www.sh +## formats: write the pages generated from the format registry +## (formats/registry.json): the format matrix and the viewer's +## summary sentence in the READMEs and the site. +formats: + node scripts/formats.mjs generate + +## test-formats: the format registry against the extractor's formats, the +## scanner's file types, the viewer's plugins, and the pages +## generated from it (test-www runs it too; the viewer's and the +## scanner's own registry tests are in test-viewer and test-native). +test-formats: + node scripts/formats.mjs check + ## test-helm: render-check the Helm chart (render guards, hardening settings, ## packaging). Needs `helm` on PATH, no cluster. CI runs `ct lint` ## in addition. See scripts/test-helm.sh. @@ -141,7 +179,18 @@ lint: $(CARGO) clippy --all-targets --features exav-core/http -- -D warnings $(CARGO) clippy -p exav-core --all-targets --features unstable-internals -- -D warnings $(CARGO) clippy -p exav-render --all-targets --features dwg -- -D warnings - $(CARGO) clippy -p exav-render --all-targets --no-default-features --features ifc,stl -- -D warnings + $(CARGO) clippy -p exav-render --all-targets --no-default-features --features ifc,stl,obj,ply,gltf -- -D warnings + $(CARGO) clippy -p exav-render --all-targets --no-default-features --features markdown -- -D warnings + $(CARGO) clippy -p exav-render --all-targets --no-default-features --features text-pdf -- -D warnings + $(CARGO) clippy -p exav-render --all-targets --no-default-features --features office-sheets -- -D warnings + $(CARGO) clippy -p exav-render --all-targets --no-default-features --features office-slides -- -D warnings + $(CARGO) clippy -p exav-render --all-targets --no-default-features --features office-docs -- -D warnings + $(CARGO) clippy -p exav-render --all-targets --no-default-features --features office,office-test-util -- -D warnings + $(CARGO) clippy -p exav-render --all-targets --no-default-features -- -D warnings + $(CARGO) clippy -p exav-render --all-targets --no-default-features --features text-office,office-test-util -- -D warnings + $(CARGO) clippy -p exav-core --all-targets --features visible-text-office -- -D warnings + $(CARGO) clippy -p exav-grep --all-targets --features text-office -- -D warnings + $(CARGO) clippy -p exav-unpack --all-targets --features text-pdf -- -D warnings ## fmt: format the code fmt: @@ -159,6 +208,23 @@ msrv: rustup toolchain install $$v --profile minimal >/dev/null 2>&1 || true; \ echo "checking MSRV $$v"; rustup run $$v cargo check --workspace --all-targets +## stack-frames: the stack frames of the functions that recurse into nested input +## (container walks, shape and block walks of the converters), read +## from release builds. A frame over 16 KiB is a value held while the +## next level is read, once per level; see scripts/stack-frames.py. +stack-frames: + $(CARGO) build --release -p exav + $(CARGO) test --release -p exav-render --features office-docs,office-slides,office-sheets --test nesting --no-run + python3 -I scripts/stack-frames.py $(EXAV) \ + $$(ls -t target/release/deps/nesting-* | grep -v '\.d$$' | head -1) + +## deps-review: the dependency report of docs/DEPENDENCIES.md ("Lifecycle"): +## versions, release age, `unsafe`, vendored copies against their +## upstream. Queries crates.io, GitHub and PyPI; `DEPS_ARGS=--offline` +## uses the last answers. +deps-review: + python3 -I scripts/deps-review.py $(DEPS_ARGS) + ## av-audit: scan the tracked tree with a real engine and signature set, to catch ## a committed fixture that other people's scanners will detect. Run it ## when fixtures change, not on every release: it needs `clamscan` and @@ -172,7 +238,7 @@ av-audit: @[ -d $(DBDIR) ] || { echo "av-audit needs $(DBDIR); run 'make db'"; exit 1; } @echo "scanning $$(git ls-files | wc -l | tr -d ' ') tracked files with $(DBDIR)" @hits=$$(git ls-files -z | xargs -0 clamscan -d $(DBDIR) --no-summary 2>/dev/null \ - | grep -v ': OK$$' || true); \ + | grep -v -e ': OK$$' -e ': Empty file$$' || true); \ if [ -n "$$hits" ]; then echo "$$hits"; \ echo "-> mask these (see crates/exav-unpack/tests/fixtures/README.md)"; exit 1; \ fi; \ diff --git a/NOTICE b/NOTICE index a0fb856e..e9eaf830 100644 --- a/NOTICE +++ b/NOTICE @@ -74,7 +74,12 @@ Copyright (c) 2024. The YARA-X Authors. All Rights Reserved. OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Other BSD-3-Clause components linked by exav (same 3-clause terms above, with -their respective copyright holders): subtle, tlsh2. +their respective copyright holders): subtle, tlsh2, and, in exav-render's `jxl` +feature (JPEG XL in @exav/viewer's image module), zenjxl-decoder with its +zenjxl-decoder-simd and zenjxl-decoder-macros: "Copyright (c) the JPEG XL +Project Authors. All rights reserved." It is a fork of the libjxl team's +jxl-rs (https://github.com/libjxl/jxl-rs), built `forbid(unsafe_code)` and +without its SIMD. ------------------------------------------------------------------------------- @@ -255,13 +260,18 @@ Copyright (c) Mike Krüger. ------------------------------------------------------------------------------- -1h1. unshield / explode, MIT (linked dependency + test fixtures) +1h1. unshield / explode, MIT, and blast.c, zlib (source derivatives + test + fixtures) ------------------------------------------------------------------------------- -crates/exav-unpack/src/formats/ishield_z.rs reads InstallShield `.z` archives -through the `unshield` crate, whose only real dependency is `explode`, a leaf -decoder for the PKWARE Data Compression Library "implode" codec those archives -use. Both MIT. +crates/exav-unpack/src/formats/ishield_z.rs reads the table of contents of +InstallShield `.z` archives as the `unshield` crate 0.2.0 does, and +crates/exav-unpack/src/formats/dcl_explode.rs decodes their PKWARE Data +Compression Library "implode" members as the `explode` crate 0.1.2 does. Both +crates are MIT. `explode` is a port of Mark Adler's `blast.c` (zlib +`contrib/blast`, zlib licence, text in section 1l; Copyright (C) Mark Adler), +whose algorithm and code-length tables dcl_explode.rs keeps; it is altered +from both: it decodes a whole slice into a capped buffer. The fixtures in crates/exav-unpack/tests/fixtures/ishieldz/ are that project's own examples, redistributed under the same terms. `undhr.z` is shipped alongside @@ -360,6 +370,57 @@ Copyright © 1995-2011 by Fredrik Lundh and contributors Copyright © 2010 by Jeffrey 'Alex' Clark and contributors +------------------------------------------------------------------------------- +1h6. Apple lzfse, BSD-3-Clause (source derivative) +------------------------------------------------------------------------------- + +crates/exav-unpack/src/formats/lzfse.rs, the LZFSE and LZVN decoder for DMG +runs, is a Rust port of the decoder of Apple's reference implementation +(lzfse_decode_base.c, lzfse_fse.c/.h, lzvn_decode_base.c and the tables of +lzfse_internal.h), changed to decode a whole buffer into a capped one. + +Upstream: https://github.com/lzfse/lzfse +Copyright (c) 2015-2016, Apple Inc. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + 3. Neither the name of the copyright holder(s) nor the names of any + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- +1h7. cmpfs, MIT (source derivative) +------------------------------------------------------------------------------- + +crates/exav-unpack/src/formats/decmpfs.rs, the decoder of the files macOS +compresses (decmpfs) for HFS+ and APFS, follows cmpfs 0.1.0's reading of the +attribute and of the resource fork (the types, the block tables, the stored-block +markers), over exav's own codecs and with its own bounds. + +Upstream: https://github.com/Dil4rd/dpp +Copyright (c) 2026 Dil4rd + + ------------------------------------------------------------------------------- 1h. oletools test sample, BSD-2-Clause (test fixture) ------------------------------------------------------------------------------- @@ -461,6 +522,9 @@ Copyright (c) Nullsoft and contributors. 3. This notice may not be removed or altered from any source distribution. +The zlib-rs crate (the `text-pdf` features' DEFLATE) is under the same +Zlib terms: (C) 2024 Trifecta Tech Foundation. + ------------------------------------------------------------------------------- 1m. sevenz-rust2, Apache-2.0 (design reference) @@ -494,6 +558,153 @@ Open Font License 1.1. NOTICE.md beside them gives each face's copyright and version, and OFL-1.1.txt the licence. +------------------------------------------------------------------------------- +1o. numbers-parser data (MIT) and the OpenOffice.org Excel format documentation +------------------------------------------------------------------------------- + +These are the office converters of exav-render (the `office-sheets`, +`office-slides` and `office-docs` features, `src/formats/office/`). They reach +the @exav/viewer npm package and, with `text-office`, exav-grep; the default +exav binary contains none of them. Their full notice is +crates/exav-render/NOTICE. + +They are written from published specifications: Microsoft's Open +Specifications ([MS-XLS], [MS-PPT], [MS-DOC], [MS-CFB], [MS-ODRAW], +[MS-OSHARED], [MS-OFFCRYPTO], [MS-VSDX]) and Visio file format reference, +ECMA-376 and ISO/IEC 29500, OpenDocument 1.2 and 1.3 (OASIS), the RTF +specification, Apple's Technical Notes TN2067 and TN2073 (Keynote 1) and RFC +1741 (BinHex 4.0). OpenOffice.org's "Microsoft Excel File Format" documentation +(Public Documentation License) gives the BIFF2 to BIFF5 record layouts. + +Two projects supply data: numbers-parser (https://github.com/masaccio/numbers-parser, +MIT, Copyright 2021 Jon Connell) gave Numbers' function indexes, the families +of macOS fonts' PostScript names and the layout of table records; keynote-parser +(https://github.com/psobot/keynote-parser, MIT, Copyright 2019-2025 Peter +Sobot) gave the Keynote and Pages message layout, and its sample.key and +table.key are test fixtures. + +The readers for PowerPoint 95 and Keynote 2 to 5 follow no published +description: they were written from sample documents, with the output of other +office applications on them used as a black box (rendered pages, never source). +No source of another office application or of a document-format library was +read. + + +------------------------------------------------------------------------------- +1p. Camera RAW previews (exav-render `raw`): specifications and sample files +------------------------------------------------------------------------------- + +crates/exav-render/src/formats/image/raw.rs and raw/ read the previews of +camera RAW files. No code was taken from another implementation. They were +written from these public specifications, which are not redistributed here: + +- TIFF Revision 6.0 (Adobe Systems Incorporated, 1992), published by Adobe for + anyone to implement. +- Adobe Digital Negative (DNG) Specification 1.7.1.0 (Adobe), published with a + royalty-free licence to implement DNG. +- Exif 2.32, CIPA DC-008-2019 (Camera & Imaging Products Association), freely + downloadable. +- ITU-T T.81 | ISO/IEC 10918-1, the JPEG standard. +- ISO/IEC 14496-12, ISO base media file format, from ISO's publicly available + standards. + +What the makers' own containers hold (the signatures of RAF, X3F, CRW, MRW, +ORF, RW2 and IIQ, the compression numbers of their sensor data) was read off +sample files from https://raw.pixls.us, which its contributors dedicate to the +public domain under CC0 1.0 +(https://creativecommons.org/publicdomain/zero/1.0/), with tools written for +the purpose. None of those files is in this repository; the tests use the +synthetic containers of crates/exav-render/tests/fixtures/raw/make.py. + +The Canon CR3 reader (raw/bmff.rs and the CR3 parts of raw.rs and make.py) was +written from ISO/IEC 14496-12 and the Exif and TIFF specifications above, and +from 110 CR3 sample files of the same CC0 source, which a box lister written +for the purpose walked: the box layout (the `crx ` brand of `ftyp`, the `uuid` +boxes of `moov`, the box that holds the Exif of the first directory and its +orientation, the JPEGs the file embeds and where) was read off those files +alone. No CR3 documentation or source code of any other project was read: no +format description, no README, no tag table, and no other RAW reader or +converter. + + +------------------------------------------------------------------------------- +1q. Example files of the @exav/viewer demo +------------------------------------------------------------------------------- + +The @exav/viewer demo lists example files kept in +crates/exav-viewer/demo/examples/. Most are made by scripts of this repository +and given to the public domain (CC0-1.0). The others are public domain, CC0, +CC-BY, MIT, BSD or Apache files, each credited with its author, its +source and the page that states its licence in examples.json, NOTICE.md and +https://exav.org/viewer/examples/. The small ones are committed; the rest are +downloaded by `npm run demo:examples` from pinned addresses and checked against +their SHA-256. They are test inputs only and are not part of any published +package. + + +------------------------------------------------------------------------------- +1r. IFC geometry (exav-render `ifc`): specifications +------------------------------------------------------------------------------- + +The curved-face, sweep-parameter, alignment, grid and storey code is written +from buildingSMART International's published IFC4 and IFC4X3 EXPRESS schemas +and documentation (CC BY-ND 4.0, read, not copied) and the surface and curve +definitions of ISO 10303-42. No code from another IFC library was read. + + +------------------------------------------------------------------------------- +1s. yoke, yoke-derive, zerofrom, zerofrom-derive, Unicode License v3 +------------------------------------------------------------------------------- + +These crates (the ICU4X project's, linked through hayro-interpret, the PDF +interpreter of the `text-pdf` features) carry this licence; reproduce its +notice in distributions. + + UNICODE LICENSE V3 + + COPYRIGHT AND PERMISSION NOTICE + + Copyright © 2020-2024 Unicode, Inc. + + NOTICE TO USER: Carefully read the following legal agreement. BY + DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR + SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE + TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT + DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + + Permission is hereby granted, free of charge, to any person obtaining a + copy of data files and any associated documentation (the "Data Files") or + software and any associated documentation (the "Software") to deal in the + Data Files or Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, and/or sell + copies of the Data Files or Software, and to permit persons to whom the + Data Files or Software are furnished to do so, provided that either (a) + this copyright and permission notice appear with all copies of the Data + Files or Software, or (b) this copyright and permission notice appear in + associated Documentation. + + THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY + KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF + THIRD PARTY RIGHTS. + + IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE + BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, + OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA + FILES OR SOFTWARE. + + Except as contained in this notice, the name of a copyright holder shall + not be used in advertising or otherwise to promote the sale, use or other + dealings in these Data Files or Software without prior written + authorization of the copyright holder. + + Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. + ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines + Corporation and others. + + ------------------------------------------------------------------------------- 2. ISC-licensed components ------------------------------------------------------------------------------- @@ -520,11 +731,22 @@ the distributor's choice. Notable examples: clap CLI parsing sha1, sha2, md-5, digest (RustCrypto) hashing flate2, miniz_oxide DEFLATE / gzip / zlib - lzfse_rust, delharc, lzxd DMG/LHA/CAB codecs + delharc, lzxd LHA/CAB codecs bitstream-io bit-level readers for decoders stringprep PDF password normalization - ext4-view ext2/3/4 filesystem reader image image decoding (image hashing) + pulldown-cmark Markdown in exav-render (`markdown` + feature) and @exav/viewer; MIT, + 2015 Google Inc. + html5gum HTML text in exav-unpack (`text` + feature): the tokenizer; MIT, + 2021 Markus Unterwaditzer + hayro-interpret, hayro-syntax, PDF text in exav-unpack (`text-pdf` + hayro-cmap, hayro-postscript feature, off by default): Apache-2.0 + OR MIT, Laurenz Stampfl + phf glyph-name tables in hayro; MIT, + 2014-2022 Steven Fackler, Yuki + Okushi hayro-jpeg2000, hayro-jbig2, JPEG 2000, JBIG2 and CCITT fax in hayro-ccitt exav-render (image hashing) and @exav/viewer; hayro-jpeg2000's @@ -536,16 +758,18 @@ the distributor's choice. Notable examples: wasm-bindgen, js-sys, web-sys, WebAssembly/JS bindings; these reach wasm-bindgen-futures the published npm artifact -sevenz-rust2, bzip2 and ppmd-rust are dev-only: they build test fixtures. The -shipped 7z reader is native (§1m) and the bzip2 decoder is vendored (§1h3). +sevenz-rust2, bzip2, ppmd-rust and lzfse_rust are dev-only: they build test +fixtures. The shipped 7z reader is native (§1m), the bzip2 decoder is vendored +(§1h3) and the LZFSE decoder is ported from Apple's (§1h6). MIT-only components (the MIT copyright and permission notice must be retained; -the MIT text is in the exav LICENSE file): apfs, ascii_tree, cfb, color_quant, -deflate64, explode, fatfs, fax, fax_derive, goblin, hfsplus, lznt1, quick-xml, +the MIT text is in the exav LICENSE file): ascii_tree, bcdec_rs (2023 SMG; +a port of bcdec, MIT or public domain, 2022 Sergii Kudlai), cfb, color_quant, +deflate64, fax, fax_derive, goblin, lznt1, quick-xml, rmp, rmp-serde, ruzstd, salzweg, scroll, scroll_derive, simd-adler32, slab, -strsim, twox-hash, unshield, xz4rust, zip, zmij, and bytes (`http` features -only). fancy-regex and tiff are vendored, and Pillow's resampler ported -(§1h5). +strsim, xz4rust, zip, zmij, and bytes (`http` features only). fancy-regex and +tiff are vendored, and Pillow's resampler ported (§1h5); unshield and explode +are ported (§1h1). Apache-2.0-only: lzma-rust2 (it ships no NOTICE file of its own; the license text is in section 4). @@ -555,10 +779,10 @@ BSD-3-Clause: yara-x-parser (§1), and subtle (`http` features only). tlsh2 is Apache-2.0 OR BSD-3-Clause. RustCrypto crates are MIT OR Apache-2.0. Offered under Zlib among other -licenses: miniz_oxide, tinyvec, bytemuck. Unicode-3.0: unicode-ident -(build-time). Other permissive -licenses: CC0-1.0 OR MIT-0 OR Apache-2.0 (constant_time_eq), -CDLA-Permissive-2.0 (webpki-roots, TLS root store, `http` features only). The +licenses: miniz_oxide, tinyvec, bytemuck. Zlib: zlib-rs (§1l). Unicode-3.0: +unicode-ident (build-time), and yoke, yoke-derive, zerofrom and +zerofrom-derive (§1s). Other permissive +licenses: CDLA-Permissive-2.0 (webpki-roots, TLS root store, `http` features only). The dev-only crates add CC0-1.0 / MIT-0 (ppmd-rust) and bzip2-1.0.6 (libbz2-rs-sys), which reach no shipped artifact. diff --git a/README.md b/README.md index 61720a7e..4532278a 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,7 @@ cargo install exav-unpack ## File viewer: `@exav/viewer` -A file viewer for the browser: PDF, images, DWG and DXF drawings, Office -documents, IFC and STL models, media and archives. Each file can open in a +A file viewer for the browser: PDF, images, DWG and DXF drawings, Word, Excel, PowerPoint and CSV, text (Markdown, JSON, XML, source code), IFC, STL, OBJ, PLY and glTF models, video, audio, and what is inside an archive. Each file can open in a sandboxed frame of its own, and exav's own decoders run in WebAssembly. ```sh @@ -62,7 +61,7 @@ npm install @exav/viewer |---|---| | [`exav-grep`](crates/exav-grep/README.md) | `grep`, but it can see inside archives | | [`exav-core`](crates/exav-core/README.md) | The scanning engine, as a Rust library | -| [`exav-render`](crates/exav-render/README.md) | The decoders behind the viewer: images, DWG, DXF, IFC, STL | +| [`exav-render`](crates/exav-render/README.md) | The decoders behind the viewer: images, DWG, DXF, IFC, STL, OBJ, PLY, glTF, Office documents | | [`exav-imagehash`](crates/exav-imagehash/README.md) | Perceptual image hashes, ClamAV's `fuzzy_img` among them | | [`exav-pe-emu`](crates/exav-pe-emu/README.md) | A bounded x86-32 emulator that runs packer stubs to unpack them | | [`exav-x86`](crates/exav-x86/README.md) | A decode-only x86-32 instruction decoder | diff --git a/crates/exav-core/Cargo.toml b/crates/exav-core/Cargo.toml index ef9104f8..e2b6a6ba 100644 --- a/crates/exav-core/Cargo.toml +++ b/crates/exav-core/Cargo.toml @@ -32,39 +32,45 @@ memchr = "2" md-5 = "0.11" sha1 = "0.11" sha2 = "0.11" -# executable parsing (PE/ELF/Mach-O) -goblin = "0.10" +# PE and ELF parsing. Without Mach-O, `ar` archives and TE images, which exav +# reads with its own code or not at all: fewer of goblin's parsers compiled. +goblin = { version = "0.10", default-features = false, features = ["std", "elf32", "elf64", "pe32", "pe64"] } # x86 instruction decoder for the bytecode disasm_x86 API. Decode-only, in the # workspace, and dependency-free: a general-purpose decoder covers the whole ISA # at the cost of hundreds of `unsafe` blocks, which is a poor trade for one API. -exav-x86 = { path = "../exav-x86", version = "0.0.2" } +exav-x86 = { path = "../exav-x86", version = "0.0.3" } # archive/container extraction (its own crate). Formats are selected via this # crate's forwarding features (see `[features]`); `default-features = false` so a # lean build can pick only the formats it needs. # gzip+tar are not optional: a `.cvd` is a gzip'd tar, a `.cld` a raw one, so # loading any signature database needs both. -exav-unpack = { path = "../exav-unpack", version = "0.0.2", default-features = false, features = ["gzip", "tar"] } +exav-unpack = { path = "../exav-unpack", version = "0.0.3", default-features = false, features = ["gzip", "tar"] } # fuzzy hashing tlsh2 = { version = "1", features = ["diff"] } # The `fuzzy_img#` image hash, ClamAV's to the bit (`image-hash` feature). Its # formats past the five clamscan treats as graphics, JPEG 2000 and JBIG2 # included, are hashed except under `--clamav-compat`. -exav-imagehash = { path = "../exav-imagehash", version = "0.0.2", default-features = false, features = ["webp", "ico", "pnm", "qoi", "dds", "ff", "hdr", "jp2", "jbig2"], optional = true } +exav-imagehash = { path = "../exav-imagehash", version = "0.0.3", default-features = false, features = ["webp", "ico", "pnm", "qoi", "dds", "ff", "hdr", "jp2", "jbig2"], optional = true } +# The text of a document as a reader sees it (`visible-text` feature): +# exav-render's `text`: Markdown (pulldown-cmark without its `html` and `simd` +# features), HTML (html5gum's tokenizer), Office and OpenDocument XML parts, +# PDF (hayro) and drawings. +exav-render = { path = "../exav-render", version = "0.0.3", default-features = false, features = ["text"], optional = true } # HTTP(S) range-request backend (optional) ureq = { version = "3", optional = true } # Anchor-frequency counting at database build: tens of millions of u64 gram # keys, where the hash is the whole cost of the lookup. rustc-hash = "2" -serde = { version = "1.0.228", features = ["derive"] } +serde = { version = "1.0.229", features = ["derive"] } rmp-serde = "1.3.1" # The `.exavdb` binary blobs, written and read directly (see `database::enc_bytes`). rmp = "0.8.15" -regex = "1.12.4" +regex = "1.13.1" # The backtracking engine for the PCRE subsignatures `regex` cannot run # (lookaround, backreferences, atomic groups) is fancy-regex, vendored in # `src/fancy_regex/` (its README says why). It needs this beside `regex-automata` # and `regex-syntax` below. -bit-set = { version = "0.8", default-features = false, features = ["std"] } +bit-set = { version = "0.11", default-features = false, features = ["std"] } # YARA rule support (ClamAV loads .yar/.yara by default, so a drop-in must too), # via exav's native YARA engine under `src/yara/`. It compiles rules to a native # tree-walking evaluator (no runtime codegen at scan time, W^X preserved) and @@ -75,7 +81,7 @@ bit-set = { version = "0.8", default-features = false, features = ["std"] } # # Parser + AST only. The AST borrows the source, so the compiler lowers it into # an owned IR. -yara-x-parser = { version = "1.18", optional = true } +yara-x-parser = { version = "1.19", optional = true } # Byte-oriented regex engine with anchored search: YARA regexp patterns, the # `matches` operator and hex patterns (lowered to byte regexes), and the lazy # DFAs that run a regex over an object read through the block cache. @@ -87,14 +93,30 @@ regex-automata = "0.4" # match is ever missed). regex-syntax = "0.8" # base64 alphabet handling for the `base64`/`base64wide` string modifiers. -base64 = { version = "0.22", optional = true } +base64 = { version = "0.23", optional = true } # The database file's checksum, and crc32/checksum32 for YARA's `hash` module. crc32fast = "1" # JSON output for the WASI binary (`wasi-bin`), nothing else. -serde_json = { version = "1.0.140", optional = true } +serde_json = { version = "1.0.154", optional = true } [features] -default = ["yara", "all-formats", "decrypt", "dlp", "phishing", "image-hash"] +default = ["yara", "all-formats", "decrypt", "dlp", "phishing", "image-hash", "visible-text"] +# A document's visible text (its words, and where each link leads) is matched +# as a text view, as well as its bytes: Markdown, HTML, Office and OpenDocument +# XML parts, PDF and drawings. Not under `--clamav-compat`. +visible-text = ["dep:exav-render"] +# And PDF's, read by hayro. Not a default: hayro-interpret turns on +# hayro-syntax's `unsafe` feature, which switches every `flate2` in the build to +# zlib-rs (and changes what a damaged stream gives back) and adds SIMD to the +# image decoders; it also adds some 40 crates. See docs/DEPENDENCIES.md. +visible-text-pdf = ["visible-text", "exav-render/text-pdf"] +# And the office formats the XML parts do not reach: DOC, XLS, PPT, RTF, Word +# 2003 XML, SpreadsheetML 2003, SYLK, flat OpenDocument, Numbers, Pages and +# Keynote, from exav-render's converters (`office`). Safe Rust and within the +# scan's limits, but the largest readers of the scan: not a default. A document +# of those formats whose text cannot be read (encrypted, past a limit, damaged) +# marks the scan incomplete. +visible-text-office = ["visible-text", "exav-render/text-office"] # `fuzzy_img#` subsignatures, through exav-imagehash and its image decoders. # Without it they load as unsupported, and no image is ever decoded. image-hash = ["dep:exav-imagehash"] @@ -134,7 +156,7 @@ decrypt = ["exav-unpack/decrypt"] # individual formats for a smaller build (e.g. `features = ["zip"]`). A format # exav recognises but wasn't compiled to unpack is reported unsupported, never # silently clean. -all-formats = ["exav-unpack/all-formats", "pdf", "base64scan"] +all-formats = ["exav-unpack/all-formats", "pdf", "base64scan", "lzw", "lzma", "rpm", "diskimage"] base64scan = ["exav-unpack/base64scan"] zip = ["exav-unpack/zip"] gzip = ["exav-unpack/gzip"] @@ -143,7 +165,9 @@ bzip2 = ["exav-unpack/bzip2"] xz = ["exav-unpack/xz"] zstd = ["exav-unpack/zstd"] lzip = ["exav-unpack/lzip"] -cab = ["exav-unpack/cab"] +lzma = ["exav-unpack/lzma"] +rpm = ["exav-unpack/rpm"] +cab =["exav-unpack/cab"] chm = ["exav-unpack/chm"] sevenz = ["exav-unpack/sevenz"] rar = ["exav-unpack/rar"] @@ -188,6 +212,11 @@ alz = ["exav-unpack/alz"] egg = ["exav-unpack/egg"] hwp3 = ["exav-unpack/hwp3"] ext = ["exav-unpack/ext"] +exfat = ["exav-unpack/exfat"] +squashfs = ["exav-unpack/squashfs"] +cramfs = ["exav-unpack/cramfs"] +romfs = ["exav-unpack/romfs"] +erofs = ["exav-unpack/erofs"] zoo = ["exav-unpack/zoo"] ishieldz = ["exav-unpack/ishieldz"] pepack = ["exav-unpack/pepack"] @@ -235,18 +264,20 @@ zip = { version = "9", default-features = false, features = ["deflate-flate2"] } # into the build at all. flate2 = "1" tar = { version = "0.4", default-features = false } -cfb = "0.14" +cfb = "0.15" # Writes the MSZIP cabinets the damaged-member tests break. cab = "0.6.0" # only for the base64-embedded-executable end-to-end test -base64 = "0.22" +base64 = "0.23" +# Reads formats/registry.json, which the detection is checked against. +serde_json = "1.0.154" # The reference PPMd8 encoder, for the ZIP method-98 end-to-end test. ppmd-rust = "1" # Encodes the images the `fuzzy_img#` tests scan. image = { version = "=0.25.9", default-features = false, features = ["png", "webp"] } # The unit tests of the vendored `src/fancy_regex/`, as upstream wrote them. matches = "0.1.10" -quickcheck = { version = "1.0", default-features = false } +quickcheck = { version = "1.1", default-features = false } # Small chunks when reading a source, so tests on small inputs cross many # chunk seams. Features only: which formats are built stays with the # dependency above. diff --git a/crates/exav-core/examples/bc_scan.rs b/crates/exav-core/examples/bc_scan.rs index ac2fd3a3..44928601 100644 --- a/crates/exav-core/examples/bc_scan.rs +++ b/crates/exav-core/examples/bc_scan.rs @@ -15,10 +15,10 @@ fn main() { let mut sources = Vec::new(); for e in std::fs::read_dir(&dir).unwrap().flatten() { let p = e.path(); - if p.extension().map(|x| x == "cbc").unwrap_or(false) { - if let Ok(t) = std::fs::read_to_string(&p) { - sources.push(t); - } + if p.extension().map(|x| x == "cbc").unwrap_or(false) + && let Ok(t) = std::fs::read_to_string(&p) + { + sources.push(t); } } let (rt, triggers) = BytecodeRuntime::standalone(sources); diff --git a/crates/exav-core/examples/diag.rs b/crates/exav-core/examples/diag.rs index d6df19dc..49a6f882 100644 --- a/crates/exav-core/examples/diag.rs +++ b/crates/exav-core/examples/diag.rs @@ -99,6 +99,8 @@ fn main() { } eprintln!(" top fan-out groups (alen gsize hits fanout | best_off coverage%):"); for (alen, gsize, hits, fan, off, cov) in db.engine().scan_diag_groups(&data, ft) { - eprintln!(" alen={alen} gsize={gsize:>5} hits={hits:>7} fanout={fan:>10} | off={off:>4} cov={cov}%"); + eprintln!( + " alen={alen} gsize={gsize:>5} hits={hits:>7} fanout={fan:>10} | off={off:>4} cov={cov}%" + ); } } diff --git a/crates/exav-core/examples/triage_bc.rs b/crates/exav-core/examples/triage_bc.rs index e2c57682..35aeb10e 100644 --- a/crates/exav-core/examples/triage_bc.rs +++ b/crates/exav-core/examples/triage_bc.rs @@ -53,11 +53,7 @@ fn input_for(trigger: &str) -> Vec { .and_then(|s| s.split([',', ';']).next()) .and_then(|s| s.trim().parse::().ok()) .unwrap_or(0); - if target == 1 { - benign_pe() - } else { - benign() - } + if target == 1 { benign_pe() } else { benign() } } const OP_NAMES: &[&str] = &[ @@ -225,5 +221,7 @@ fn main() { "{executed}/{} executed to completion (no unsupported op/API)", rows.len() ); - println!(" of which {ran_clean} clean, {false_pos} set a name (trigger-gated programs run standalone — not false positives)"); + println!( + " of which {ran_clean} clean, {false_pos} set a name (trigger-gated programs run standalone — not false positives)" + ); } diff --git a/crates/exav-core/examples/yara_coverage.rs b/crates/exav-core/examples/yara_coverage.rs index e94c08f2..5db9ddc0 100644 --- a/crates/exav-core/examples/yara_coverage.rs +++ b/crates/exav-core/examples/yara_coverage.rs @@ -97,10 +97,10 @@ fn main() { // A real rule rejection. rule_rejects += 1; let bucket = normalize(err.message()); - if bucket == "external variable / unknown identifier" { - if let Some(id) = extract_ident(err.message()) { - *ext_idents.entry(id).or_default() += 1; - } + if bucket == "external variable / unknown identifier" + && let Some(id) = extract_ident(err.message()) + { + *ext_idents.entry(id).or_default() += 1; } *buckets.entry(bucket).or_default() += 1; } @@ -183,10 +183,10 @@ fn collect(dir: &Path, out: &mut Vec) { let path = entry.path(); if path.is_dir() { collect(&path, out); - } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if ext == "yar" || ext == "yara" { - out.push(path); - } + } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) + && (ext == "yar" || ext == "yara") + { + out.push(path); } } } diff --git a/crates/exav-core/src/authenticode/mod.rs b/crates/exav-core/src/authenticode/mod.rs index 2fdab91f..cb106ccc 100644 --- a/crates/exav-core/src/authenticode/mod.rs +++ b/crates/exav-core/src/authenticode/mod.rs @@ -154,10 +154,10 @@ impl CrbDb { } else { continue; } - if let Some(ref ser) = e.serial { - if ser.trim_start_matches('0') != cser { - continue; - } + if let Some(ref ser) = e.serial + && ser.trim_start_matches('0') != cser + { + continue; } return Some(&e.name); } diff --git a/crates/exav-core/src/bin/exav-wasm.rs b/crates/exav-core/src/bin/exav-wasm.rs index 1dd4310c..908959dc 100644 --- a/crates/exav-core/src/bin/exav-wasm.rs +++ b/crates/exav-core/src/bin/exav-wasm.rs @@ -22,7 +22,7 @@ #![forbid(unsafe_code)] -use exav_core::{analyze, ScanOptions, Scanner, VerdictCategory}; +use exav_core::{ScanOptions, Scanner, VerdictCategory, analyze}; use std::path::{Path, PathBuf}; fn main() { diff --git a/crates/exav-core/src/bytecode/exec.rs b/crates/exav-core/src/bytecode/exec.rs index 4b8e781f..522b604c 100644 --- a/crates/exav-core/src/bytecode/exec.rs +++ b/crates/exav-core/src/bytecode/exec.rs @@ -103,6 +103,9 @@ const MAX_ALLOC: usize = 256 << 20; /// The largest `malloc` ClamAV 1.4.3 grants: it returns NULL from /// `(128 << 20) - 8` bytes up, and so does exav. const MAX_MALLOC: usize = (128 << 20) - 9; +/// Bytes one bulk read or write of memory costs one step: a program that +/// copies megabytes in an instruction is not one step of work. +const BYTES_PER_STEP: u64 = 16; /// Size of `__clambc_pedata`. const PEDATA_LEN: usize = 648; @@ -452,12 +455,22 @@ pub struct Ctx<'a> { pub struct WriteLimits { pub file: u64, pub total: u64, + /// What a run may hold in all: its `malloc`s (a program frees nothing, and + /// a loop of allocations each under the per-allocation limit would take + /// the host's memory long before its steps ran out), the frames of the + /// calls in progress, and the globals it has read. Past it the run stops as + /// incomplete. + pub memory: u64, } +/// What a run may hold unless the scan says otherwise. +pub const DEFAULT_MEMORY: u64 = 256 << 20; + impl WriteLimits { pub const NONE: Self = WriteLimits { file: u64::MAX, total: u64::MAX, + memory: DEFAULT_MEMORY, }; } @@ -491,11 +504,16 @@ struct Machine<'a> { layouts: Vec, usize)>>, /// The program's declared APIs, by `CALL_API` id, resolved once per run. apis: Vec<(u32, Api)>, - /// Globals rendered to bytes once (a global read happens in hot loops, so - /// this must not reallocate per access). - gcache: Vec>, + /// Globals rendered to bytes at their first read (a global read happens in + /// hot loops, so this must not reallocate per access, and a global the + /// program never reads costs nothing). + gcache: Vec>>, /// `malloc` allocations, indexed by region `R_HEAP + i`. heaps: Vec>, + /// What the run holds in all: the `malloc`s, the frames of the calls in + /// progress and the globals read, against `memory`. + held: usize, + memory: usize, /// The in-progress extraction buffer (`write` appends here). extract_cur: Vec, /// Finalized extracted buffers (`extract_new` flushes `extract_cur` here). @@ -646,8 +664,8 @@ impl<'a> Machine<'a> { match self.ctx.globals.values.get(g as usize).map(|v| v.len()) { Some(l) if l >= 2 => self.global_ptr(g), Some(_) => self.ctx.globals.values[g as usize] - .first() - .map(|&v| v as i64) + .get(0) + .map(|v| v as i64) .unwrap_or(0), None => { self.flag(); @@ -714,8 +732,7 @@ impl<'a> Machine<'a> { self.flag(); return R_NULL; }; - if gv.len() >= 2 { - let (offset, target) = (gv[0], gv[1]); + if let (Some(offset), Some(target)) = (gv.get(0), gv.get(1)) { if target & GLOBAL_PREDEFINED != 0 { return match target { GLOBAL_FILESIZE => compose(R_FILESIZE, offset as u32), @@ -764,7 +781,7 @@ impl<'a> Machine<'a> { } if region >= R_GLOBAL { let g = (region - R_GLOBAL) as usize; - let v = self.gcache.get(g).and_then(|b| read_le(b, off as usize, w)); + let v = read_le(self.global_bytes(g), off as usize, w); return v.unwrap_or_else(|| { self.flag(); 0 @@ -815,12 +832,40 @@ impl<'a> Machine<'a> { } } - fn global_bytes(&self, g: usize) -> &[u8] { - self.gcache.get(g).map(|v| v.as_slice()).unwrap_or(&[]) + /// Global `g` as bytes, rendered at its first read and held from then on. + /// One the run has no memory left for ends it as incomplete. + fn global_bytes(&mut self, g: usize) -> &[u8] { + if self.gcache.get(g).is_some_and(Option::is_none) { + let len = self.ctx.globals.values[g].len(); + if !self.hold(len) { + self.gap(); + return &[]; + } + self.gcache[g] = Some(self.ctx.globals.values[g].bytes()); + } + self.gcache.get(g).and_then(|v| v.as_deref()).unwrap_or(&[]) + } + + /// Charges the steps a copy of `n` bytes is worth; the run ends, as it + /// does for any other instruction, when they pass `MAX_STEPS`. + fn charge_bytes(&mut self, n: usize) { + self.steps = self.steps.saturating_add(n as u64 / BYTES_PER_STEP); + } + + /// Takes `n` bytes of the run's memory, or says that none is left. + fn hold(&mut self, n: usize) -> bool { + match self.held.checked_add(n) { + Some(held) if held <= self.memory => { + self.held = held; + true + } + _ => false, + } } /// Read `len` bytes through a pointer (for API arguments and memops). fn read_region(&mut self, p: i64, len: usize) -> Vec { + self.charge_bytes(len.min(MAX_ALLOC)); let (region, off) = (ptr_region(p), ptr_off(p) as usize); let slice = |buf: &[u8]| { buf.get(off..off.saturating_add(len).min(buf.len())) @@ -866,6 +911,27 @@ impl<'a> Machine<'a> { Vec::new() } fn write_region(&mut self, p: i64, bytes: &[u8]) { + self.charge_bytes(bytes.len()); + // Within one region, in a piece: a program that asks for megabytes + // (`get_environment` into a frame) is not megabytes of single stores. + let (region, off) = (ptr_region(p), ptr_off(p) as usize); + let target = if (R_HEAP..R_GLOBAL).contains(®ion) { + self.heaps + .get_mut((region - R_HEAP) as usize) + .map(Vec::as_mut_slice) + } else if (R_STACK..R_HEAP).contains(®ion) { + self.frames + .get_mut((region - R_STACK) as usize) + .map(|f| f.stack.as_mut_slice()) + } else { + None + }; + if let Some(to) = target.and_then(|t| t.get_mut(off..off.checked_add(bytes.len())?)) { + to.copy_from_slice(bytes); + return; + } + // Past the end of the region (the first byte there ends the run, with + // the bytes before it written) or in none. for (i, &b) in bytes.iter().enumerate() { self.store_int( compose(ptr_region(p), ptr_off(p).wrapping_add(i as u32)), @@ -875,7 +941,7 @@ impl<'a> Machine<'a> { } } - fn read_cstring(&self, p: i64) -> Option { + fn read_cstring(&mut self, p: i64) -> Option { let (region, off) = (ptr_region(p), ptr_off(p) as usize); let take = |b: &[u8]| -> Option { let s: Vec = b @@ -1088,6 +1154,10 @@ impl<'a> Machine<'a> { if self.trace { eprintln!("[trace] malloc({size}) -> heap#{}", self.heaps.len()); } + if !self.hold(size as usize) { + self.gap(); + return 0; + } let idx = self.heaps.len(); self.heaps.push(vec![0u8; size as usize]); compose(R_HEAP + idx as i64, 0) @@ -1414,7 +1484,7 @@ impl<'a> Machine<'a> { let types = self.ctx.types; let (slots, nbytes) = self.layouts[idx].get_or_insert_with(|| layout(f, types)); let (slots, nbytes) = (Rc::clone(slots), *nbytes); - if nbytes > MAX_BYTES { + if nbytes > MAX_BYTES || !self.hold(nbytes) { self.gap(); return 0; } @@ -1472,6 +1542,7 @@ impl<'a> Machine<'a> { } } self.frames.pop(); + self.held -= nbytes; ret } @@ -1851,19 +1922,15 @@ pub fn run_without_pe_data(funcs: &[Function], entry: usize, ctx: &Ctx) -> Outco } fn run_inner(funcs: &[Function], entry: usize, ctx: &Ctx, pe_missing: bool) -> Outcome { - let gcache: Vec> = ctx - .globals - .values - .iter() - .map(|v| v.iter().map(|&c| c as u8).collect()) - .collect(); let mut m = Machine { ctx, frames: Vec::new(), layouts: Vec::new(), apis: resolve_apis(ctx), - gcache, + gcache: vec![None; ctx.globals.values.len()], heaps: Vec::new(), + held: 0, + memory: usize::try_from(ctx.write_limits.memory).unwrap_or(usize::MAX), extract_cur: Vec::new(), extracted: Vec::new(), written: 0, @@ -1910,7 +1977,7 @@ mod tests { use crate::bytecode::instr::{ Body, Function, Inst, OP_BRANCH, OP_CALL_API, OP_JMP, OP_RET, OP_RET_VOID, }; - use crate::bytecode::types::{Globals, TypeTable}; + use crate::bytecode::types::{Global, Globals, TypeTable}; fn detector() -> Vec { let blocks = vec![ @@ -2011,13 +2078,10 @@ mod tests { }], layouts: Vec::new(), apis: resolve_apis(c), - gcache: c - .globals - .values - .iter() - .map(|v| v.iter().map(|&x| x as u8).collect()) - .collect(), + gcache: vec![None; c.globals.values.len()], heaps: Vec::new(), + held: 0, + memory: usize::try_from(c.write_limits.memory).unwrap_or(usize::MAX), extract_cur: Vec::new(), extracted: Vec::new(), written: 0, @@ -2347,12 +2411,75 @@ mod tests { assert!(!m.unsupported && !m.gap); } + /// A program frees nothing, so allocations that each fit are held until + /// the run ends: past a total the run stops as incomplete, rather than the + /// host running out of memory first. + #[test] + fn globals_of_zeros_cost_nothing_until_read_and_then_count_against_the_memory() { + // A thousand globals of 16.7 million components, as 5 KB of signature + // declares them: each is 16 MiB once read. + let globals = Globals { + values: (0..1000).map(|_| Global::zeros(1 << 24)).collect(), + }; + let (apis, types) = (apis(), TypeTable::default()); + let mut c = ctx(b"", &apis, &globals, &types); + c.write_limits.memory = 100 << 20; + let mut m = machine(&c); + assert_eq!(m.held, 0, "nothing is made before a read"); + for g in 0..6 { + assert_eq!(m.global_bytes(g).len(), 1 << 24); + } + assert!(!m.gap, "six of them are inside 100 MiB"); + assert!(m.global_bytes(6).is_empty()); + assert!(m.gap && m.halt, "the seventh ends the run as incomplete"); + // One read twice is held once. + assert_eq!(m.held, 6 << 24); + assert_eq!(m.global_bytes(0).len(), 1 << 24); + assert_eq!(m.held, 6 << 24); + } + + /// 16 MiB written by one call used to be 16 million single-byte stores that + /// no step counted: a loop of calls ran for years inside the step limit. + #[test] + fn a_bulk_write_is_one_copy_and_costs_steps_by_its_size() { + let (apis, globals, types) = (apis(), Globals::default(), TypeTable::default()); + let c = ctx(b"", &apis, &globals, &types); + let mut m = machine(&c); + let p = m.call_api(0, Api::Malloc, &[Operand::Const(16 << 20)]); + let before = m.steps; + let started = std::time::Instant::now(); + m.write_region(p, &vec![7u8; 16 << 20]); + assert!(started.elapsed().as_millis() < 500, "{:?}", started.elapsed()); + assert_eq!(m.steps - before, (16 << 20) / BYTES_PER_STEP); + assert!(!m.halt); + assert_eq!(m.read_region(p, 4), [7, 7, 7, 7]); + // The bytes before the end of the region are written, and the first + // one past it ends the run. + m.write_region(p, &vec![9u8; (16 << 20) + 1]); + assert!(m.halt); + assert_eq!(m.read_region(p, 1), [9]); + } + + #[test] + fn malloc_in_all_past_the_heap_cap_stops_the_run() { + let (apis, globals, types) = (apis(), Globals::default(), TypeTable::default()); + let c = ctx(b"", &apis, &globals, &types); + let mut m = machine(&c); + let each = Operand::Const(100 << 20); + assert_ne!(m.call_api(0, Api::Malloc, &[each]), 0); + assert_ne!(m.call_api(0, Api::Malloc, &[each]), 0); + assert!(!m.halt); + assert_eq!(m.call_api(0, Api::Malloc, &[each]), 0); + assert!(m.gap && m.halt, "the run ends as incomplete"); + assert_eq!(m.held, 200 << 20); + } + /// Output past exav's own cap is not dropped silently: the run stops /// as incomplete. #[test] fn write_past_the_extraction_cap_is_a_gap() { let globals = Globals { - values: vec![vec![0, 1], vec![0x41, 0x42]], + values: vec![vec![0, 1].into(), vec![0x41, 0x42].into()], }; let (apis, types) = (apis(), TypeTable::default()); let c = ctx(b"", &apis, &globals, &types); @@ -2372,13 +2499,17 @@ mod tests { #[test] fn write_past_the_scan_limits_fails_and_the_run_goes_on() { let globals = Globals { - values: vec![vec![0, 1], vec![0x41, 0x42, 0x43]], + values: vec![vec![0, 1].into(), vec![0x41, 0x42, 0x43].into()], }; let (apis, types) = (apis(), TypeTable::default()); let args = [Operand::Global(0), Operand::Const(3)]; // Three bytes to one file, seven in all. let c = Ctx { - write_limits: WriteLimits { file: 3, total: 7 }, + write_limits: WriteLimits { + file: 3, + total: 7, + ..WriteLimits::NONE + }, ..ctx(b"", &apis, &globals, &types) }; let mut m = machine(&c); @@ -2447,10 +2578,37 @@ mod tests { assert!(out.incomplete, "a program cut short is not one that found nothing"); } + /// Each call's frame may be large and a program may recurse deep, so the + /// frames in progress together are bounded: past it the run ends as + /// incomplete, and the frames of calls that returned are given back. + #[test] + fn frames_in_all_are_bounded() { + let funcs = vec![Function { + num_args: 0, + return_type: 0, + types: vec![32], + num_insts: 1, + num_bb: 1, + blocks: vec![vec![]], + }]; + let (apis, globals, types) = (Vec::new(), Globals::default(), TypeTable::default()); + let c = ctx(b"", &apis, &globals, &types); + let mut m = machine(&c); + m.exec_fn(&funcs, 0, &[], 0); + assert!(!m.gap && m.held == 0, "a returned call's frame is given back"); + m.held = m.memory; + m.exec_fn(&funcs, 0, &[], 1); + assert!(m.gap && m.halt, "no room left for another frame"); + } + #[test] fn resolves_global_string() { let globals = Globals { - values: vec![vec![0], vec![69, 105, 99, 97, 114, 0], vec![0, 1]], + values: vec![ + vec![0].into(), + vec![69, 105, 99, 97, 114, 0].into(), + vec![0, 1].into(), + ], }; let (apis, types) = (Vec::<(u32, String)>::new(), TypeTable::default()); let c = ctx(b"", &apis, &globals, &types); @@ -2462,7 +2620,7 @@ mod tests { #[test] fn resolves_filesize_global() { let globals = Globals { - values: vec![vec![0, GLOBAL_FILESIZE]], + values: vec![vec![0, GLOBAL_FILESIZE].into()], }; let (apis, types) = (Vec::<(u32, String)>::new(), TypeTable::default()); let c = ctx(b"12345678", &apis, &globals, &types); diff --git a/crates/exav-core/src/bytecode/runtime.rs b/crates/exav-core/src/bytecode/runtime.rs index 45075185..56b81ec3 100644 --- a/crates/exav-core/src/bytecode/runtime.rs +++ b/crates/exav-core/src/bytecode/runtime.rs @@ -224,11 +224,10 @@ impl BytecodeRuntime { let mut out = Vec::new(); for (idx, bc) in self.programs.iter().enumerate() { let o = run_program(bc, &data, pe.as_ref(), pe_missing, Some(&pdf), &[], exec::WriteLimits::NONE); - if !o.hit_unsupported { - if let Some(d) = o.detection { + if !o.hit_unsupported + && let Some(d) = o.detection { out.push((d, idx)); } - } } out } @@ -257,11 +256,10 @@ fn take_outcome( if o.hit_unsupported { return; } - if det.is_none() { - if let Some(d) = o.detection { + if det.is_none() + && let Some(d) = o.detection { *det = Some((d, idx)); } - } } /// Run one program's entry function (0) under a bounded, panic-isolated VM. diff --git a/crates/exav-core/src/bytecode/types.rs b/crates/exav-core/src/bytecode/types.rs index c4fde8a7..b1a51bfa 100644 --- a/crates/exav-core/src/bytecode/types.rs +++ b/crates/exav-core/src/bytecode/types.rs @@ -14,6 +14,8 @@ const START_TID: u32 = 69; /// Predefined pointer types occupying ids 65..69. const NUM_STATIC: usize = (START_TID - 65) as usize; const MAX_TYPES: usize = 1 << 20; +/// Most components one global may declare. The memory a run takes for what it +/// reads of them is counted against the run's memory limit as it is read. const MAX_GLOBAL_COMPONENTS: usize = 1 << 24; #[derive(Debug, Clone)] @@ -261,7 +263,53 @@ fn read_type_id(r: &mut Reader) -> Result { /// Constant globals: a flat list, per global a list of 64-bit components. #[derive(Debug, Clone, Default)] pub struct Globals { - pub values: Vec>, + pub values: Vec, +} + +/// One global's components. A zero initializer is two bytes of text whatever +/// the size of the global, so a global of zeros is kept as its length and +/// holds nothing until it is read. +#[derive(Debug, Clone, Default)] +pub struct Global { + len: usize, + /// The components, or nothing for a global of zeros. + init: Vec, +} + +impl Global { + /// A global of `len` zeros. + pub fn zeros(len: usize) -> Global { + Global { len, init: vec![] } + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn get(&self, i: usize) -> Option { + (i < self.len).then(|| self.init.get(i).copied().unwrap_or(0)) + } + + /// The first byte of each component. + pub fn bytes(&self) -> Vec { + if self.init.is_empty() { + return vec![0u8; self.len]; + } + self.init.iter().map(|&c| c as u8).collect() + } +} + +impl From> for Global { + fn from(init: Vec) -> Global { + Global { + len: init.len(), + init, + } + } } impl Globals { @@ -275,7 +323,7 @@ impl Globals { if num > MAX_TYPES { return Err(perr("too many globals")); } - let mut values = Vec::with_capacity(num); + let mut values = Vec::with_capacity(num.min(rec.len())); for _ in 0..num { let tid = read_type_id(&mut r)?; let comp = types.components(tid)?; @@ -290,15 +338,16 @@ impl Globals { /// Read `comp` constant components, or a zero-initializer (`@\``), terminated /// by a `` ` `` byte. -fn read_constant(r: &mut Reader, comp: usize) -> Result, ParseError> { +fn read_constant(r: &mut Reader, comp: usize) -> Result { let m = |_| perr("truncated constant"); // Zero initializer: bytes 0x40 ('@') 0x60 ('`'). if r.peek() == Some(0x40) && r.peek_at(1) == Some(0x60) { r.byte().map_err(m)?; r.byte().map_err(m)?; - return Ok(vec![0u64; comp]); + return Ok(Global::zeros(comp)); } - let mut out = Vec::with_capacity(comp); + // The text bounds what is read, not the declared count. + let mut out = Vec::with_capacity(comp.min(1 << 16)); while r.peek() != Some(0x60) { if out.len() >= comp { return Err(perr("constant has too many components")); @@ -320,7 +369,7 @@ fn read_constant(r: &mut Reader, comp: usize) -> Result, ParseError> { if out.len() != comp { return Err(perr("constant has too few components")); } - Ok(out) + Ok(out.into()) } fn read_nibble(r: &mut Reader) -> Result { @@ -330,3 +379,4 @@ fn read_nibble(r: &mut Reader) -> Result { } Ok(b & 0x0f) } + diff --git a/crates/exav-core/src/cvd.rs b/crates/exav-core/src/cvd.rs index 872c48f5..5e719cf9 100644 --- a/crates/exav-core/src/cvd.rs +++ b/crates/exav-core/src/cvd.rs @@ -84,6 +84,21 @@ pub fn read_with_limits( let header = parse_header(data)?; let payload = &data[512..]; + // A `.cvd` names the md5 of the bytes after its header. A file cut short by + // an interrupted download or a full disk, or damaged since, no longer + // matches, and the decompressor would hand back the part it could read: a + // database with signatures missing and nothing to say so. (A `.cld`, a tar + // that updates rewrite in place, does not carry the md5 of its own body.) + if payload.starts_with(&[0x1f, 0x8b]) { + let got = md5_hex(payload); + if !header.md5.trim().eq_ignore_ascii_case(&got) { + return Err(format!( + "cvd is damaged or incomplete: its header says md5 {}, its contents are {got}", + header.md5.trim() + )); + } + } + // A CVD payload is a gzip-compressed tar; a CLD's is a raw tar. Both are // unpacked through exav-unpack's gzip/tar extractors. The signature container // is TRUSTED, so the budget only caps total size/count to reject a corrupt or @@ -120,6 +135,11 @@ pub fn read_with_limits( Ok((header, files)) } +fn md5_hex(data: &[u8]) -> String { + use md5::{Digest, Md5}; + crate::hexsig::encode_hex(&Md5::digest(data)) +} + /// A permissive [`Budget`] for unpacking the TRUSTED CVD/CLD container: it caps /// total bytes + file count (so a corrupt or maliciously-oversized database is /// rejected rather than OOMing the loader) but disables the ratio / scan-byte / @@ -164,10 +184,18 @@ mod tests { gz.write_all(&tar_buf).unwrap(); let payload = gz.finish().unwrap(); - let mut header = "ClamAV-VDB:01 Jan 2026 00-00 +0000:58:1234:90:abcdef0123456789:dsig:exav-test:1700000000".to_string() + header_for(&payload, 1234) + } + + /// A `.cvd` around `payload`: its header names the payload's md5. + fn header_for(payload: &[u8], sigs: u32) -> Vec { + let mut header = format!( + "ClamAV-VDB:01 Jan 2026 00-00 +0000:58:{sigs}:90:{}:dsig:exav-test:1700000000", + md5_hex(payload) + ) .into_bytes(); header.resize(512, b' '); - header.extend_from_slice(&payload); + header.extend_from_slice(payload); header } @@ -203,13 +231,7 @@ mod tests { let mut gz = GzEncoder::new(Vec::new(), Compression::default()); gz.write_all(&tar_buf).unwrap(); let payload = gz.finish().unwrap(); - let mut header = - "ClamAV-VDB:01 Jan 2026 00-00 +0000:58:3:90:abcdef0123456789:dsig:exav-test:1700000000" - .to_string() - .into_bytes(); - header.resize(512, b' '); - header.extend_from_slice(&payload); - header + header_for(&payload, 3) } // Regression for the per-entry-only budget (review finding C1): the @@ -225,4 +247,32 @@ mod tests { assert!(read_with_limits(&cvd, &limits).is_err()); assert!(read(&cvd).is_ok()); // default limits accept it } + + // A database cut short or damaged in place loads fewer signatures without + // saying so, unless its header's md5 is checked. + #[test] + fn a_cvd_cut_short_or_damaged_is_refused() { + let cvd = make_cvd(); + assert!(read(&cvd).is_ok()); + // Cut inside the compressed payload: the unpacker would still return + // the files it reached. + for cut in [cvd.len() - 1, cvd.len() - 20, 512 + 40] { + let e = read(&cvd[..cut]).err().unwrap_or_default(); + assert!(e.contains("damaged or incomplete"), "cut at {cut}: {e:?}"); + } + let mut flipped = cvd.clone(); + let at = flipped.len() - 12; + flipped[at] ^= 1; + let e = read(&flipped).err().unwrap_or_default(); + assert!(e.contains("damaged or incomplete"), "{e:?}"); + } + + #[test] + fn the_shipped_header_md5_is_the_md5_of_the_body() { + // The rule the check relies on, against a real container's layout: the + // md5 is of everything after the 512-byte header. + let cvd = make_cvd(); + let hdr = parse_header(&cvd).unwrap(); + assert_eq!(hdr.md5, md5_hex(&cvd[512..])); + } } diff --git a/crates/exav-core/src/database.rs b/crates/exav-core/src/database.rs index 4a438584..08a190d1 100644 --- a/crates/exav-core/src/database.rs +++ b/crates/exav-core/src/database.rs @@ -16,11 +16,11 @@ use std::path::Path; use crc32fast::Hasher as Crc32; +use crate::Scanner; use crate::engine::SigEngine; use crate::fuzzy::FuzzyDb; use crate::hashes::{HashDb, SectionHashDb}; use crate::ml::HeuristicModel; -use crate::Scanner; /// 8-byte format tag for the `.exavdb` prebuilt database. The 6-char ASCII name /// plus two fixed bytes; the format *serial* lives in the separate [`VERSION`] @@ -677,7 +677,7 @@ pub fn load(path: &Path) -> io::Result { #[cfg(test)] mod tests { - use crate::{analyze, ScanOptions, Verdict}; + use crate::{ScanOptions, Verdict, analyze}; /// What the indexed encoder writes, rmp-serde reads back as the same /// value, every shape serde has; and no variant goes by its name. diff --git a/crates/exav-core/src/dlp.rs b/crates/exav-core/src/dlp.rs index f16cc0a4..19a0daaa 100644 --- a/crates/exav-core/src/dlp.rs +++ b/crates/exav-core/src/dlp.rs @@ -440,7 +440,7 @@ mod tests { #[test] fn integration_structured_cc_verdict() { - use crate::{analyze, Method, ScanOptions, Scanner, Verdict}; + use crate::{Method, ScanOptions, Scanner, Verdict, analyze}; let db = Scanner::builtin(); let mut buf = Vec::new(); for card in [VISA, MC, AMEX, DISCOVER] { @@ -469,7 +469,7 @@ mod tests { #[test] fn counts_read_in_blocks_are_the_counts_from_memory() { - use crate::byte_source::{BlockCache, Stepper, CHUNK}; + use crate::byte_source::{BlockCache, CHUNK, Stepper}; let plants: &[&[u8]] = &[ VISA, MC, diff --git a/crates/exav-core/src/engine/mod.rs b/crates/exav-core/src/engine/mod.rs index f95e4420..64e29ec1 100644 --- a/crates/exav-core/src/engine/mod.rs +++ b/crates/exav-core/src/engine/mod.rs @@ -432,6 +432,53 @@ pub fn reset_scan_truncated() { SCAN_TRUNCATED.with(|c| c.set(false)); SCAN_OVER_SIZE.with(|r| r.borrow_mut().take()); SCAN_CAPPED.with(|r| r.borrow_mut().take()); + CLEAN_SEEN.with(|m| m.borrow_mut().clear()); +} + +/// Most objects remembered as scanned clean in one scan. +const CLEAN_SEEN_MAX: usize = 4096; + +thread_local! { + /// Objects this scan has scanned to the end and found clean, by a digest of + /// their bytes and of everything about where they sit that a scan reads, + /// with the shallowest depth one was scanned at. Cleared with the + /// truncation flags, at the start of each top-level scan. + static CLEAN_SEEN: RefCell> = + RefCell::new(std::collections::HashMap::new()); +} + +/// Whether an object with this digest was already scanned clean at `depth` or +/// shallower. Deeper copies are not covered: the walk has fewer levels left +/// there, so the same bytes could be reported over the limit. +pub(crate) fn clean_seen(key: &[u8; 32], depth: u32) -> bool { + let seen = CLEAN_SEEN.with(|m| m.borrow().get(key).is_some_and(|&d| d <= depth)); + #[cfg(test)] + if seen { + CLEAN_SKIPS.with(|c| c.set(c.get() + 1)); + } + seen +} + +#[cfg(test)] +thread_local! { + /// How many objects [`clean_seen`] let a scan skip on this thread. + pub(crate) static CLEAN_SKIPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Remember an object scanned clean at `depth`. +pub(crate) fn clean_seen_insert(key: [u8; 32], depth: u32) { + CLEAN_SEEN.with(|m| { + let mut m = m.borrow_mut(); + if m.len() < CLEAN_SEEN_MAX || m.contains_key(&key) { + m.entry(key).and_modify(|d| *d = (*d).min(depth)).or_insert(depth); + } + }); +} + +/// The container types above the object being scanned, outermost first, as +/// `Intermediates:` reads them. +pub(crate) fn ancestry_snapshot() -> Vec> { + ANCESTRY.with(|a| a.borrow().clone()) } /// Record that carving reached its cap with more to carve, and why. Unlike a @@ -1717,21 +1764,18 @@ impl Ldb { SubSig::Bcomp(b) => { if b.trigger < n && c[b.trigger] > 0 { // Offset anchored at the trigger subsig's first match. - if let SubSig::Bodies(ids) = &self.subs[b.trigger] { - if let Some(off) = ids.iter().filter_map(|&id| body_off(id)).min() { - if b.matches(buf.src, off) { + if let SubSig::Bodies(ids) = &self.subs[b.trigger] + && let Some(off) = ids.iter().filter_map(|&id| body_off(id)).min() + && b.matches(buf.src, off) { c[i] = 1; } - } - } } } SubSig::Fuzzy(h, dist) => { - if let Some(ih) = img_hash { - if hamming64(&ih, h) <= *dist { + if let Some(ih) = img_hash + && hamming64(&ih, h) <= *dist { c[i] = 1; } - } } SubSig::Bodies(_) => {} } @@ -2228,11 +2272,10 @@ impl EngineBuilder { match &body.elems { Some(elems) => { for e in elems { - if let Elem::Bytes(b) = e { - if b.len() >= MIN_ANCHOR { + if let Elem::Bytes(b) = e + && b.len() >= MIN_ANCHOR { f(b); } - } } } None => { @@ -2483,7 +2526,7 @@ impl Groups { fn valid(&self, n: usize) -> bool { self.start.first() == Some(&0) && self.start.last().is_some_and(|&e| e as usize == self.bodies.len()) - && self.start.windows(2).all(|w| w[0] <= w[1]) + && self.start.array_windows().all(|[a, b]| a <= b) && self.bodies.iter().all(|&b| (b as usize) < n) } } @@ -3262,11 +3305,10 @@ impl SigEngine { total += count as u64; return true; } - if let Some(hits) = buffered.take() { - if v.replay(&hits, None) { + if let Some(hits) = buffered.take() + && v.replay(&hits, None) { return false; } - } !v.hit(pidx, value, start, count, None) }; // The pinned bodies first, each a group of its own in the @@ -3449,11 +3491,10 @@ impl SigEngine { } let body_off = |b: usize| sc.first_offset(b); if let Some(program) = ldb.bytecode { - if let Some(fired) = fired.as_deref_mut() { - if ldb.eval(&hay,&|b| sc.counts[b], &body_off, img_hash, icon_ctx) { + if let Some(fired) = fired.as_deref_mut() + && ldb.eval(&hay,&|b| sc.counts[b], &body_off, img_hash, icon_ctx) { fired.push((program, sub_offsets(ldb, &sc))); } - } continue; } if ldb.eval(&hay,&|b| sc.counts[b], &body_off, img_hash, icon_ctx) { @@ -3582,22 +3623,20 @@ impl SigEngine { continue; } if let Some(program) = ldb.bytecode { - if let Some(fired) = fired.as_deref_mut() { - if ldb.eval(&hay,&|b| sc.counts[b], &body_off, img_hash, icon_ctx) { + if let Some(fired) = fired.as_deref_mut() + && ldb.eval(&hay,&|b| sc.counts[b], &body_off, img_hash, icon_ctx) { fired.push((program, sub_offsets(ldb, &sc))); } - } continue; } // A `HandlerType:` signature re-types rather than alerts, so it has // no name to collect, but the re-type itself must still be recorded, // or `--all-matches` runs a different signature set than a normal scan. if ldb.handler_type.is_some() { - if let Some(ft) = ldb.handler_type { - if ldb.eval(&hay,&|b| sc.counts[b], &body_off, img_hash, icon_ctx) { + if let Some(ft) = ldb.handler_type + && ldb.eval(&hay,&|b| sc.counts[b], &body_off, img_hash, icon_ctx) { set_retype(src, ft); } - } continue; } if ldb.eval(&hay,&|b| sc.counts[b], &body_off, img_hash, icon_ctx) @@ -4030,7 +4069,7 @@ fn verify_inner( // run hits thousands of times per file, and all but the pinned hit are // rejected without running it. macro_rules! sim { - ($toks:expr, $at:expr) => {{ + ($toks:expr_2021, $at:expr_2021) => {{ if *budget == 0 { SCAN_TRUNCATED.with(|c| c.set(true)); return None; @@ -4208,9 +4247,9 @@ fn match_forward( // SIMD-search for each occurrence of that literal within the gap // window and recurse only there. Case-sensitive only (memmem can't // fold case); the `nocase` path keeps the scalar fallback. - if !nocase { - if let Some((Elem::Bytes(lit), tail)) = rest.split_first() { - if !lit.is_empty() { + if !nocase + && let Some((Elem::Bytes(lit), tail)) = rest.split_first() + && !lit.is_empty() { let lo = pos + *min; let last_start = pos + hi; // literal may start up to here if lo > last_start || lo >= buf.len() { @@ -4252,8 +4291,6 @@ fn match_forward( } return false; } - } - } for g in *min..=hi { if match_forward(rest, buf, pos + g, nocase, budget) { return true; @@ -4347,11 +4384,10 @@ fn match_backward( } Elem::AltMasked { opts } => { for o in opts { - if end >= o.len() && masked_eq(o, &buf.window(end - o.len(), o.len()), nocase) { - if let Some(s) = match_backward(rest, buf, end - o.len(), nocase, budget) { + if end >= o.len() && masked_eq(o, &buf.window(end - o.len(), o.len()), nocase) + && let Some(s) = match_backward(rest, buf, end - o.len(), nocase, budget) { return Some(s); } - } if *budget == 0 { SCAN_TRUNCATED.with(|c| c.set(true)); return None; @@ -4373,11 +4409,10 @@ fn match_backward( match_backward(rest, buf, end - l, nocase, budget) } else { for o in opts { - if end >= o.len() && bytes_eq(&buf.window(end - o.len(), o.len()), o, nocase) { - if let Some(s) = match_backward(rest, buf, end - o.len(), nocase, budget) { + if end >= o.len() && bytes_eq(&buf.window(end - o.len(), o.len()), o, nocase) + && let Some(s) = match_backward(rest, buf, end - o.len(), nocase, budget) { return Some(s); } - } if *budget == 0 { return None; } @@ -4534,12 +4569,11 @@ const MAX_REACH: usize = 1 << 20; /// interval when they touch it. `false`, with the scan flagged incomplete, /// when the set is full. fn reach_push(out: &mut Vec<(usize, usize)>, lo: usize, hi: usize) -> bool { - if let Some(last) = out.last_mut() { - if lo >= last.0 && lo <= last.1.saturating_add(1) { + if let Some(last) = out.last_mut() + && lo >= last.0 && lo <= last.1.saturating_add(1) { last.1 = last.1.max(hi); return true; } - } if out.len() >= MAX_REACH { SCAN_TRUNCATED.with(|c| c.set(true)); return false; diff --git a/crates/exav-core/src/engine/parse.rs b/crates/exav-core/src/engine/parse.rs index eeca2b77..76e3e5f8 100644 --- a/crates/exav-core/src/engine/parse.rs +++ b/crates/exav-core/src/engine/parse.rs @@ -614,8 +614,8 @@ pub(super) fn pick_anchor( let mut best_fixed: Option<(isize, usize, Prefix)> = None; let mut best_any: Option<(isize, usize, usize)> = None; // (score, len, idx) for (i, e) in elems.iter().enumerate() { - if let Elem::Bytes(b) = e { - if b.len() >= MIN_ANCHOR { + if let Elem::Bytes(b) = e + && b.len() >= MIN_ANCHOR { let score = anchor_score(b, stats); let better = |cur: &Option<(isize, usize, Prefix)>| match cur { Some((s, l, _)) => score > *s || (score == *s && b.len() > *l), @@ -625,11 +625,9 @@ pub(super) fn pick_anchor( // no fixed-prefix one, instead of one at a wrapped distance. if let (true, Ok(len), Ok(anchor_idx)) = (in_fixed_prefix, u32::try_from(fixed), u32::try_from(i)) - { - if better(&best_fixed) { + && better(&best_fixed) { best_fixed = Some((score, b.len(), Prefix::Fixed { anchor_idx, len })); } - } let any_better = match &best_any { Some((s, l, _)) => score > *s || (score == *s && b.len() > *l), None => true, @@ -638,15 +636,14 @@ pub(super) fn pick_anchor( best_any = Some((score, b.len(), i)); } } - } match e.width() { Some(w) => fixed = fixed.saturating_add(w), None => in_fixed_prefix = false, // past the first variable element } } // Prefer an internal anchor only when it beats the fixed-prefix one. - if allow_internal { - if let Some((any_score, _, idx)) = best_any { + if allow_internal + && let Some((any_score, _, idx)) = best_any { // No fixed-prefix candidate at all must lose to every real one: // scores are signed, so a `0` floor here would reject a merely // common internal anchor and leave the body with no anchor — i.e. @@ -663,18 +660,15 @@ pub(super) fn pick_anchor( } } } - } if let Some((.., p)) = best_fixed { return Some(p); } // Floating: a single leading variable gap, then a usable literal. - if matches!(elems.first(), Some(Elem::Gap { .. })) { - if let Some(Elem::Bytes(b)) = elems.get(1) { - if b.len() >= MIN_ANCHOR { + if matches!(elems.first(), Some(Elem::Gap { .. })) + && let Some(Elem::Bytes(b)) = elems.get(1) + && b.len() >= MIN_ANCHOR { return Some(Prefix::Floating { anchor_idx: 1 }); } - } - } None } @@ -881,14 +875,13 @@ fn digits_or_empty(s: &str) -> Option { pub(super) fn parse_tdb_engine(tdb: &str) -> (u32, u32) { for field in tdb.split(',') { - if let Some(v) = field.trim().strip_prefix("Engine:") { - if let Some((a, b)) = v.trim().split_once('-') { + if let Some(v) = field.trim().strip_prefix("Engine:") + && let Some((a, b)) = v.trim().split_once('-') { return ( a.trim().parse().unwrap_or(0), b.trim().parse().unwrap_or(u32::MAX), ); } - } } (0, u32::MAX) } diff --git a/crates/exav-core/src/fancy_regex/README.md b/crates/exav-core/src/fancy_regex/README.md index 7784fbab..98cd4a9f 100644 --- a/crates/exav-core/src/fancy_regex/README.md +++ b/crates/exav-core/src/fancy_regex/README.md @@ -33,13 +33,29 @@ It is vendored rather than depended on because a crate published on crates.io can only depend on crates.io releases: `cargo install exav` would build the release without the fixes. -## Two fixes of exav's own +## Fixes of exav's own + +0.20.0 (2026-10-24) has the two pull requests above and, on its own, the last +two fixes below (`utf8_empty(false)` is in its `compile.rs`, the ASCII word +boundaries in its `vm.rs`). Run against it, `cargo test -p exav-core pcre` +passes but for the first fix. + +`analyze.rs`, `compile.rs`: the size analysis adds and multiplies with +saturating arithmetic where 0.19.2 and 0.20.0 use `+` and `*` for the minimum +size (`min_size * lo` of a repeat, the sums over a conditional and over the +children of a sequence, `go_back` of a lookbehind). A signature of nested +repeat counts, `((((a{65535}){65535}){65535}){65535}){65535}\1`, overflows +`usize`: a panic where overflow checks are on (this workspace's release +profile has them), a wrong size where they are off. Pinned by +`nested_repeat_counts_do_not_overflow_the_size_analysis` in `engine/pcre.rs`, +which panics inside 0.20.0's `analyze.rs`. Not yet reported upstream. `compile.rs`: the delegated regex-automata engines are configured with `utf8_empty(false)` outside `BytesMode::Unicode`. With its default (`true`) an engine skips an empty match at a position inside what would be a UTF-8 sequence, so a delegated `x*` found no match before a byte over 0x7F, and `(\bb)x*` did not match `b\x9e`. Pinned by the same test as the next fix. +Fixed in 0.20.0. `vm.rs`: in `BytesMode::Ascii`, `\b`, `\B` and `\b{start}`/`\b{end}` (and their half forms) read a word character as an ASCII one, as `\w` does. 0.19.2 asks @@ -48,8 +64,7 @@ the position as UTF-8: `\bab` did not match `1\x9aab` (the byte `\x9a` was read with the `1` before it), where `regex` and PCRE2 find a boundary after `\x9a`, no word character. Found by `fuzz/fuzz_targets/pcre.rs`, pinned by `a_word_boundary_beside_a_high_byte_reads_the_same_in_both_engines` in -`engine/pcre.rs`. Not yet reported upstream: when it is, say so here, and drop -the change once a release carries the fix. +`engine/pcre.rs`. Fixed in 0.20.0. ## What differs from upstream's `src/` @@ -70,15 +85,18 @@ and do not run here. ## Going back to the crates.io release -Once a release has both fixes (0.19.3 or later): +Once a release has the size analysis fix above. 0.20.0 has everything else +(with `variable-lookbehinds` in its features, which `exav-core` needs) and fails +`nested_repeat_counts_do_not_overflow_the_size_analysis`. It also depends on +`bit-set` 0.8, so the direct `bit-set` 0.11 goes with the copy. 1. Delete this directory, and in `lib.rs` the `mod fancy_regex` declaration and `extern crate alloc`. -2. In `Cargo.toml`, add `fancy-regex = "0.19.3"`, and remove `bit-set`, the +2. In `Cargo.toml`, add `fancy-regex` with `default-features = false` and the + features `std`, `unicode`, `perf` and `variable-lookbehinds`, and remove `bit-set`, the `matches` and `quickcheck` dev-dependencies, and `[lib] doctest = false`. 3. `crate::fancy_regex::` becomes `fancy_regex::` in `engine/mod.rs` and `engine/pcre.rs`. -4. `cargo test -p exav-core pcre`: the cases there with a byte above 0x7F in a - lookaround or back reference fail without the fixes, and - `a_word_boundary_beside_a_high_byte_reads_the_same_in_both_engines` fails - unless the release also has the ASCII word boundary fix above. +4. `cargo test -p exav-core pcre`, then the `pcre` fuzz target for a few + minutes: the cases with a byte above 0x7F in a lookaround or back reference, + and the word boundary ones, are what the fixes above are pinned by. diff --git a/crates/exav-core/src/fancy_regex/analyze.rs b/crates/exav-core/src/fancy_regex/analyze.rs index 4add9395..44edbd5f 100644 --- a/crates/exav-core/src/fancy_regex/analyze.rs +++ b/crates/exav-core/src/fancy_regex/analyze.rs @@ -579,7 +579,7 @@ impl<'a> Analyzer<'a> { Expr::Absent(ref absent) => { use crate::fancy_regex::Absent::*; match absent { - Repeater(ref child) => { + Repeater(child) => { let child_info = self.visit(child, min_pos_in_group, inside_zero_rep, enclosing_group)?; min_size = 0; @@ -589,8 +589,8 @@ impl<'a> Analyzer<'a> { children.push(child_info); } Expression { - ref absent, - ref exp, + absent, + exp, } => { let absent_info = self.visit(absent, min_pos_in_group, inside_zero_rep, enclosing_group)?; @@ -603,7 +603,7 @@ impl<'a> Analyzer<'a> { children.push(absent_info); children.push(exp_info); } - Stopper(ref child) => { + Stopper(child) => { let child_info = self.visit(child, min_pos_in_group, inside_zero_rep, enclosing_group)?; // Absent stopper doesn't consume any characters itself diff --git a/crates/exav-core/src/fancy_regex/compile.rs b/crates/exav-core/src/fancy_regex/compile.rs index a772c307..4307669e 100644 --- a/crates/exav-core/src/fancy_regex/compile.rs +++ b/crates/exav-core/src/fancy_regex/compile.rs @@ -849,7 +849,7 @@ impl<'a> Compiler<'a> { return Ok(()); } if infos.len() == 1 { - if let Expr::LiteralBytes { ref bytes, .. } = infos[0].expr { + if let Expr::LiteralBytes { bytes, .. } = infos[0].expr { self.emit_literal_bytes(bytes); return Ok(()); } @@ -1898,7 +1898,7 @@ mod tests { assert_eq!(prog.len(), 5, "prog: {:?}", prog); assert_matches!(prog[0], Save(2)); - assert_matches!(&prog[1], BackwardsDelegate(ReverseBackwardsDelegate { pattern, dfa: _, cache_pool: _, capture_group_extraction_inner: ref inner, capture_groups: Some(CaptureGroupRange(0, 1)) }) if pattern == "a(b+)" && inner.is_some()); + assert_matches!(&prog[1], BackwardsDelegate(ReverseBackwardsDelegate { pattern, dfa: _, cache_pool: _, capture_group_extraction_inner: inner, capture_groups: Some(CaptureGroupRange(0, 1)) }) if pattern == "a(b+)" && inner.is_some()); assert_matches!(prog[2], Restore(2)); assert_matches!(prog[3], Lit(ref l) if l == "x"); assert_matches!(prog[4], End); diff --git a/crates/exav-core/src/fancy_regex/mod.rs b/crates/exav-core/src/fancy_regex/mod.rs index 58c1eb0e..55121f37 100644 --- a/crates/exav-core/src/fancy_regex/mod.rs +++ b/crates/exav-core/src/fancy_regex/mod.rs @@ -2663,12 +2663,12 @@ impl<'a> Iterator for ExprChildrenIter<'a> { fn next(&mut self) -> Option { match self { ExprChildrenIter::Empty => None, - ExprChildrenIter::Single(ref mut child) => child.take(), - ExprChildrenIter::Vec(ref mut iter) => iter.next(), + ExprChildrenIter::Single(child) => child.take(), + ExprChildrenIter::Vec(iter) => iter.next(), ExprChildrenIter::Triple { - ref mut first, - ref mut second, - ref mut third, + first, + second, + third, } => first .take() .or_else(|| second.take()) @@ -2683,12 +2683,12 @@ impl<'a> Iterator for ExprChildrenIterMut<'a> { fn next(&mut self) -> Option { match self { ExprChildrenIterMut::Empty => None, - ExprChildrenIterMut::Single(ref mut child) => child.take(), - ExprChildrenIterMut::Vec(ref mut iter) => iter.next(), + ExprChildrenIterMut::Single(child) => child.take(), + ExprChildrenIterMut::Vec(iter) => iter.next(), ExprChildrenIterMut::Triple { - ref mut first, - ref mut second, - ref mut third, + first, + second, + third, } => first .take() .or_else(|| second.take()) @@ -2698,7 +2698,7 @@ impl<'a> Iterator for ExprChildrenIterMut<'a> { } macro_rules! children_iter_match { - ($self:expr, $iter:ident, $vec_method:ident, $single_method:ident, $group_method:ident) => { + ($self:expr_2021, $iter:ident, $vec_method:ident, $single_method:ident, $group_method:ident) => { match $self { Expr::Concat(children) | Expr::Alt(children) => $iter::Vec(children.$vec_method()), Expr::Group(child) => $iter::Single(Some(Arc::$group_method(child))), diff --git a/crates/exav-core/src/fancy_regex/optimize.rs b/crates/exav-core/src/fancy_regex/optimize.rs index b66cbab4..c192c502 100644 --- a/crates/exav-core/src/fancy_regex/optimize.rs +++ b/crates/exav-core/src/fancy_regex/optimize.rs @@ -621,7 +621,7 @@ fn optimize_trailing_lookahead(tree: &mut ExprTree) -> bool { unreachable!("already checked it is a lookahead"); } } - } else if let Expr::LookAround(ref mut inner, LookAround::LookAhead) = &mut tree.expr { + } else if let Expr::LookAround(inner, LookAround::LookAhead) = &mut tree.expr { let group0 = Expr::Group(Arc::new(Expr::Empty)); let mut swap = Expr::Empty; mem::swap(&mut swap, inner); diff --git a/crates/exav-core/src/fancy_regex/vm.rs b/crates/exav-core/src/fancy_regex/vm.rs index a78c3dd0..f4a90045 100644 --- a/crates/exav-core/src/fancy_regex/vm.rs +++ b/crates/exav-core/src/fancy_regex/vm.rs @@ -179,7 +179,7 @@ pub enum CharClassMatcher { impl fmt::Debug for CharClassMatcher { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if let Some(ref name) = match self { + if let Some(name) = match self { CharClassMatcher::Codepoint { name, .. } => name, CharClassMatcher::Byte { name, .. } => name, } { diff --git a/crates/exav-core/src/filetype.rs b/crates/exav-core/src/filetype.rs index ef8adf9b..89dda73d 100644 --- a/crates/exav-core/src/filetype.rs +++ b/crates/exav-core/src/filetype.rs @@ -187,6 +187,11 @@ pub enum FileType { Fat, // FAT12/16/32 filesystem Inno, // Inno Setup installer (recognised, not decoded) Ntfs, // NTFS filesystem + Exfat, // exFAT filesystem + Squashfs, // SquashFS image + Cramfs, // cramfs image + Romfs, // romfs image + Erofs, // EROFS image Zstd, // Zstandard compressed stream Lzip, // Lzip compressed stream Uuencode, // uuencode / base64-uuencode wrapped file @@ -271,6 +276,11 @@ impl FileType { FileType::Fat => "FAT", FileType::Inno => "InnoSetup", FileType::Ntfs => "NTFS", + FileType::Exfat => "exFAT", + FileType::Squashfs => "SquashFS", + FileType::Cramfs => "cramfs", + FileType::Romfs => "romfs", + FileType::Erofs => "EROFS", FileType::Zstd => "ZSTD", FileType::Lzip => "LZIP", FileType::Uuencode => "uuencode", @@ -308,7 +318,7 @@ pub fn identify(buf: &[u8]) -> FileType { } /// Bytes of an object's start the content sniffers below read. -const SNIFF_HEAD: usize = 8192; +const SNIFF_HEAD: usize = 64 * 1024; /// As [`identify`], over an object that need not be held in memory: its start /// is read, and the container detection reads what it needs. @@ -456,32 +466,61 @@ fn looks_like_html(buf: &[u8]) -> bool { .any(|m| memchr::memmem::find(&lower, m).is_some()) } -/// Heuristic RFC822/MIME detection (email has no magic bytes). Conservative: -/// the message must start with a recognized header line *and* carry a -/// MIME-Version/Content-Type header, or be an mbox (`From ` separator). +/// How much of the start is read to judge a message by its headers. +const EMAIL_HEAD: usize = 64 * 1024; + +/// Heuristic RFC822/MIME detection (email has no magic bytes). +/// +/// An mbox (`From ` separator), or a header block that starts with a header +/// field and carries a MIME header and a header only mail has. Names are not +/// case sensitive and the first field is any: a mail client writes its own +/// (`X-Mozilla-Status`, `X-Account-Key`) ahead of the rest, and a gateway adds +/// a long run of `Received` fields before the MIME headers. fn looks_like_email(buf: &[u8]) -> bool { - let head = &buf[..buf.len().min(8192)]; - let text = String::from_utf8_lossy(head); - if text.starts_with("From ") { + let head = &buf[..buf.len().min(EMAIL_HEAD)]; + if head.starts_with(b"From ") { return true; // mbox } - let first_is_header = text.lines().next().is_some_and(|l| { - let key = l.split_once(':').map(|(k, _)| k).unwrap_or(""); - matches!( - key, - "Received" - | "Return-Path" - | "From" - | "To" - | "Subject" - | "Date" - | "Message-ID" - | "Delivered-To" - | "MIME-Version" - | "Content-Type" - ) - }); - first_is_header && (text.contains("MIME-Version:") || text.contains("Content-Type:")) + // The header block: up to the first empty line. + let end = head + .windows(2) + .position(|w| w == b"\n\n") + .into_iter() + .chain(head.windows(4).position(|w| w == b"\r\n\r\n")) + .min() + .unwrap_or(head.len()); + let block = &head[..end]; + let field_name = |line: &[u8]| -> Option { + let colon = line.iter().position(|&b| b == b':')?; + let name = &line[..colon]; + (!name.is_empty() + && name.len() <= 64 + && name.iter().all(|b| b.is_ascii_graphic() && *b != b':')) + .then(|| String::from_utf8_lossy(name).to_ascii_lowercase()) + }; + let mut lines = block.split(|&b| b == b'\n'); + let Some(first) = lines.next().and_then(field_name) else { + return false; + }; + // Folded lines start with white space and name nothing. + let names: Vec = std::iter::once(first.clone()) + .chain(lines.filter_map(field_name)) + .collect(); + let has = |n: &str| names.iter().any(|x| x == n); + let mime = has("mime-version") || has("content-type"); + const ONLY_MAIL: &[&str] = &[ + "received", + "return-path", + "from", + "to", + "subject", + "date", + "message-id", + "delivered-to", + "mime-version", + ]; + // A part saved on its own starts with its `Content-Type`. + mime && (first == "content-type" || ONLY_MAIL.iter().any(|n| has(n))) } /// Whether a MIME document is an **MHTML web archive** rather than a mail @@ -555,6 +594,10 @@ pub(crate) fn filetype_of_format(fmt: crate::unpack::Format) -> FileType { // the filesystem inside, which is typed when the member is scanned. Format::Vhd => FileType::Unknown, Format::Lzw => FileType::Unknown, + // LZMA Utils' `.lzma` has no ClamAV type either. + Format::Lzma => FileType::Unknown, + // So has an RPM package. + Format::Rpm => FileType::Unknown, Format::Qcow2 | Format::Vmdk | Format::Vhdx => FileType::Unknown, Format::Wim => FileType::Wim, Format::Lz4 => FileType::Lz4, @@ -575,6 +618,11 @@ pub(crate) fn filetype_of_format(fmt: crate::unpack::Format) -> FileType { Format::Fat => FileType::Fat, Format::Inno => FileType::Inno, Format::Ntfs => FileType::Ntfs, + Format::Exfat => FileType::Exfat, + Format::Squashfs => FileType::Squashfs, + Format::Cramfs => FileType::Cramfs, + Format::Romfs => FileType::Romfs, + Format::Erofs => FileType::Erofs, Format::Zstd => FileType::Zstd, Format::Lzip => FileType::Lzip, Format::Uuencode => FileType::Uuencode, @@ -633,6 +681,8 @@ pub(crate) const MAGIC_DISPATCH_ONLY: &[crate::unpack::Format] = &[ crate::unpack::Format::Dmg, crate::unpack::Format::Vhd, crate::unpack::Format::Lzw, + crate::unpack::Format::Lzma, + crate::unpack::Format::Rpm, crate::unpack::Format::Qcow2, crate::unpack::Format::Vmdk, crate::unpack::Format::Vhdx, @@ -769,4 +819,58 @@ mod tests { // Plain prose with a colon must not be misread as email (it is text). assert_eq!(identify(b"Notes: buy milk\nand eggs\n"), FileType::Text); } + + /// A message is a message by what its headers are, not by which one comes + /// first or how it is spelt: a mail client writes its own header ahead of the + /// rest, and a header name is not case sensitive. + #[test] + fn a_message_is_known_by_its_headers_not_by_the_first_of_them() { + let rest = "Subject: x\r\nMIME-Version: 1.0\r\nContent-Type: text/plain\r\n\r\nhi"; + for first in [ + "X-Mozilla-Status: 0001", + "X-Account-Key: account1", + "Cc: a@b", + "Message-Id: <1@b>", + "Authentication-Results: mx; spf=pass", + "FROM: a@b", + "received: from x", + ] { + let eml = format!("{first}\r\n{rest}"); + assert_eq!(identify(eml.as_bytes()), FileType::Email, "{first}"); + } + let lower = "from: a@b\nmime-version: 1.0\ncontent-type: text/plain\n\nhi"; + assert_eq!(identify(lower.as_bytes()), FileType::Email); + } + + /// Header blocks of mail that went through gateways run past 8 KiB. + #[test] + fn a_long_header_block_does_not_hide_the_mime_headers() { + let mut eml = String::new(); + for i in 0..200 { + eml.push_str(&format!("Received: from relay{i}.example.org by mx{i}.example.net; Tue, 1 Jan 2030 00:00:00 +0000\r\n")); + } + let at = eml.len(); + eml.push_str("MIME-Version: 1.0\r\nContent-Type: text/plain\r\n\r\nhi"); + assert!(at > 8192, "{at}"); + assert_eq!(identify(eml.as_bytes()), FileType::Email); + } + + #[test] + fn headers_alone_do_not_make_a_message() { + // No MIME header. + assert_ne!( + identify(b"X-Thing: a\r\nFrom: a@b\r\n\r\nhi"), + FileType::Email + ); + // MIME headers in the body, not the headers. + assert_ne!( + identify(b"X-Thing: a\r\nFrom: a@b\r\n\r\nContent-Type: text/plain\r\nhi"), + FileType::Email + ); + // A header-looking first line and nothing mail about it. + assert_ne!( + identify(b"Server: x\r\nContent-Length: 3\r\nContent-Type: text/plain\r\n\r\nhi"), + FileType::Email + ); + } } diff --git a/crates/exav-core/src/grams/mod.rs b/crates/exav-core/src/grams/mod.rs index c132bc55..51dbc708 100644 --- a/crates/exav-core/src/grams/mod.rs +++ b/crates/exav-core/src/grams/mod.rs @@ -31,11 +31,11 @@ mod rarity; mod sweep; -#[cfg(feature = "yara")] -pub(crate) use rarity::structural; #[cfg(test)] pub(crate) use rarity::FILTER_MIN_BODIES; -pub(crate) use rarity::{anchor_score, GramStats}; +#[cfg(feature = "yara")] +pub(crate) use rarity::structural; +pub(crate) use rarity::{GramStats, anchor_score}; pub(crate) use sweep::sweep; #[cfg(test)] pub(crate) use sweep::{MIN_SKIP_RUN, NO_RUN_COLLAPSE, SWEEP_WINDOW}; @@ -737,8 +737,8 @@ impl Grams { }; if !list.iter().all(fits) || !list - .windows(2) - .all(|p| window(&pool, &p[0], w) <= window(&pool, &p[1], w)) + .array_windows() + .all(|[a, b]| window(&pool, a, w) <= window(&pool, b, w)) { return Err(bad()); } diff --git a/crates/exav-core/src/grams/rarity.rs b/crates/exav-core/src/grams/rarity.rs index 2b9157e9..7db812f1 100644 --- a/crates/exav-core/src/grams/rarity.rs +++ b/crates/exav-core/src/grams/rarity.rs @@ -144,11 +144,7 @@ impl GramStats { /// literal is never penalised, and the penalty grows with an order of /// magnitude rather than with a raw count that spans 1..10^6. fn log2_floor(c: u32) -> isize { - if c <= 1 { - 0 - } else { - c.ilog2() as isize - } + if c <= 1 { 0 } else { c.ilog2() as isize } } /// How rare `b` should be from its bytes alone. A low-entropy run (a constant diff --git a/crates/exav-core/src/grams/sweep.rs b/crates/exav-core/src/grams/sweep.rs index 46211b59..f881ce7e 100644 --- a/crates/exav-core/src/grams/sweep.rs +++ b/crates/exav-core/src/grams/sweep.rs @@ -1,7 +1,7 @@ //! One pass over an object, a window at a time, looking up the anchors of a //! [`Grams`] at every position but inside long runs of one byte. -use super::{Grams, Scan, GRAM}; +use super::{GRAM, Grams, Scan}; use crate::byte_source::ByteSource; /// Bytes of the object [`sweep`] reads at a time; in memory, a slice of it. diff --git a/crates/exav-core/src/hashes.rs b/crates/exav-core/src/hashes.rs index 67877b29..e4661f06 100644 --- a/crates/exav-core/src/hashes.rs +++ b/crates/exav-core/src/hashes.rs @@ -80,7 +80,7 @@ impl Serialize for SortedTable { } let spans: Vec = self.spans.iter().flat_map(|&(o, l)| [o.to_le_bytes(), l.to_le_bytes()]).flatten().collect(); let mut bits = vec![0u8; self.unofficial.len().div_ceil(8)]; - for (i, _) in self.unofficial.iter().enumerate().filter(|(_, &u)| u) { + for (i, _) in self.unofficial.iter().enumerate().filter(|&(_, &u)| u) { bits[i / 8] |= 1 << (i % 8); } (Blob(&keys), Blob(&spans), &self.names, Blob(&bits)).serialize(s) diff --git a/crates/exav-core/src/icon.rs b/crates/exav-core/src/icon.rs index bd668b69..27d47312 100644 --- a/crates/exav-core/src/icon.rs +++ b/crates/exav-core/src/icon.rs @@ -421,19 +421,17 @@ impl<'a> Resources<'a> { Some(a) => a, None => continue, }; - if let Some(namedir) = self.find_id_subdir(t3abs, icon_id) { - if let Some((coff, csize)) = self.first_data_leaf(namedir, 0) { + if let Some(namedir) = self.find_id_subdir(t3abs, icon_id) + && let Some((coff, csize)) = self.first_data_leaf(namedir, 0) { let end = coff .checked_add(csize) .map(|e| e.min(self.data.len())) .unwrap_or(self.data.len()); - if let Some(slice) = self.data.get(coff..end) { - if !slice.is_empty() { + if let Some(slice) = self.data.get(coff..end) + && !slice.is_empty() { out.push(slice); } - } } - } } out } @@ -1260,11 +1258,10 @@ fn take_cells( let mut cells = [Cell::default(); 3]; for c in cells.iter_mut() { let avg = take_field(b, pos, avg_n)?; - if let Some(mx) = avg_max { - if avg > mx { + if let Some(mx) = avg_max + && avg > mx { return None; } - } let x = take_field(b, pos, 2)?; if x > coord_max { return None; @@ -1346,11 +1343,10 @@ fn matchpoint(side: u32, a: &[Cell; 3], b: &[Cell; 3], max: u32) -> u32 { for ca in a { let mut best = 0u32; for cb in b { - if let Some(score) = cell_score(ca, cb, dist_lim, avg_lim) { - if score > best { + if let Some(score) = cell_score(ca, cb, dist_lim, avg_lim) + && score > best { best = score; } - } } total += best; } @@ -1365,11 +1361,10 @@ fn matchbwpoint(side: u32, a: &[Cell; 6], b: &[Cell; 6]) -> u32 { for ca in a { let mut best = 0u32; for cb in b { - if let Some(score) = cell_score(ca, cb, dist_lim, avg_lim) { - if score > best { + if let Some(score) = cell_score(ca, cb, dist_lim, avg_lim) + && score > best { best = score; } - } } total += best; } diff --git a/crates/exav-core/src/jsnorm.rs b/crates/exav-core/src/jsnorm.rs index 83119671..5e7cfaf9 100644 --- a/crates/exav-core/src/jsnorm.rs +++ b/crates/exav-core/src/jsnorm.rs @@ -22,11 +22,10 @@ //! purely static rewriter: it never executes code, so runtime-assembled payloads //! (numeric-array VMs, dynamic dispatch) are intentionally out of scope. //! -//! Not yet implemented (tracked in tmp/GAPS.md): -//! * **Microsoft Script Encoder** (`#@~^…^#~@`, JScript.Encode/VBScript.Encode) -//! decoding — needs the exact published 119×3 translation + 64-entry pick -//! tables; deferred rather than ship an unverified table (and near-absent in -//! the current corpus). +//! Not done here: Microsoft Script Encoder (`#@~^…^#~@`) blobs are decoded by +//! exav-unpack's `screnc`, which surfaces the plaintext as a member of its own. +//! +//! Not yet implemented: //! * **base64→script rescan** — decoding long base64 runs to *script/text* (not //! just exec-magic payloads, which `exav-unpack::base64_payloads` already //! covers). The higher-yield lever per corpus evidence, but needs a distinct @@ -805,13 +804,12 @@ fn percent_decode(s: &[u8]) -> Vec { continue; } } - if s[i] == b'%' && i + 2 < n { - if let Some(v) = hex2(s[i + 1], s[i + 2]) { + if s[i] == b'%' && i + 2 < n + && let Some(v) = hex2(s[i + 1], s[i + 2]) { out.push(v); i += 3; continue; } - } out.push(s[i]); i += 1; } @@ -900,11 +898,10 @@ impl Emitter { } Tok::Punct(b) => std::slice::from_ref(b), }; - if let (Some(&last), Some(&first)) = (self.out.last(), piece.first()) { - if is_ident(last) && is_ident(first) { + if let (Some(&last), Some(&first)) = (self.out.last(), piece.first()) + && is_ident(last) && is_ident(first) { self.out.push(b' '); } - } self.out.extend_from_slice(piece); } diff --git a/crates/exav-core/src/lib.rs b/crates/exav-core/src/lib.rs index eab32a2e..8955f31a 100644 --- a/crates/exav-core/src/lib.rs +++ b/crates/exav-core/src/lib.rs @@ -204,6 +204,11 @@ fn unpack_format(ft: FileType) -> Option { FileType::Fat => unpack::Format::Fat, FileType::Inno => unpack::Format::Inno, FileType::Ntfs => unpack::Format::Ntfs, + FileType::Exfat => unpack::Format::Exfat, + FileType::Squashfs => unpack::Format::Squashfs, + FileType::Cramfs => unpack::Format::Cramfs, + FileType::Romfs => unpack::Format::Romfs, + FileType::Erofs => unpack::Format::Erofs, FileType::Zstd => unpack::Format::Zstd, FileType::Lzip => unpack::Format::Lzip, FileType::Uuencode => unpack::Format::Uuencode, @@ -254,6 +259,8 @@ pub(crate) struct Facts<'a> { struct Shared { want: hashes::Want, carve: bool, + /// Far candidates carving holds before it checks them. + far: usize, } impl<'a> Facts<'a> { @@ -271,12 +278,13 @@ impl<'a> Facts<'a> { /// The facts of `data` scanned by `db`, carved when `carve`: when `data` /// is not held in memory, all worked out in one read of it. - fn for_scan(data: &'a dyn ByteSource, db: &Scanner, carve: bool) -> Self { + fn for_scan(data: &'a dyn ByteSource, db: &Scanner, carve: bool, opts: &ScanOptions) -> Self { let facts = Facts::new(data); if data.as_slice().is_none() { facts.shared.set(Some(Shared { want: digests_wanted_by(db, data.len() as u64), carve, + far: opts.carve_pending(), })); } facts @@ -325,7 +333,7 @@ impl<'a> Facts<'a> { /// The one read of an object not held in memory, the first time a step /// asks, if it was not made yet. fn read_once(&self) { - let Some(Shared { want, carve }) = self.shared.take() else { + let Some(Shared { want, carve, far }) = self.shared.take() else { return; }; let search = self.fmt.get().is_none() && unpack::Prescan::needed(self.data.len()); @@ -335,7 +343,7 @@ impl<'a> Facts<'a> { let data = self.data; let len = data.len(); let mut hashing = (want != hashes::Want::default()).then(|| hashes::Hashing::new(want)); - let mut carving = carve.then(pe::Carving::new); + let mut carving = carve.then(|| pe::Carving::new(far)); let mut prescan = None; let overlap = unpack::Prescan::OVERLAP.max(pe::Carving::OVERLAP); let mut at = 0; @@ -372,7 +380,8 @@ impl<'a> Facts<'a> { if let Some(h) = hashing { let _ = self.digests.set(h.finish()); } - if let Some(c) = carving { + if let Some(mut c) = carving { + c.settle(data); *self.embedded.borrow_mut() = Some(c.found); } *self.prescan.borrow_mut() = prescan; @@ -420,28 +429,26 @@ fn unpack_target( // from the magic. Compat mode leaves them off: stock ClamAV opens none of // them, and extracting more there would be counted as a disagreement. let detected = || facts.format(); - if !restrict { - if let Some(fmt) = detected() { - if filetype::MAGIC_DISPATCH_ONLY.contains(&fmt) { - return Some(fmt); - } - } + if !restrict + && let Some(fmt) = detected() + && filetype::MAGIC_DISPATCH_ONLY.contains(&fmt) + { + return Some(fmt); } // Installers and self-extractors: real executables, so `identify` answers // `Pe`/`Elf` and `unpack_format` has nothing to map. Their payload is the // installer's own format rather than a recognisable archive, so carving does // not reach it either. Without this an NSIS or Inno installer scans clean // with every file it packages unexamined. - if ft.is_executable() { - if let Some(fmt) = detected() { - if filetype::EXECUTABLE_CONTAINERS.contains(&fmt) { - // Stock ClamAV unpacks NSIS, AutoIt and SFX, so those stay on in - // compat mode; it has no Inno Setup support, and claiming the - // extra reach there would register as a disagreement. - if !restrict || fmt != unpack::Format::Inno { - return Some(fmt); - } - } + if ft.is_executable() + && let Some(fmt) = detected() + && filetype::EXECUTABLE_CONTAINERS.contains(&fmt) + { + // Stock ClamAV unpacks NSIS, AutoIt and SFX, so those stay on in + // compat mode; it has no Inno Setup support, and claiming the + // extra reach there would register as a disagreement. + if !restrict || fmt != unpack::Format::Inno { + return Some(fmt); } } // UPX unpacking. ClamAV's UPX unpacker is invoked only from its PE scan path; @@ -874,6 +881,21 @@ pub struct ScanOptions { /// over this limit with no spill to go to is not scanned, and reported. /// A scan also holds `limits.max_buffer_bytes` to it. Default 256 MiB. pub deep_analysis_max: u64, + /// Memory of the block cache of an object read in place, in bytes + /// (`--object-cache-bytes`): an object past `deep_analysis_max`, a spilled + /// member or view, a spilled stream. One cache for each such object being + /// scanned, nested ones included. Carving also holds up to this much of + /// candidates waiting for a check while it reads such an object. Default + /// 8 MiB. + pub object_cache_bytes: u64, + /// One block of that cache, in bytes (`--object-cache-block-bytes`): the + /// unit it reads from the object, and over HTTP what a read at an offset + /// elsewhere fetches. Default 1 MiB. + pub object_cache_block_bytes: u64, + /// Over HTTP, the most one range request fetches (`--http-request-bytes`), + /// reached by a read that goes on from where the last stopped, which asks + /// for twice as much each time from one block. Default 8 MiB. + pub http_request_bytes: u64, /// Largest object PCRE subsignatures run on (`--max-pcre-bytes`); on a /// larger one they do not match, as ClamAV's `PCREMaxFileSize` has it. /// `None` = no limit (default); 100 MiB under `--clamav-compat`. @@ -1032,11 +1054,43 @@ pub struct ScanOptions { pub spill: Option>, } +impl ScanOptions { + /// The block and capacity, in bytes, of a block cache: at least a byte a + /// block, and room for two. + pub(crate) fn cache_sizes(&self) -> (usize, usize) { + let block = usize::try_from(self.object_cache_block_bytes) + .unwrap_or(usize::MAX) + .max(1); + let bytes = usize::try_from(self.object_cache_bytes).unwrap_or(usize::MAX); + (block, bytes) + } + + /// How many far candidates carving holds before it checks them: their + /// 16 bytes each fill no more than the object cache does. + pub(crate) fn carve_pending(&self) -> usize { + usize::try_from(self.object_cache_bytes / 16) + .unwrap_or(usize::MAX) + .max(1) + } + + /// A block cache over `reader`, sized by these options. + pub(crate) fn object_cache( + &self, + reader: R, + ) -> io::Result> { + let (block, bytes) = self.cache_sizes(); + byte_source::BlockCache::with_sizes(reader, block, bytes) + } +} + impl Default for ScanOptions { fn default() -> Self { Self { max_scan_size: None, deep_analysis_max: 256 * 1024 * 1024, + object_cache_bytes: 8 * 1024 * 1024, + object_cache_block_bytes: 1024 * 1024, + http_request_bytes: 8 * 1024 * 1024, max_pcre_bytes: None, min_scan_bytes: MIN_SCAN_BYTES, heuristics: false, @@ -1084,6 +1138,9 @@ impl ScanOptions { Self { max_scan_size: Some(100 * 1024 * 1024), deep_analysis_max: 400 * 1024 * 1024, + object_cache_bytes: 8 * 1024 * 1024, + object_cache_block_bytes: 1024 * 1024, + http_request_bytes: 8 * 1024 * 1024, // ClamAV's PCREMaxFileSize default. max_pcre_bytes: Some(100 * 1024 * 1024), min_scan_bytes: MIN_SCAN_BYTES, @@ -1267,10 +1324,10 @@ impl Scanner { fn identify_inner(&self, facts: &Facts) -> FileType { let data = facts.data; let mut ft = filetype::identify_source_with(data, || facts.format()); - if ft == FileType::Unknown { - if let Some(f) = self.ftm.identify_source(data) { - ft = f; - } + if ft == FileType::Unknown + && let Some(f) = self.ftm.identify_source(data) + { + ft = f; } // The bzip2 (`BZh`), CAB (`MSCF`), gzip (`1f 8b`) and ARJ (`60 ea`) // file-type magics are short and collide with ordinary binary data; a @@ -1500,16 +1557,16 @@ pub fn scan_seekable( // Thread-local, so it carries over from whatever this thread scanned last // unless every entry point clears it. engine::reset_scan_truncated(); - if let Some(max) = opts.max_scan_size { - if size > max { - // Invariant 2 (see crate docs): scan what is within the limit - // before refusing by size, so a detection in it still wins. - let head = scan_within(db, Window::new(&mut reader, max)?, max, opts)?; - if head.verdict.category() == VerdictCategory::Infected { - return Ok(head); - } - return Ok(max_file_size_report(size, max, opts)); + if let Some(max) = opts.max_scan_size + && size > max + { + // Invariant 2 (see crate docs): scan what is within the limit + // before refusing by size, so a detection in it still wins. + let head = scan_within(db, Window::new(&mut reader, max)?, max, opts)?; + if head.verdict.category() == VerdictCategory::Infected { + return Ok(head); } + return Ok(max_file_size_report(size, max, opts)); } scan_within(db, reader, size, opts) } @@ -1531,10 +1588,20 @@ fn scan_within( if data.len() as u64 <= opts.deep_analysis_max { return Ok(analyze(db, &data, opts)); } - // It grew while it was read: scanned as the larger object it now is. + // It grew while it was read: scanned as the larger object it now is, + // if it says it is. A device that reads on without end (`/dev/zero`) + // measures nothing, and a scan of its measure would be of no bytes. + let end = reader.seek(SeekFrom::End(0))?; reader.seek(SeekFrom::Start(0))?; + if end <= opts.deep_analysis_max { + return Err(io::Error::other(format!( + "the source gave more than {} bytes and says it has {end}: \ + it is not a file with a length, and cannot be scanned as one", + opts.deep_analysis_max + ))); + } } - let cache = byte_source::BlockCache::new(reader)?; + let cache = opts.object_cache(reader)?; let report = analyze_source(db, &cache, opts); // A read that failed part way leaves bytes the scan never saw. if let Some(e) = cache.read_error() { @@ -1644,11 +1711,11 @@ fn max_file_size_report(size: u64, max: u64, opts: &ScanOptions) -> ScanReport { /// The alert a top-level file larger than `max_scan_size` is reported as /// under `--alert-exceeds-max`, or else the reason it is over the limit. fn max_file_size(size: u64, max: u64, opts: &ScanOptions) -> Result<&'static str, String> { - if opts.alert_exceeds_max { - if let Some(name) = limits_alert_name(unpack::LimitKind::MaxFileSize) { - match_loc_record(); - return Ok(name); - } + if opts.alert_exceeds_max + && let Some(name) = limits_alert_name(unpack::LimitKind::MaxFileSize) + { + match_loc_record(); + return Ok(name); } Err(format!( "file size {size} exceeds max-input-bytes {max}; scanned first {max} bytes only" @@ -1661,15 +1728,15 @@ fn max_file_size(size: u64, max: u64, opts: &ScanOptions) -> Result<&'static str /// treated as complete: a kind with no alert name keeps the `LimitsExceeded` /// verdict and its reason. fn limits_outcome(opts: &ScanOptions, kind: unpack::LimitKind, reason: String) -> DeepOutcome { - if opts.alert_exceeds_max { - if let Some(name) = limits_alert_name(kind) { - match_loc_record(); - return DeepOutcome::Infected { - signature: name.to_string(), - offset: 0, - method: Method::Heuristic, - }; - } + if opts.alert_exceeds_max + && let Some(name) = limits_alert_name(kind) + { + match_loc_record(); + return DeepOutcome::Infected { + signature: name.to_string(), + offset: 0, + method: Method::Heuristic, + }; } DeepOutcome::Limits(reason) } @@ -1753,7 +1820,11 @@ fn read_member( let Some(spill) = &cx.opts.spill else { return not_scanned(tally, "spilling to disk is off"); }; - match spill::spill_stream(spill.as_ref(), &mut io::Cursor::new(buf).chain(rest)) { + match spill::spill_stream( + spill.as_ref(), + &mut io::Cursor::new(buf).chain(rest), + cx.opts.cache_sizes(), + ) { Ok(spilled) => Ok(Body::Spilled(spilled)), Err(spill::SpillStreamError::Read(e)) if unpack::is_budget_overflow(&e) => { Err(over_budget()) @@ -1805,10 +1876,11 @@ fn visit_member( // to decompress to itself, so the guard lives here rather than in any // extractor. The length test is false for essentially every real member, // so the comparison is only reached by a genuine fixed point. - if let Body::Mem(data) = &body { - if data.len() == container.len() && *container.window(0, data.len()) == data[..] { - return None; - } + if let Body::Mem(data) = &body + && data.len() == container.len() + && *container.window(0, data.len()) == data[..] + { + return None; } let size_real = match &body { Body::Mem(data) => data.len() as u64, @@ -1829,7 +1901,7 @@ fn visit_member( // of it is a real Clean: exav scans for malware, it is not a // file-integrity validator. Undecodable bytes still present // keep the not-fully-scanned verdict. - let o = member_content_scan(cx, tally, &data, budget, findings, sink); + let o = member_content_scan(cx, tally, &meta.name, &data, budget, findings, sink); if o.is_none() && unpack::decode_error_hides_content(&e) { tally.unscannable.get_or_insert_with(|| { format!( @@ -1848,10 +1920,10 @@ fn visit_member( unpack::volume::Offer::Held => return None, unpack::volume::Offer::PassThrough { data, .. } => data, }; - member_content_scan(cx, tally, &data, budget, findings, sink) + member_content_scan(cx, tally, &meta.name, &data, budget, findings, sink) } Body::Spilled(spilled) => { - let o = member_content_scan(cx, tally, &spilled, budget, findings, sink); + let o = member_content_scan(cx, tally, &meta.name, &spilled, budget, findings, sink); if let Some(e) = spilled.read_error() { tally .unscannable @@ -1957,14 +2029,13 @@ fn walk_members( break; } let _mpg = MatchPathGuard::enter(&name); - terminal = member_content_scan(&cx, &mut tally, &buf, budget, findings, sink); + terminal = member_content_scan(&cx, &mut tally, &name, &buf, budget, findings, sink); } - if let Some(name) = tally.encrypted.take() { - if !matches!(terminal, Some(DeepOutcome::Infected { .. })) { - if let Some(o) = sink.hit(name.to_string(), 0, Method::Heuristic) { - return o; - } - } + if let Some(name) = tally.encrypted.take() + && !matches!(terminal, Some(DeepOutcome::Infected { .. })) + && let Some(o) = sink.hit(name.to_string(), 0, Method::Heuristic) + { + return o; } terminal.unwrap_or_else(|| tally.verdict()) } @@ -1996,8 +2067,8 @@ pub struct VolumeSetVerdict { /// whole set with it. Byte-split naming records no part count, so a set missing /// its LAST part still looks contiguous and would join into a truncated prefix, /// which parses as the archive and scans clean. The set is reported -/// `Unscannable` instead. The dropped part's own scan, which the caller does -/// separately, still covers that part's bytes. +/// `Unscannable` instead, each part read and not held included. The caller's +/// own scan of a part says only that the fragment is not malware itself. /// /// Returns one entry per *part*, each carrying the verdict of the archive that /// part belongs to, including `Unscannable` for a set with a hole in it, whose @@ -2025,13 +2096,21 @@ where // missing trailing index leaves no trace in them. let mut broken: std::collections::BTreeMap = std::collections::BTreeMap::new(); + // The parts the collector read and would not hold. It keeps no trace of + // them, so a set none of whose parts it holds comes back with no entry. + let mut passed: Vec = Vec::new(); + // Parts group without regard to case, so a set is known by its lower-case + // name here, whichever part's case it was joined under. + fn set_key(name: &str) -> String { + name.to_ascii_lowercase() + } for name in names { if !unpack::volume::parse(name).is_some_and(|v| v.scheme.is_byte_split()) { continue; } let Ok(data) = fetch(name) else { broken.insert( - volume_set_name(name), + set_key(&volume_set_name(name)), "part of a multi-volume set one of whose volumes could not be read", ); continue; @@ -2041,9 +2120,10 @@ where // cap, or a second part claiming an index another part already holds. if let unpack::volume::Offer::PassThrough { name, .. } = collector.offer(name, data) { broken.insert( - volume_set_name(&name), + set_key(&volume_set_name(&name)), "part of a multi-volume set that could not be reassembled whole", ); + passed.push(name); } } if !any { @@ -2059,7 +2139,7 @@ where for j in held.joined { // A set with a hole gets the hole's verdict, not a verdict read off the // bytes that happened to arrive. - let report = match broken.get(j.name.as_str()) { + let report = match broken.get(set_key(&j.name).as_str()) { Some(reason) => ScanReport::unscannable((*reason).to_string(), Vec::new()), None => analyze(db, &j.data, opts), }; @@ -2074,19 +2154,288 @@ where for u in held.unjoined { // A lone numbered file is not a set: plenty of ordinary files end in // `.001`, and the caller's own scan of it is the right answer. - let Some(reason) = u.incomplete_set else { + // Unless a part of it was lost: one readable part of a set whose other + // part could not be read is not a lone file. + let set = volume_set_name(&u.name); + let Some(reason) = u + .incomplete_set + .or_else(|| broken.get(set_key(&set).as_str()).copied()) + else { continue; }; - let set = volume_set_name(&u.name); out.push(VolumeSetVerdict { name: u.name, set, report: ScanReport::unscannable(reason.to_string(), Vec::new()), }); } + // A part that was read and not held belongs to a set that cannot be + // rejoined: it is not a clean file because its own bytes scan clean. + for name in passed { + if out.iter().any(|v| v.name == name) { + continue; + } + let set = volume_set_name(&name); + out.push(VolumeSetVerdict { + name, + set, + report: ScanReport::unscannable( + "part of a multi-volume set that could not be reassembled whole".to_string(), + Vec::new(), + ), + }); + } out } +/// The sets of **format-aware volumes** among a group of files that arrived +/// together, each put back into the one archive it was cut from and scanned: +/// RAR (`x.part1.rar`, `x.part2.rar`, ..., or `x.rar`, `x.r00`, ...), ZIP +/// (`x.z01`, `x.z02`, ..., `x.zip`) and CAB (`a.cab`, `b.cab`, as each header +/// names the one after it). Each volume carries its own headers, so the join is +/// the format's, not a concatenation, and the parts of a set scanned alone are +/// cut short. +/// +/// `fetch(name, None)` is a whole file and `fetch(name, Some(n))` its first `n` +/// bytes. Whole files are fetched only for the parts of a set, up to +/// `max-object-bytes` of them together; the heads of `.cab` files are read to +/// find which of them name a neighbour. Names are labels, never paths: a +/// cabinet's own header names its neighbours, and they are looked for in +/// `names` only. +/// +/// As [`analyze_volume_sets`]: one entry per *part*, with the verdict of the +/// archive that part belongs to, `Unscannable` for a set with a hole in it or +/// one that does not fit together. A file that is not part of a set is not in +/// the result. +#[cfg(feature = "all-formats")] +pub fn analyze_split_sets( + db: &Scanner, + names: &[String], + opts: &ScanOptions, + mut fetch: F, +) -> Vec +where + F: FnMut(&str, Option) -> io::Result>, +{ + use unpack::volume::{Scheme, group, parse}; + + #[derive(Clone, Copy, PartialEq)] + enum Kind { + Rar, + Zip, + Cab, + } + struct Set { + name: String, + kind: Kind, + parts: Vec, + /// Why the set cannot be joined, when it is known from the names. + broken: Option<&'static str>, + } + let mut sets: Vec = Vec::new(); + let lower = |s: &str| s.to_ascii_lowercase(); + + // RAR: the naming schemes say the order, and a gap says a part is missing. + let rar: Vec<&str> = names + .iter() + .map(String::as_str) + .filter(|n| { + matches!( + parse(n).map(|v| v.scheme), + Some(Scheme::RarPart { .. } | Scheme::RarOld) + ) + }) + .collect(); + for set in group(&rar) { + let parts: Vec<&str> = set.iter().map(|&i| rar[i]).collect(); + let first = parse(parts[0]).expect("grouped by name"); + let contiguous = parts + .iter() + .enumerate() + .all(|(i, p)| parse(p).is_some_and(|v| v.index == i)); + sets.push(Set { + name: format!("{}.rar", first.stem), + kind: Kind::Rar, + parts: parts.iter().map(|p| (*p).to_string()).collect(), + broken: (!contiguous).then_some("part of a multi-volume RAR set with a volume missing"), + }); + } + + // ZIP: `x.z01` ... and the `x.zip` that comes last and holds the directory. + let mut stems: Vec = Vec::new(); + for n in names { + if let Some(v) = parse(n).filter(|v| v.scheme == Scheme::ZipSplit) + && !stems.iter().any(|s| s.eq_ignore_ascii_case(&v.stem)) + { + stems.push(v.stem); + } + } + for stem in stems { + let mut numbered: Vec<(usize, &String)> = names + .iter() + .filter_map(|n| { + parse(n) + .filter(|v| v.scheme == Scheme::ZipSplit && v.stem.eq_ignore_ascii_case(&stem)) + .map(|v| (v.index, n)) + }) + .collect(); + numbered.sort(); + let last = names + .iter() + .find(|n| lower(n) == lower(&format!("{stem}.zip"))); + let contiguous = numbered.iter().enumerate().all(|(i, (idx, _))| *idx == i); + let mut parts: Vec = numbered.iter().map(|(_, n)| (*n).clone()).collect(); + let broken = match last { + Some(l) => { + parts.push(l.clone()); + (!contiguous).then_some("part of a multi-volume ZIP set with a volume missing") + } + None => Some( + "part of a multi-volume ZIP set whose last volume, the `.zip`, was not present", + ), + }; + sets.push(Set { + name: format!("{stem}.zip"), + kind: Kind::Zip, + parts, + broken, + }); + } + + // CAB: no naming scheme; each header names its neighbours. + let mut cabs: Vec<(String, unpack::CabLinks)> = Vec::new(); + for n in names.iter().filter(|n| lower(n).ends_with(".cab")) { + // The header, its reserved area and the two names fit in 64 KiB. + if let Some(l) = fetch(n, Some(64 * 1024)) + .ok() + .and_then(|h| unpack::cab_links(&h)) + { + cabs.push((n.clone(), l)); + } + } + let mut ids: Vec = cabs.iter().map(|(_, l)| l.set_id).collect(); + ids.sort_unstable(); + ids.dedup(); + for id in ids { + let mut members: Vec<&(String, unpack::CabLinks)> = + cabs.iter().filter(|(_, l)| l.set_id == id).collect(); + members.sort_by_key(|(_, l)| l.index); + // The header's name is a label: its last component, whatever separator. + let leaf = |s: &str| lower(s.rsplit(['/', '\\']).next().unwrap_or(s)); + let chained = members.iter().enumerate().all(|(i, (name, l))| { + usize::from(l.index) == i + && (i == 0) == l.prev.is_none() + && (i + 1 == members.len()) == l.next.is_none() + && l.next + .as_deref() + .is_none_or(|n| members.get(i + 1).is_some_and(|m| leaf(n) == lower(&m.0))) + && l.prev + .as_deref() + .is_none_or(|p| i > 0 && leaf(p) == lower(&members[i - 1].0)) + && !name.is_empty() + }); + sets.push(Set { + name: members[0].0.clone(), + kind: Kind::Cab, + parts: members.iter().map(|(n, _)| n.clone()).collect(), + broken: (!chained).then_some("part of a multi-volume CAB set whose cabinets do not follow one another as their headers say"), + }); + } + + let mut out = Vec::new(); + let mut held_total = 0usize; + for set in sets { + let verdict = |report: ScanReport, out: &mut Vec| { + for part in &set.parts { + out.push(VolumeSetVerdict { + name: part.clone(), + set: set.name.clone(), + report: report.clone(), + }); + } + }; + if let Some(reason) = set.broken { + verdict( + ScanReport::unscannable(reason.to_string(), Vec::new()), + &mut out, + ); + continue; + } + let mut data: Vec> = Vec::new(); + let mut lost = None; + for part in &set.parts { + match fetch(part, None) { + Ok(d) => { + // Counted only once held, so a part turned away leaves + // the budget as the sets after it found it. + if held_total.saturating_add(d.len()) > opts.deep_analysis_max as usize { + lost = Some( + "part of a multi-volume set too large to hold for rejoining (--max-object-bytes)", + ); + break; + } + held_total += d.len(); + data.push(d); + } + Err(_) => { + lost = + Some("part of a multi-volume set one of whose volumes could not be read"); + break; + } + } + } + held_total = held_total.saturating_sub(data.iter().map(Vec::len).sum()); + if let Some(reason) = lost { + verdict( + ScanReport::unscannable(reason.to_string(), Vec::new()), + &mut out, + ); + continue; + } + let joined: Result = match set.kind { + Kind::Rar | Kind::Cab => { + let refs: Vec<&[u8]> = data.iter().map(Vec::as_slice).collect(); + let joined = if set.kind == Kind::Rar { + unpack::join_rar_volumes(&refs) + } else { + unpack::join_cab_volumes(&refs) + }; + joined.map(|j| analyze(db, &j, opts)) + } + Kind::Zip => { + let parts = data + .into_iter() + .map(|d| Box::new(d) as Box) + .collect(); + unpack::span::ZipSpan::new(parts).map(|s| analyze_source(db, &s, opts)) + } + }; + let report = joined.unwrap_or_else(|e| { + ScanReport::unscannable( + format!("part of a multi-volume set that could not be rejoined: {e}"), + Vec::new(), + ) + }); + verdict(report, &mut out); + } + out +} + +/// Without `all-formats` no archive format is read, so no part of a set of +/// volumes is known to belong to one: nothing is joined. +#[cfg(not(feature = "all-formats"))] +pub fn analyze_split_sets( + _db: &Scanner, + _names: &[String], + _opts: &ScanOptions, + _fetch: F, +) -> Vec +where + F: FnMut(&str, Option) -> io::Result>, +{ + Vec::new() +} + /// The name a part's SET is reported under, matching what /// `unpack::volume::Collector` names a joined set. A name that does not parse as /// a volume is its own set of one. @@ -2206,17 +2555,17 @@ pub fn analyze_all_seekable( size: u64, opts: &ScanOptions, ) -> io::Result<(Vec<(String, Method)>, AllMatchOutcome)> { - if let Some(max) = opts.max_scan_size { - if size > max { - let (mut names, outcome) = all_within(db, Window::new(&mut reader, max)?, max, opts)?; - return Ok(match max_file_size(size, max, opts) { - Ok(name) => { - names.push((name.to_string(), Method::Heuristic)); - (names, outcome) - } - Err(reason) => (names, AllMatchOutcome::LimitsExceeded(reason)), - }); - } + if let Some(max) = opts.max_scan_size + && size > max + { + let (mut names, outcome) = all_within(db, Window::new(&mut reader, max)?, max, opts)?; + return Ok(match max_file_size(size, max, opts) { + Ok(name) => { + names.push((name.to_string(), Method::Heuristic)); + (names, outcome) + } + Err(reason) => (names, AllMatchOutcome::LimitsExceeded(reason)), + }); } all_within(db, reader, size, opts) } @@ -2238,7 +2587,7 @@ fn all_within( } reader.seek(SeekFrom::Start(0))?; } - let cache = byte_source::BlockCache::new(reader)?; + let cache = opts.object_cache(reader)?; let found = analyze_all_source(db, &cache, opts); // A read that failed part way leaves bytes the scan never saw. if let Some(e) = cache.read_error() { @@ -2385,14 +2734,14 @@ fn whole_buffer_heuristics( // many. if (opts.clamav_heuristics || opts.heuristics) && ft == FileType::Zip { const OVERLAP_THRESHOLD: usize = 5; - if unpack::overlapping_local_records(src) > OVERLAP_THRESHOLD { - if let Some(o) = sink.hit( + if unpack::overlapping_local_records(src) > OVERLAP_THRESHOLD + && let Some(o) = sink.hit( "Heuristics.Zip.OverlappingFiles".to_string(), 0, Method::Heuristic, - ) { - return Some(o); - } + ) + { + return Some(o); } } @@ -2404,16 +2753,15 @@ fn whole_buffer_heuristics( if ft == FileType::Xz { // The stream header and the first block header, which is at most // 1024 bytes. - if let Some(dict) = unpack::xz_declared_dict_size(&src.window(0, 12 + 1024 + 64)) { - if dict > unpack::XZ_MAX_DICT { - if let Some(o) = sink.hit( - "Heuristics.XZ.DicSizeLimit".to_string(), - 0, - Method::Heuristic, - ) { - return Some(o); - } - } + if let Some(dict) = unpack::xz_declared_dict_size(&src.window(0, 12 + 1024 + 64)) + && dict > unpack::XZ_MAX_DICT + && let Some(o) = sink.hit( + "Heuristics.XZ.DicSizeLimit".to_string(), + 0, + Method::Heuristic, + ) + { + return Some(o); } } @@ -2432,12 +2780,12 @@ fn whole_buffer_heuristics( ] .iter() .any(|m| head.starts_with(m)); - if opts.alert_broken_media && media { - if let Some(name) = whole().and_then(unpack::broken_media_alert) { - if let Some(o) = sink.hit(name.to_string(), 0, Method::Heuristic) { - return Some(o); - } - } + if opts.alert_broken_media + && media + && let Some(name) = whole().and_then(unpack::broken_media_alert) + && let Some(o) = sink.hit(name.to_string(), 0, Method::Heuristic) + { + return Some(o); } // A file that claims to be an executable and whose headers describe a @@ -2451,14 +2799,17 @@ fn whole_buffer_heuristics( // that does not parse is a fragment or a false hit, not a broken file. // clamscan `--alert-broken` agrees: it reports a broken PE or ELF alone and // not embedded in another file. - if opts.alert_broken && executable && !embedded && pe::looks_broken_in(src) { - if let Some(o) = sink.hit( + if opts.alert_broken + && executable + && !embedded + && pe::looks_broken_in(src) + && let Some(o) = sink.hit( "Heuristics.Broken.Executable".to_string(), 0, Method::Heuristic, - ) { - return Some(o); - } + ) + { + return Some(o); } // An ELF whose section-header table has been stripped. Detected in BOTH // modes; only the name differs. It is not breakage (the program headers are @@ -2484,12 +2835,11 @@ fn whole_buffer_heuristics( // so the image shows one filesystem to whatever mounts it and another to // whatever scans it. Parser confusion one layer below the ZIP case above. // Opt-in, matching ClamAV's `--alert-partition-intersection`. - if opts.alert_partition_intersection { - if let Some(name) = unpack::partition_intersection_alert(src) { - if let Some(o) = sink.hit(name.to_string(), 0, Method::Heuristic) { - return Some(o); - } - } + if opts.alert_partition_intersection + && let Some(name) = unpack::partition_intersection_alert(src) + && let Some(o) = sink.hit(name.to_string(), 0, Method::Heuristic) + { + return Some(o); } // ClamAV `Heuristics.PDF.ObfuscatedNameObject`: a PDF whose name objects @@ -2500,14 +2850,13 @@ fn whole_buffer_heuristics( if (opts.clamav_heuristics || opts.heuristics) && ft == FileType::Pdf && whole().is_some_and(unpack::has_obfuscated_name_object) - { - if let Some(o) = sink.hit( + && let Some(o) = sink.hit( "Heuristics.PDF.ObfuscatedNameObject".to_string(), 0, Method::Heuristic, - ) { - return Some(o); - } + ) + { + return Some(o); } None } @@ -2719,12 +3068,11 @@ fn member_metadata_scan( // Opt-in (`--detect packed`): name the packer as well as reporting that its // payload went unread. Same rule as the encryption case above: two facts, // both true, both reported. - if cx.opts.alert_packed { - if let Some(n) = packed_heuristic_name(&e.name) { - if let Some(o) = sink.hit(n, 0, Method::Heuristic) { - return Some(o); - } - } + if cx.opts.alert_packed + && let Some(n) = packed_heuristic_name(&e.name) + && let Some(o) = sink.hit(n, 0, Method::Heuristic) + { + return Some(o); } if let Some(r) = e.unsupported { if e.encrypted { @@ -2745,14 +3093,14 @@ fn member_metadata_scan( // for an Excel 4.0 macro sheet. Emitting the bare name looked harmless // and is not: a gateway filtering on ClamAV's exact string matches // neither of ours. - if let Some(kind) = macro_dialect(&e.name) { - if let Some(o) = sink.hit( + if let Some(kind) = macro_dialect(&e.name) + && let Some(o) = sink.hit( format!("Heuristics.OLE2.ContainsMacros.{kind}"), 0, Method::Heuristic, - ) { - return Some(o); - } + ) + { + return Some(o); } } if !cx.db.cdb.is_empty() { @@ -2790,6 +3138,54 @@ fn member_metadata_scan( None } +/// Smallest and largest member worth remembering as scanned clean: below the +/// first, scanning again costs about what the digest does; past the second, the +/// digest is another read of a very large object. +const CLEAN_MEMO_MIN: usize = 64 * 1024; +const CLEAN_MEMO_MAX: usize = 512 * 1024 * 1024; + +/// A digest of a member's bytes and of what a scan of it reads from its place: +/// the type of its container, the type its container forces on it, the types +/// above it, and the extension of its name. `None` for a member outside the +/// sizes worth remembering. +fn clean_memo_key( + cx: &MemberCtx<'_>, + name: &str, + core_type: Option, + data: &dyn ByteSource, +) -> Option<[u8; 32]> { + use sha2::Digest; + let len = data.len(); + if !(CLEAN_MEMO_MIN..=CLEAN_MEMO_MAX).contains(&len) { + return None; + } + let ext = name + .rsplit_once('.') + .map_or("", |(_, e)| e) + .to_ascii_lowercase(); + let mut h = sha2::Sha256::new(); + h.update( + format!( + "{:?}|{core_type:?}|{:?}|{ext}|{len}|", + cx.member_container, + engine::ancestry_snapshot() + ) + .as_bytes(), + ); + let mut at = 0; + while at < len { + let w = data.window(at, byte_source::CHUNK.min(len - at)); + if w.is_empty() { + return None; + } + h.update(&w[..]); + at += w.len(); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&h.finalize()); + Some(key) +} + /// Scan one member's bytes, as an object one level below its container. /// /// Split from [`member_metadata_scan`] because a member of a multi-volume set @@ -2800,6 +3196,7 @@ fn member_metadata_scan( fn member_content_scan( cx: &MemberCtx<'_>, tally: &mut MemberTally, + name: &str, data: &dyn ByteSource, budget: &mut Budget, findings: &mut Vec, @@ -2825,11 +3222,30 @@ fn member_content_scan( container: cx.member_container, core_type, filename: None, + name: Some(name), core: true, carve: true, embedded: false, }; - match scan_object(cx.db, data, obj, cx.opts, budget, findings, sink) { + // The same bytes in the same place, already scanned clean at this depth or + // shallower, cannot give a different answer, and a document that holds + // copies of itself would otherwise be scanned once per copy. + let seen = clean_memo_key(cx, name, core_type, data); + if seen + .as_ref() + .is_some_and(|k| engine::clean_seen(k, obj.depth)) + { + return None; + } + let truncated_before = engine::scan_was_truncated(); + let outcome = scan_object(cx.db, data, obj, cx.opts, budget, findings, sink); + if let (DeepOutcome::Clean, Some(k)) = (&outcome, seen) { + // A scan that stopped short on the way has not seen all of it. + if truncated_before || !engine::scan_was_truncated() { + engine::clean_seen_insert(k, obj.depth); + } + } + match outcome { DeepOutcome::Clean => None, // Nested unscannable/encrypted members are remembered, not propagated as // a stop: keep scanning the rest of this container. @@ -2866,6 +3282,11 @@ struct Obj<'a> { /// The input's name, for YARA's filename externals. Nothing inside the /// input has one. filename: Option<&'a str>, + /// The name the object has where it sits (the input's, or a member's), for + /// what only a name says, like which text format a plain file is. Unlike + /// `filename`, a member has one; what is carved or decoded does not. + #[cfg_attr(not(feature = "visible-text"), allow(dead_code))] + name: Option<&'a str>, /// Whether to match the object's own bytes. Off for an archive carved out /// of an object whose own match already covered them. core: bool, @@ -2888,6 +3309,7 @@ impl<'a> Obj<'a> { container: None, core_type: None, filename: opts.filename.as_deref(), + name: opts.filename.as_deref(), core: true, carve: true, embedded: false, @@ -2901,6 +3323,7 @@ impl<'a> Obj<'a> { container, core_type: None, filename: None, + name: None, core: true, carve: true, embedded: false, @@ -2931,7 +3354,7 @@ fn scan_object( // anything inside it is a detection. Keyed on the object's bytes, so an // entry means the same thing wherever the object is found. let carve = obj.carve && obj.depth < budget.limits().max_recursion; - let facts = Facts::for_scan(data, db, carve); + let facts = Facts::for_scan(data, db, carve, opts); if allowlisted(db, &facts) { return DeepOutcome::Clean; } @@ -2961,10 +3384,23 @@ fn scan_archive( if obj.depth == 0 { findings.push(Finding::new("type", ft.as_str())); } - if let Some(o) = scan_carried(db, data, obj, ft, opts, budget, findings, sink) { + let mut carried = DeepOutcome::Clean; + if let Some(o) = scan_carried( + db, + data, + obj, + ft, + opts, + budget, + findings, + sink, + &mut carried, + ) { return o; } - let members = walk_members(db, data, obj, ft, fmt, opts, budget, findings, sink); + let mut members = walk_members(db, data, obj, ft, fmt, opts, budget, findings, sink); + // What the encoded files in the object turned up, if the walk found nothing. + defer(&mut members, carried); // Matched whatever stopped the walk short of a detection: a detection beats // a limit. if matches!(members, DeepOutcome::Infected { .. }) || !obj.core { @@ -2996,6 +3432,49 @@ fn scan_archive( ) { return o; } + // A file can be two archives. The magic at its start types it as one, and a + // reader that goes by another structure opens the other: a ZIP appended to a + // PDF, a tar or a gzip is the archive `unzip` finds by the record at the end + // of the file, and an ISO or ext image may begin with an archive's magic in + // the space it leaves free. What the first walk did not reach is walked as + // the second. + if obj.carve && obj.depth < budget.limits().max_recursion { + let second = Obj { + carve: false, + embedded: true, + ..obj.inner(obj.container) + }; + if fmt != unpack::Format::Zip + && let Some(at) = unpack::appended_zip_start(data) + && let Ok(at) = usize::try_from(at) + && at < data.len() + { + let sub = &byte_source::Sub::new(data, at, data.len() - at); + match scan_found(db, sub, second, opts, budget, findings, sink) { + DeepOutcome::Clean => {} + o @ (DeepOutcome::Infected { .. } | DeepOutcome::Limits(_)) => return o, + o => defer(&mut outcome, o), + } + } + if let Some(other) = unpack::also_reads_as(data, fmt) { + let o = walk_members( + db, + data, + second, + filetype::filetype_of_format(other), + other, + opts, + budget, + findings, + sink, + ); + match o { + DeepOutcome::Clean => {} + o @ (DeepOutcome::Infected { .. } | DeepOutcome::Limits(_)) => return o, + o => defer(&mut outcome, o), + } + } + } // An OLE2 file's executables are also carved from its own bytes, each // scanned from its header to the end of the file, as ClamAV carves them. // Its streams are sectors laid out in any order, so what follows an @@ -3038,6 +3517,7 @@ fn scan_carried( budget: &mut Budget, findings: &mut Vec, sink: &mut Sink, + deferred: &mut DeepOutcome, ) -> Option { // Embedded base64-encoded executables. Scripts/RTF/HTML carriers stash a // PE/ELF as a long base64 string (PowerShell reflective loaders, JS/VBS @@ -3046,14 +3526,13 @@ fn scan_carried( // real executable. Bounded by recursion depth and the scan budget; // exav-exclusive, so off under `--clamav-compat` and `--no-base64`. #[cfg(feature = "base64scan")] - if opts.decode_base64 - && obj.depth < budget.limits().max_recursion - && mostly_text(&data.window(0, 8192)) - { - for (b64ix, payload) in unpack::base64_payloads(data, budget.limits().max_buffer_bytes) - .into_iter() - .enumerate() - { + if opts.decode_base64 && obj.depth < budget.limits().max_recursion && text_anywhere(data) { + let (payloads, capped) = + unpack::base64_payloads_capped(data, budget.limits().max_buffer_bytes); + if capped { + engine::mark_capped(base64_capped); + } + for (b64ix, payload) in payloads.into_iter().enumerate() { // Name the decoded run so a hit inside it is attributable. Without // this the detection reports no location and reads as a match on the // carrier's own bytes, which is how a correct `Win.Trojan.Mimikatz` @@ -3071,9 +3550,10 @@ fn scan_carried( carve: false, ..obj.inner(payload_container) }; - let o = scan_found(db, &payload, inner, opts, budget, findings, sink); - if matches!(o, DeepOutcome::Infected { .. } | DeepOutcome::Limits(_)) { - return Some(o); + match scan_found(db, &payload, inner, opts, budget, findings, sink) { + DeepOutcome::Clean => {} + o @ (DeepOutcome::Infected { .. } | DeepOutcome::Limits(_)) => return Some(o), + o => defer(deferred, o), } } } @@ -3087,23 +3567,26 @@ fn scan_carried( // those constraints exist to separate "this image, in a document" from // "this image, on its own". #[cfg(feature = "base64scan")] - if opts.decode_base64 && obj.depth < budget.limits().max_recursion { - if let Some(mc) = markup_cltype(ft, &data.window(0, 4096)) { - let _ag = engine::AncestryGuard::enter(Some(mc)); - for (ix, payload) in - unpack::markup_embedded_payloads(data, budget.limits().max_buffer_bytes) - .into_iter() - .enumerate() - { - let _mpg = MatchPathGuard::enter(&format!("embedded-asset-{}", ix + 1)); - let inner = Obj { - carve: false, - ..obj.inner(Some(mc)) - }; - let o = scan_found(db, &payload, inner, opts, budget, findings, sink); - if matches!(o, DeepOutcome::Infected { .. } | DeepOutcome::Limits(_)) { - return Some(o); - } + if opts.decode_base64 + && obj.depth < budget.limits().max_recursion + && let Some(mc) = markup_cltype(ft, &data.window(0, 4096)) + { + let _ag = engine::AncestryGuard::enter(Some(mc)); + let (payloads, capped) = + unpack::markup_embedded_payloads_capped(data, budget.limits().max_buffer_bytes); + if capped { + engine::mark_capped(base64_capped); + } + for (ix, payload) in payloads.into_iter().enumerate() { + let _mpg = MatchPathGuard::enter(&format!("embedded-asset-{}", ix + 1)); + let inner = Obj { + carve: false, + ..obj.inner(Some(mc)) + }; + match scan_found(db, &payload, inner, opts, budget, findings, sink) { + DeepOutcome::Clean => {} + o @ (DeepOutcome::Infected { .. } | DeepOutcome::Limits(_)) => return Some(o), + o => defer(deferred, o), } } } @@ -3252,7 +3735,17 @@ fn scan_file( ) { return o; } - if let Some(o) = scan_carried(db, data, obj, ft, opts, budget, findings, sink) { + if let Some(o) = scan_carried( + db, + data, + obj, + ft, + opts, + budget, + findings, + sink, + &mut deferred, + ) { return o; } @@ -3305,7 +3798,11 @@ fn scan_file( let sub = &byte_source::Sub::new(data, off, data.len() - off); match scan_found(db, sub, carved, opts, budget, findings, sink) { DeepOutcome::Clean => {} - other => return other, + // As in the loops below: an image that could not be read whole + // is reported if nothing else is found, and must not stop the + // scan of the rest of the carrier. + o @ (DeepOutcome::Infected { .. } | DeepOutcome::Limits(_)) => return o, + o => defer(&mut deferred, o), } } @@ -3393,27 +3890,27 @@ fn scan_file( // Only a PE carries one, and it is parsed from all of the file. let signed = (opts.alert_broken_authenticode || !db.crb.is_empty()) && data.window(0, 2)[..] == *b"MZ"; - if signed { - if let Some(sig) = + if signed + && let Some(sig) = whole(data, opts, "the Authenticode checks").and_then(|w| authenticode::analyze_pe(&w)) - { - for cert in &sig.certs { - if let Some(name) = db.crb.blocked(cert) { - if let Some(o) = sink.hit(name.to_string(), 0, Method::Hash) { - return o; - } - } - } - if opts.alert_broken_authenticode && !sig.digest_matches { - if let Some(o) = sink.hit( - "Heuristics.Authenticode.HashMismatch".to_string(), - 0, - Method::Heuristic, - ) { - return o; - } + { + for cert in &sig.certs { + if let Some(name) = db.crb.blocked(cert) + && let Some(o) = sink.hit(name.to_string(), 0, Method::Hash) + { + return o; } } + if opts.alert_broken_authenticode + && !sig.digest_matches + && let Some(o) = sink.hit( + "Heuristics.Authenticode.HashMismatch".to_string(), + 0, + Method::Heuristic, + ) + { + return o; + } } // Nothing below fires unless at least the ClamAV-default heuristics are on @@ -3424,14 +3921,13 @@ fn scan_file( // TLSH fuzzy matching is exav-exclusive (ClamAV has no TLSH), so it stays // behind the full `--detect exav-heuristics` flag and off under `--clamav-compat`. - if opts.heuristics { - if let Some(hit) = profile::timed("fuzzy", data.len() as u64, || { + if opts.heuristics + && let Some(hit) = profile::timed("fuzzy", data.len() as u64, || { db.fuzzy.match_tlsh_source(data) - }) { - if let Some(o) = sink.hit(hit, 0, Method::Fuzzy) { - return o; - } - } + }) + && let Some(o) = sink.hit(hit, 0, Method::Fuzzy) + { + return o; } // Parsed from all of the PE, so read only when something below uses it. @@ -3451,10 +3947,9 @@ fn scan_file( if let Some(hit) = db .fuzzy .match_imphash(&info.imphash, info.import_count as u64) + && let Some(o) = sink.hit(hit, 0, Method::Fuzzy) { - if let Some(o) = sink.hit(hit, 0, Method::Fuzzy) { - return o; - } + return o; } if opts.heuristics { if depth == 0 { @@ -3486,23 +3981,24 @@ fn scan_file( format!("{score:.2} ({})", db.model.name()), )); } - if score >= db.ml_threshold { - if let Some(o) = sink.hit( + if score >= db.ml_threshold + && let Some(o) = sink.hit( format!("Heuristics.Static.Suspect.{:.0}", score * 100.0), 0, Method::Static, - ) { - return o; - } + ) + { + return o; } - if info.looks_packed() && info.suspicious_imports.len() >= 2 { - if let Some(o) = sink.hit( + if info.looks_packed() + && info.suspicious_imports.len() >= 2 + && let Some(o) = sink.hit( "Heuristics.PE.PackedWithInjectionImports".to_string(), 0, Method::Heuristic, - ) { - return o; - } + ) + { + return o; } } } @@ -3576,27 +4072,25 @@ fn structured_data_scan( Some(d) => dlp::count_ssns(d, dlp::SsnMode::Both), None => dlp::count_ssns_in(&mut byte_source::Stepper::new(data), dlp::SsnMode::Both), }; - if let Some(threshold) = opts.structured_cc_count { - if count_cards() >= threshold as usize { - if let Some(o) = sink.hit( - "Heuristics.Structured.CreditCardNumber".to_string(), - 0, - Method::Heuristic, - ) { - return Some(o); - } - } + if let Some(threshold) = opts.structured_cc_count + && count_cards() >= threshold as usize + && let Some(o) = sink.hit( + "Heuristics.Structured.CreditCardNumber".to_string(), + 0, + Method::Heuristic, + ) + { + return Some(o); } - if let Some(threshold) = opts.structured_ssn_count { - if count_ssns() >= threshold as usize { - if let Some(o) = sink.hit( - "Heuristics.Structured.SSN".to_string(), - 0, - Method::Heuristic, - ) { - return Some(o); - } - } + if let Some(threshold) = opts.structured_ssn_count + && count_ssns() >= threshold as usize + && let Some(o) = sink.hit( + "Heuristics.Structured.SSN".to_string(), + 0, + Method::Heuristic, + ) + { + return Some(o); } None } @@ -3715,6 +4209,33 @@ fn mostly_text(data: &[u8]) -> bool { printable * 100 >= sample.len() * 90 } +/// Whether any stretch of `data` is text, going by up to four 8 KiB samples (its +/// start, its end and the thirds between, stopping at the first that has any) +/// and by the kilobytes of a sample one by one. A script behind a binary header +/// or a long comment of high bytes is text where it matters. Each sample over a +/// cache is a block of it read, so a binary object pays at most three more than +/// the first. +#[cfg(feature = "base64scan")] +fn text_anywhere(data: &dyn ByteSource) -> bool { + const SAMPLE: usize = 8192; + let texty = |at: usize| data.window(at, SAMPLE).chunks(1024).any(mostly_text); + let len = data.len(); + if len <= SAMPLE { + return texty(0); + } + let last = len - SAMPLE; + [0, last, last / 3, last / 3 * 2].into_iter().any(texty) +} + +/// Why an object that has more base64-encoded files than are decoded in one +/// object is not fully scanned. +#[cfg(feature = "base64scan")] +fn base64_capped() -> String { + "more base64-encoded executables, archives or images than are decoded in one object: \ + the rest were not scanned" + .to_string() +} + /// The normalised views of `data` a textual file is matched against, in order. /// The caller makes each with [`make_view`] and drops it before the next. /// @@ -3735,7 +4256,7 @@ fn normalizations(ft: FileType, data: &dyn ByteSource) -> Vec<(ViewKind, FileTyp return vec![ (ViewKind::Html, FileType::HtmlView), (ViewKind::Text, FileType::TextView), - ] + ]; } FileType::Html => (ViewKind::Html, FileType::HtmlView), _ => (ViewKind::Text, FileType::TextView), @@ -3748,6 +4269,58 @@ fn normalizations(ft: FileType, data: &dyn ByteSource) -> Vec<(ViewKind, FileTyp v } +/// The text a reader sees in the object `data` called `name` (Markdown, HTML, +/// an Office or OpenDocument XML part, PDF, a drawing): its words, and where +/// each link leads (`exav_render::text`). +/// +/// The text as a text view is matched, and each link whose label is not its +/// target as `(label, target)`. +/// +/// `None` when the object has none, or when it cannot be made, and the scan is +/// then marked incomplete: the object is past `deep_analysis_max`, so it is +/// neither held in memory nor read whole. A text cut at four times the object and a little, +/// which no real document comes to, and a PDF past the extraction limits, mark +/// the scan incomplete too; what was made is still scanned. So does an +/// office document (with `visible-text-office`) whose text cannot be taken +/// out: encrypted, past a limit, damaged. +#[cfg(feature = "visible-text")] +fn visible_text_view(name: &str, data: &dyn ByteSource, opts: &ScanOptions) -> Option { + if !exav_render::text::wants(name, &data.window(0, 1024)) { + return None; + } + let bytes = whole(data, opts, "the reading of its visible text")?; + let cap = bytes + .len() + .saturating_mul(4) + .saturating_add(64 << 10) + .min(usize::try_from(opts.deep_analysis_max).unwrap_or(usize::MAX)); + let visible = exav_render::text::visible_text(name, &bytes, cap, &opts.limits)?; + if visible.truncated { + engine::mark_scan_truncated(); + } + // A document of a kind that has text, whose text could not be taken out + // (encrypted, past a limit, damaged): its bytes are still scanned, and the + // scan is not complete. + if visible.unreadable.is_some() { + engine::mark_scan_truncated(); + return None; + } + // As any text view is, so that `Target:7` signatures, written against the + // lowercased, whitespace-collapsed form, apply. + Some(VisibleView { + text: normalize::text(visible.text.as_bytes()), + links: visible.links, + }) +} + +/// A document's visible text as a text view, and its links. +#[cfg(feature = "visible-text")] +struct VisibleView { + text: Vec, + /// Each link whose label is not its target, as `(label, target)`. + links: Vec<(String, String)>, +} + /// A normalised view of an object: [`normalize`]'s three, and [`jsnorm`]'s. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ViewKind { @@ -3819,7 +4392,7 @@ fn make_view(kind: ViewKind, data: &dyn ByteSource, opts: &ScanOptions) -> Optio ViewKind::Text => normalize::text_into(&mut src, &mut out), _ => normalize::javascript_into(&mut src, &mut out), } - match out.finish() { + match out.finish(opts.cache_sizes()) { Ok(spilled) => Some(View::Spilled(spilled)), Err(e) => { not_made(&e); @@ -3877,6 +4450,7 @@ fn write_limits(budget: &Budget) -> bytecode::exec::WriteLimits { bytecode::exec::WriteLimits { file: budget.limits().max_buffer_bytes, total: budget.remaining_scan(), + memory: budget.limits().max_bytecode_memory_bytes, } } @@ -3938,8 +4512,13 @@ fn core_matches( }; // A PE's structure (layout, icons, section table) is parsed from all of it. let image = pe_image(ft, data, opts); - // PE layout lets the engine resolve EP/section-relative offsets. - let layout = image.as_deref().and_then(pe::layout); + // PE layout lets the engine resolve EP/section-relative offsets, and so + // does an ELF's or a Mach-O's, read from its headers. + let layout = image.as_deref().and_then(pe::layout).or_else(|| match ft { + FileType::Elf => pe::elf_layout(data), + FileType::MachO => pe::macho_layout(data), + _ => None, + }); // PE-icon perceptual metrics for `IconGroup1/2`-constrained logical // signatures: computed only for a PE when `.idb` entries are loaded. They // gate such sigs (the structural condition must also hold); see [`icon`]. @@ -4001,6 +4580,31 @@ fn core_matches( engine_pass(view.source(), as_type, None, None, None)?; } } + // The words of a document, and where each link leads: what a reader sees, + // which the bytes above do not give (a label naming one site over a target + // on another, markup, an entity, a compressed stream or an escape in the + // middle of a signature's text). + #[cfg(feature = "visible-text")] + if !opts.clamav_compat + && !db.engine.is_empty() + && let Some(view) = profile::timed("visible-text", data.len() as u64, || { + visible_text_view(obj.name.unwrap_or(""), data, opts) + }) + { + // Reported where the document is, as the other views are. + engine_pass(&view.text, FileType::TextView, None, None, None)?; + // A link that says one site and goes to another, in a document + // that is not HTML: judged as an anchor of an HTML one is. + if opts.alert_phishing { + let (found, complete) = phishing::scan_links(&view.links, &db.phishing); + if !complete { + engine::mark_scan_truncated(); + } + if let Some(p) = found { + hit(p.signature(), false, 0, Method::Heuristic)?; + } + } + } // Bytecode programs whose trigger/hook fires (gated execution). Their // names are reported verbatim, never `.UNOFFICIAL`-suffixed. let mut unpacked = Vec::new(); @@ -4014,28 +4618,29 @@ fn core_matches( } unpacked = extracted; } - if !db.sections.is_empty() { - if let Some(image) = &image { - let found: Vec<_> = profile::timed("sections", data.len() as u64, || { - pe::section_slices(image) - .into_iter() - .filter_map(|(size, slice)| { - db.sections - .lookup(size, &hashes::section_digests(&db.sections, size, slice)) - }) - .collect() - }); - for (name, unofficial) in found { - hit(&name, unofficial, 0, Method::Hash)?; - } + if !db.sections.is_empty() + && let Some(image) = &image + { + let found: Vec<_> = profile::timed("sections", data.len() as u64, || { + pe::section_slices(image) + .into_iter() + .filter_map(|(size, slice)| { + db.sections + .lookup(size, &hashes::section_digests(&db.sections, size, slice)) + }) + .collect() + }); + for (name, unofficial) in found { + hit(&name, unofficial, 0, Method::Hash)?; } } - if !db.hashes.is_empty() && hash_matchable(data.len() as u64) { - if let Some((name, unofficial)) = profile::timed("hashes", data.len() as u64, || { + if !db.hashes.is_empty() + && hash_matchable(data.len() as u64) + && let Some((name, unofficial)) = profile::timed("hashes", data.len() as u64, || { db.hashes.lookup(facts.digests(db), data.len() as u64) - }) { - hit(&name, unofficial, 0, Method::Hash)?; - } + }) + { + hit(&name, unofficial, 0, Method::Hash)?; } if !db.yara.is_empty() { let unofficial = db.yara.unofficial(); @@ -4078,8 +4683,8 @@ mod tests { /// How many times over a scan by `db` reads `data` from a source it does /// not hold in memory. fn passes_over(db: &Scanner, data: &[u8]) -> f64 { - use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; + use std::sync::atomic::{AtomicU64, Ordering}; let read = Arc::new(AtomicU64::new(0)); let reader = Counting(Cursor::new(data.to_vec()), read.clone()); let opts = ScanOptions { @@ -4160,6 +4765,106 @@ mod tests { } } + /// The size of the cache of an object read in place and of its blocks are + /// the scan's: a cache past the object serves the later reads of it from + /// memory, and a smaller block is more reads of the source. + #[test] + fn the_object_cache_follows_the_options() { + use std::sync::atomic::{AtomicU64, Ordering}; + struct Tally(Cursor>, std::sync::Arc<(AtomicU64, AtomicU64)>); + impl std::io::Read for Tally { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.0.read(buf)?; + self.1.0.fetch_add(n as u64, Ordering::Relaxed); + self.1.1.fetch_add(1, Ordering::Relaxed); + Ok(n) + } + } + impl std::io::Seek for Tally { + fn seek(&mut self, p: std::io::SeekFrom) -> std::io::Result { + self.0.seek(p) + } + } + // Noise with no magic, a hash signature of its size: detection, the + // digests and the sweep each read it through. + let size = 12 << 20; + let mut state = 0x9e37_79b9_7f4a_7c15u64; + let mut noise: Vec = (0..size) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 24) as u8 + }) + .collect(); + for w in 0..noise.len() - 1 { + if matches!( + &noise[w..w + 2], + b"MZ" | b"PK" | b"7z" | b"BZ" | b"\x1f\x8b" | b"Ra" | b"MS" + ) { + noise[w] ^= 0x80; + } + } + let mut l = loader::Builder::new(); + l.add_named_bytes("a.ndb", b"Sig.Pat:0:*:0badc0de0badf00d0bad\n", true); + let hdb = format!("0123456789abcdef0123456789abcdef:{size}:Sig.Hash\n"); + l.add_named_bytes("c.hdb", hdb.as_bytes(), true); + let db = l.build().unwrap(); + // (bytes read from the source, reads of it) for a scan. + let reads = |cache: u64, block: u64| { + let tally = std::sync::Arc::new((AtomicU64::new(0), AtomicU64::new(0))); + let opts = ScanOptions { + deep_analysis_max: 1 << 20, + object_cache_bytes: cache, + object_cache_block_bytes: block, + ..ScanOptions::default() + }; + let reader = Tally(Cursor::new(noise.clone()), tally.clone()); + scan_seekable(&db, reader, size as u64, &opts).unwrap(); + ( + tally.0.load(Ordering::Relaxed), + tally.1.load(Ordering::Relaxed), + ) + }; + let (small, small_reads) = reads(8 << 20, 64 << 10); + let (big, _) = reads(16 << 20, 64 << 10); + assert!(small > size as u64 * 3 / 2, "{small}"); + assert!(big <= size as u64 + (1 << 20), "{big}"); + let (_, fine_reads) = reads(8 << 20, 4 << 10); + assert!( + fine_reads > 8 * small_reads, + "{fine_reads} vs {small_reads}" + ); + } + + /// A source that reads on without end and measures itself as empty (a + /// character device such as `/dev/zero`) is an error, not a clean scan of + /// nothing. + #[test] + fn a_source_with_no_length_is_an_error_not_a_clean_scan() { + struct Endless; + impl std::io::Read for Endless { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + buf.fill(0); + Ok(buf.len()) + } + } + impl std::io::Seek for Endless { + fn seek(&mut self, _: std::io::SeekFrom) -> std::io::Result { + Ok(0) + } + } + let opts = ScanOptions { + deep_analysis_max: 4096, + ..ScanOptions::default() + }; + let e = scan_seekable(&Scanner::builtin(), Endless, 0, &opts).unwrap_err(); + assert!(e.to_string().contains("not a file with a length"), "{e}"); + // A file that grew while it was read is still scanned as what it is. + let big = std::io::Cursor::new(vec![b'a'; 10_000]); + assert!(scan_seekable(&Scanner::builtin(), big, 100, &opts).is_ok()); + } + /// The one read of a streamed object finds what each step's own read of /// it would, whichever step asks first. #[test] @@ -4206,7 +4911,7 @@ mod tests { let cache = byte_source::BlockCache::with_sizes(Cursor::new(data.clone()), 4093, 16 * 4093) .unwrap(); - let facts = Facts::for_scan(&cache, &db, true); + let facts = Facts::for_scan(&cache, &db, true, &ScanOptions::default()); let mut embedded = None; for step in (0..3).map(|k| (first + k) % 3) { match step { @@ -4256,7 +4961,7 @@ mod tests { let read = std::sync::Arc::new(AtomicU64::new(0)); let reader = Counting(Cursor::new(vec![b'.'; len]), read.clone()); let cache = byte_source::BlockCache::with_sizes(reader, 4093, 16 * 4093).unwrap(); - let facts = Facts::for_scan(&cache, &db, false); + let facts = Facts::for_scan(&cache, &db, false, &ScanOptions::default()); let before = read.load(Ordering::Relaxed); assert!(!allowlisted(&db, &facts)); assert_eq!( @@ -5123,6 +5828,111 @@ mod tests { } } + /// 128 KiB of text that differs with `seed`. + #[cfg(feature = "all-formats")] + fn memo_body(seed: usize) -> Vec { + let text: String = (0..4096) + .map(|i| format!("line {seed} {i} of a member with nothing in it\n")) + .collect(); + text.as_bytes()[..128 * 1024].to_vec() + } + + /// A stored zip of `n` members of `memo_body`, the same in each when `same`. + #[cfg(feature = "all-formats")] + fn memo_zip(n: usize, same: bool) -> Vec { + use std::io::Write; + let stored = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for k in 0..n { + z.start_file(format!("member{k}.txt"), stored).unwrap(); + z.write_all(&memo_body(if same { 0 } else { k })).unwrap(); + } + z.finish().unwrap().into_inner() + } + + fn clean_skips_of(data: &[u8]) -> (Verdict, u64) { + engine::CLEAN_SKIPS.with(|c| c.set(0)); + let v = analyze(&Scanner::builtin(), data, &ScanOptions::default()).verdict; + (v, engine::CLEAN_SKIPS.with(|c| c.get())) + } + + /// Bytes already scanned clean in the same place are not scanned again: + /// the first of 40 copies is scanned and the other 39 are not. + #[test] + #[cfg(feature = "all-formats")] + fn copies_of_a_clean_member_are_scanned_once() { + assert_eq!(clean_skips_of(&memo_zip(40, true)), (Verdict::Clean, 39)); + } + + /// The control: members that differ are each scanned. + #[test] + #[cfg(feature = "all-formats")] + fn members_that_differ_are_each_scanned() { + assert_eq!(clean_skips_of(&memo_zip(40, false)), (Verdict::Clean, 0)); + } + + /// Members under the size worth remembering are scanned every time. + #[test] + #[cfg(feature = "all-formats")] + fn small_copies_are_each_scanned() { + use std::io::Write; + let stored = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for k in 0..10 { + z.start_file(format!("m{k}.txt"), stored).unwrap(); + z.write_all(&memo_body(0)[..1000]).unwrap(); + } + let z = z.finish().unwrap().into_inner(); + assert_eq!(clean_skips_of(&z), (Verdict::Clean, 0)); + } + + /// A copy that is not clean is found wherever it is, after clean ones with + /// the same start. + #[test] + #[cfg(feature = "all-formats")] + fn an_infected_member_after_clean_copies_is_found() { + use std::io::Write; + let stored = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + // Deflated, so the signature is in the member only, not in the zip's bytes. + let deflated = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for k in 0..3 { + z.start_file(format!("clean{k}.bin"), stored).unwrap(); + z.write_all(&memo_body(0)).unwrap(); + } + let mut dirty = memo_body(0); + dirty.extend_from_slice(&[eicar(), b"\n"].concat().repeat(64)); + z.start_file("dirty.bin", deflated).unwrap(); + z.write_all(&dirty).unwrap(); + let z = z.finish().unwrap().into_inner(); + let (v, skips) = clean_skips_of(&z); + assert_eq!(skips, 2); + assert!(matches!(v, Verdict::Infected { .. }), "{v:?}"); + } + + /// The memo remembers the shallowest depth a clean scan was at: a copy + /// deeper than that is covered, one shallower is not. + #[test] + fn a_clean_copy_covers_only_the_same_depth_or_deeper() { + engine::reset_scan_truncated(); + let key = [7u8; 32]; + assert!(!engine::clean_seen(&key, 3)); + engine::clean_seen_insert(key, 3); + assert!(!engine::clean_seen(&key, 2)); + assert!(engine::clean_seen(&key, 3)); + assert!(engine::clean_seen(&key, 9)); + engine::clean_seen_insert(key, 1); + assert!(engine::clean_seen(&key, 1)); + assert!(!engine::clean_seen(&key, 0)); + // A new scan starts with nothing remembered. + engine::reset_scan_truncated(); + assert!(!engine::clean_seen(&key, 9)); + } + #[test] fn detects_eicar_pattern() { let db = Scanner::builtin(); @@ -5472,8 +6282,8 @@ mod tests { #[test] fn finds_eicar_inside_gzip() { - use flate2::write::GzEncoder; use flate2::Compression; + use flate2::write::GzEncoder; use std::io::Write; let mut e = GzEncoder::new(Vec::new(), Compression::default()); e.write_all(eicar()).unwrap(); @@ -5611,8 +6421,8 @@ mod tests { ignore = "host filesystem/tempdir unavailable under WASI" )] fn streamed_gzip_over_cap_finds_eicar() { - use flate2::write::GzEncoder; use flate2::Compression; + use flate2::write::GzEncoder; use std::io::Write; let mut e = GzEncoder::new(Vec::new(), Compression::default()); e.write_all(eicar()).unwrap(); @@ -5700,8 +6510,8 @@ mod tests { ignore = "host filesystem/tempdir unavailable under WASI" )] fn nested_gzip_over_entry_cap_is_streamed_and_found() { - use flate2::write::GzEncoder; use flate2::Compression; + use flate2::write::GzEncoder; use std::io::Write; // gzip whose decompressed content (padding + EICAR at the end) far exceeds // the tiny per-member buffer cap set below. @@ -5874,8 +6684,8 @@ mod tests { fn http_range_scan_fetches_only_what_it_needs() { use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; - use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; + use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; let big: Vec = (0..1_000_000u32).map(|i| (i as u8) ^ 0x5a).collect(); @@ -5932,7 +6742,10 @@ mod tests { served_t.fetch_add(slice.len() as u64, Ordering::SeqCst); let hdr = format!( "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes {}-{}/{}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - a, b, blob_t.len(), slice.len() + a, + b, + blob_t.len(), + slice.len() ); let _ = stream.write_all(hdr.as_bytes()); let _ = stream.write_all(slice); @@ -5995,10 +6808,9 @@ mod parity { unpack::read_fixture( path.to_string_lossy().trim_end_matches(".xor"), ) + && data.len() < 8 << 20 { - if data.len() < 8 << 20 { - out.push((path.display().to_string(), data)); - } + out.push((path.display().to_string(), data)); } } } diff --git a/crates/exav-core/src/loader.rs b/crates/exav-core/src/loader.rs index de691bcf..e949f8a7 100644 --- a/crates/exav-core/src/loader.rs +++ b/crates/exav-core/src/loader.rs @@ -89,11 +89,11 @@ fn read_capped(path: &Path) -> Result, LoadError> { } Ok(buf) } +use crate::Scanner; use crate::fuzzy::FuzzyDb; use crate::hashes::{HashDb, SectionHashDb}; use crate::ml::HeuristicModel; use crate::patterns::eicar; -use crate::Scanner; /// Accumulates parsed signatures from multiple files before building. #[derive(Default)] @@ -799,7 +799,7 @@ Sig.C;td;0;cleartext-pw\n"; // duplicate password de-duped ignore = "host filesystem/tempdir unavailable under WASI" )] fn report_time_suffix_from_one_db() { - use crate::{analyze, ScanOptions, Verdict}; + use crate::{ScanOptions, Verdict, analyze}; let dir = crate::tmpfile::TempDir::new().unwrap(); std::fs::write(dir.path().join("a.ndb"), "Demo.Loose:0:*:6d616c77617265\n").unwrap(); let db = load(dir.path()).unwrap(); @@ -828,7 +828,7 @@ Sig.C;td;0;cleartext-pw\n"; // duplicate password de-duped /// mixed source. #[test] fn official_cvd_never_suffixed_mixed_source() { - use crate::{analyze, ScanOptions, Verdict}; + use crate::{ScanOptions, Verdict, analyze}; let mut builder = Builder::new(); // Official member (as if from a `.cvd`): 6f6666696369616c = "official". builder.add_named_text("main.ndb", "Demo.Official:0:*:6f6666696369616c\n", true); @@ -866,7 +866,7 @@ Sig.C;td;0;cleartext-pw\n"; // duplicate password de-duped ignore = "host filesystem/tempdir unavailable under WASI" )] fn ign_suppresses_suffixed_name_in_compat() { - use crate::{analyze, ScanOptions, Verdict}; + use crate::{ScanOptions, Verdict, analyze}; let dir = crate::tmpfile::TempDir::new().unwrap(); std::fs::write(dir.path().join("a.ndb"), "Demo.Loose:0:*:6d616c77617265\n").unwrap(); // A real-world compat `.ign2` names the suffixed detection. @@ -896,7 +896,7 @@ Sig.C;td;0;cleartext-pw\n"; // duplicate password de-duped ignore = "host filesystem/tempdir unavailable under WASI" )] fn a_db_signature_matches_in_memory() { - use crate::{analyze, ScanOptions, Verdict}; + use crate::{ScanOptions, Verdict, analyze}; let dir = crate::tmpfile::TempDir::new().unwrap(); std::fs::write(dir.path().join("x.db"), "Test.Db=6576696c31706179\n").unwrap(); let db = load(dir.path()).unwrap(); @@ -913,7 +913,7 @@ Sig.C;td;0;cleartext-pw\n"; // duplicate password de-duped ignore = "host filesystem/tempdir unavailable under WASI" )] fn pua_literals_stay_off_by_default() { - use crate::{analyze, ScanOptions, Verdict}; + use crate::{ScanOptions, Verdict, analyze}; let scan = |db: &Scanner, data: &[u8]| analyze(db, data, &ScanOptions::default()); let dir = crate::tmpfile::TempDir::new().unwrap(); std::fs::write( @@ -943,7 +943,7 @@ Sig.C;td;0;cleartext-pw\n"; // duplicate password de-duped ignore = "host filesystem/tempdir unavailable under WASI" )] fn pua_signatures_stay_off_on_every_scan_path() { - use crate::{analyze, scan_path, scan_seekable, ScanOptions, Verdict}; + use crate::{ScanOptions, Verdict, analyze, scan_path, scan_seekable}; use std::io::Write; let dir = crate::tmpfile::TempDir::new().unwrap(); std::fs::write( diff --git a/crates/exav-core/src/normalize.rs b/crates/exav-core/src/normalize.rs index fbc053b1..0a3fd2ab 100644 --- a/crates/exav-core/src/normalize.rs +++ b/crates/exav-core/src/normalize.rs @@ -155,11 +155,10 @@ pub(crate) fn javascript_into(data: &mut B, out: &mut O) { let c = lower(b); if pending_ws { pending_ws = false; - if let Some(last) = out.last() { - if is_word(last) && is_word(c) { + if let Some(last) = out.last() + && is_word(last) && is_word(c) { out.push(b' '); } - } } out.push(c); }); @@ -453,11 +452,10 @@ mod tests { let c = lower(b); if pending_ws { pending_ws = false; - if let Some(&last) = out.last() { - if is_word(last) && is_word(c) { + if let Some(&last) = out.last() + && is_word(last) && is_word(c) { out.push(b' '); } - } } out.push(c); } diff --git a/crates/exav-core/src/pe.rs b/crates/exav-core/src/pe.rs index a8723f2c..bf21d0a9 100644 --- a/crates/exav-core/src/pe.rs +++ b/crates/exav-core/src/pe.rs @@ -538,6 +538,162 @@ pub fn layout(data: &[u8]) -> Option { }) } +/// Most sections an ELF or Mach-O layout lists: a section table is a `u16` +/// long in an ELF, and nothing a real file has comes near it. +const MAX_EXEC_SECTIONS: u64 = 1 << 16; + +/// [`PeLayout`] of an ELF file, for the `EP`, `Sn`, `SL` and `SEn` offsets of a +/// `Target:6` signature, read from the headers alone (the object is not held). +/// +/// As clamscan resolves them (measured, with one signature per offset and one +/// wrong by three bytes as the control): `EP` is the entry point's file +/// offset, found by the loadable segment whose memory image holds it (the +/// part past the file's bytes, the `.bss`, included), and `Sn` is the file +/// offset of section header `n`, counted from the null section at 0. +/// A section with no bytes in the file (`SHT_NOBITS`) is a point. +pub fn elf_layout(src: &dyn ByteSource) -> Option { + let mut f = Fields::new(src); + let ident = f.get(0, 16)?; + if &ident[..4] != b"\x7fELF" { + return None; + } + let wide = match ident[4] { + 1 => false, + 2 => true, + _ => return None, + }; + // Anything but little-endian is read as big-endian, as `elf_broken` does. + f.be = ident[5] != 1; + let (header, ph_min, sh_min) = if wide { (64, 56, 64) } else { (52, 32, 40) }; + f.get(0, header)?; + let w = if wide { 8 } else { 4 }; + let entry_va = f.word(24, wide)?; + let (phoff, shoff) = (f.word(24 + w, wide)?, f.word(24 + 2 * w, wide)?); + let fields = 28 + 3 * w; + let [ph_entsize, phnum, sh_entsize, shnum] = + [2, 4, 6, 8].map(|i| f.u16(fields + i).map(u64::from)); + let (ph_entsize, phnum, sh_entsize, shnum) = + (ph_entsize?, phnum?, sh_entsize?, shnum?); + + let mut entry = None; + if ph_entsize >= ph_min { + let (offset_at, vaddr_at, memsz_at) = if wide { (8, 16, 40) } else { (4, 8, 20) }; + for i in 0..phnum { + let p = phoff.checked_add(i * ph_entsize)?; + let (Some(kind), Some(offset), Some(vaddr), Some(memsz)) = ( + f.u32(p), + p.checked_add(offset_at).and_then(|a| f.word(a, wide)), + p.checked_add(vaddr_at).and_then(|a| f.word(a, wide)), + p.checked_add(memsz_at).and_then(|a| f.word(a, wide)), + ) else { + break; + }; + // PT_LOAD + if kind == 1 && entry_va >= vaddr && entry_va - vaddr < memsz { + entry = offset.checked_add(entry_va - vaddr); + break; + } + } + } + + let mut section_rawptrs = Vec::new(); + let mut section_rawsizes = Vec::new(); + if sh_entsize >= sh_min { + let (offset_at, size_at) = if wide { (24, 32) } else { (16, 20) }; + for i in 0..shnum.min(MAX_EXEC_SECTIONS) { + let Some(s) = shoff.checked_add(i * sh_entsize) else { + break; + }; + let (Some(kind), Some(offset), Some(size)) = ( + s.checked_add(4).and_then(|a| f.u32(a)), + s.checked_add(offset_at).and_then(|a| f.word(a, wide)), + s.checked_add(size_at).and_then(|a| f.word(a, wide)), + ) else { + break; + }; + section_rawptrs.push(offset); + // SHT_NOBITS + section_rawsizes.push(if kind == 8 { 0 } else { size }); + } + } + Some(PeLayout { + entry, + section_rawptrs, + section_rawsizes, + version_info: Vec::new(), + }) +} + +/// [`PeLayout`] of a thin Mach-O file, for the `Sn`, `SL` and `SEn` offsets +/// of a `Target:9` signature. `n` counts the sections of every segment in the +/// order of the load commands, and an offset is the section's own. +/// +/// `EP` is left unresolved: clamscan 1.4.3 does not resolve it on a Mach-O of +/// either width, from `LC_MAIN` or from `LC_UNIXTHREAD` (a signature +/// anchored there never fired in any of eight variants, where the section +/// offsets did, each with a control), so a signature using it matches +/// nowhere, as it does there. +pub fn macho_layout(src: &dyn ByteSource) -> Option { + let mut f = Fields::new(src); + let (wide, be) = match f.get(0, 4)? { + [0xCE, 0xFA, 0xED, 0xFE] => (false, false), + [0xCF, 0xFA, 0xED, 0xFE] => (true, false), + [0xFE, 0xED, 0xFA, 0xCE] => (false, true), + [0xFE, 0xED, 0xFA, 0xCF] => (true, true), + _ => return None, + }; + f.be = be; + let header: u64 = if wide { 32 } else { 28 }; + let (ncmds, sizeofcmds) = (u64::from(f.u32(16)?), u64::from(f.u32(20)?)); + let end = header.checked_add(sizeofcmds)?; + if end > src.len() as u64 { + return None; + } + // LC_SEGMENT and LC_SEGMENT_64, and what each one's sections are like. + let (segment, nsects_at, first_section, section_len, size_at, offset_at) = if wide { + (0x19u32, 64u64, 72u64, 80u64, 40u64, 48u64) + } else { + (0x1, 48, 56, 68, 36, 40) + }; + let mut section_rawptrs = Vec::new(); + let mut section_rawsizes = Vec::new(); + let mut at = header; + 'commands: for _ in 0..ncmds.min(MAX_EXEC_SECTIONS) { + let (Some(cmd), Some(size)) = (f.u32(at), f.u32(at + 4).map(u64::from)) else { + break; + }; + if size < 8 || at.checked_add(size)? > end { + break; + } + if cmd == segment { + let Some(nsects) = f.u32(at + nsects_at).map(u64::from) else { + break; + }; + for i in 0..nsects { + let s = at + first_section + i * section_len; + if s + section_len > at + size || section_rawptrs.len() as u64 >= MAX_EXEC_SECTIONS { + break 'commands; + } + let (Some(offset), Some(len)) = + (f.u32(s + offset_at).map(u64::from), f.word(s + size_at, wide)) + else { + break 'commands; + }; + section_rawptrs.push(offset); + // A zero-fill section has no bytes in the file. + section_rawsizes.push(if offset == 0 { 0 } else { len }); + } + } + at += size; + } + Some(PeLayout { + entry: None, + section_rawptrs, + section_rawsizes, + version_info: Vec::new(), + }) +} + /// One PE section in the shape the bytecode PE APIs expect /// (`cli_exe_section`). /// @@ -754,7 +910,7 @@ const CARVE_MAGICS: [(&[u8], Magic); 14] = [ /// is validated as it is met. The search stops once every kind is at its cap /// and one more candidate has been seen past a cap. pub(crate) fn embedded_in(data: &dyn ByteSource) -> Embedded { - let mut carving = Carving::new(); + let mut carving = Carving::new(usize::MAX); let len = data.len(); let mut at = 0; while at < len && !carving.full() { @@ -770,6 +926,7 @@ pub(crate) fn embedded_in(data: &dyn ByteSource) -> Embedded { } at += owned; } + carving.settle(data); carving.found } @@ -778,9 +935,41 @@ pub(crate) fn embedded_in(data: &dyn ByteSource) -> Embedded { pub(crate) struct Carving { finders: Vec>, hits: Vec<(usize, Magic)>, + /// Where the last ZIP local header seen says the next entry begins, when + /// it can say (see [`zip_next_header`]). + zip_next: Option, + /// `(header, MZ)` of candidates whose `PE\0\0` header lies beyond the + /// window they were found in, in an object not held in memory. Checked + /// by [`Carving::settle`] in offset order, so a header costs one read of + /// its block however many candidates point near it. + far: Vec<(usize, usize)>, + /// How many of those wait before they are settled. + far_max: usize, pub(crate) found: Embedded, } +/// Where the ZIP local header at `off` says the next entry begins: after its +/// name, extra field and compressed data. `None` when it cannot say: the entry +/// is encrypted, its sizes follow the data (a data descriptor), or its size is +/// ZIP64's. +fn zip_next_header(data: &dyn ByteSource, off: usize) -> Option { + let h = data.window(off, 30); + if h.len() != 30 || h[..4] != *b"PK\x03\x04" { + return None; + } + let flags = u16::from_le_bytes([h[6], h[7]]); + if flags & 0b1001 != 0 { + return None; + } + let csize = u32::from_le_bytes([h[18], h[19], h[20], h[21]]); + if csize == u32::MAX { + return None; + } + let name = usize::from(u16::from_le_bytes([h[26], h[27]])); + let extra = usize::from(u16::from_le_bytes([h[28], h[29]])); + off.checked_add(30 + name + extra)?.checked_add(csize as usize) +} + impl Carving { /// Bytes each window must run into the next, so a magic across the seam /// is seen whole. @@ -796,10 +985,14 @@ impl Carving { longest - 1 }; - pub(crate) fn new() -> Self { + /// A search that settles its far candidates once `far_max` wait. + pub(crate) fn new(far_max: usize) -> Self { Carving { finders: CARVE_MAGICS.iter().map(|(m, _)| memchr::memmem::Finder::new(m)).collect(), hits: Vec::new(), + zip_next: None, + far: Vec::new(), + far_max: far_max.max(1), found: Embedded::default(), } } @@ -808,7 +1001,8 @@ impl Carving { /// at its cap, and candidates past the caps were already seen. pub(crate) fn full(&self) -> bool { let f = &self.found; - f.pe_capped + self.far.is_empty() + && f.pe_capped && f.others_capped && f.pe.len() >= MAX_EMBEDDED_PE && f.elf.len() >= MAX_EMBEDDED_PE @@ -822,32 +1016,90 @@ impl Carving { pub(crate) fn feed(&mut self, data: &dyn ByteSource, at: usize, w: &[u8], owned: usize) { let found = &mut self.found; let hits = &mut self.hits; + let zip_next = &mut self.zip_next; + let far = &mut self.far; + // A header beyond the window is a read of its own, where the object + // is not in memory. + let held = at + w.len(); + let in_memory = data.as_slice().is_some(); hits.clear(); for (f, (_, kind)) in self.finders.iter().zip(CARVE_MAGICS) { hits.extend(f.find_iter(w).take_while(|&p| p < owned).map(|p| (at + p, kind))); } hits.sort_unstable_by_key(|&(off, _)| off); for &(off, kind) in hits.iter() { + let zip = matches!(kind, Magic::Archive) && data.window(off, 4)[..] == *b"PK\x03\x04"; if off == 0 { - continue; // the object's own image or archive + // The object's own image or archive; a ZIP's later entries + // are its own, not archives stapled inside it. + if zip { + *zip_next = zip_next_header(data, off); + } + continue; + } + // The next entry of a ZIP already taken is read when that ZIP is: + // its walk reaches every local header after its own, wherever the + // directory points. Counting each as an archive of its own would + // fill the cap with one zip's members and rescan the rest of the + // object once per member. + if zip && *zip_next == Some(off) { + *zip_next = zip_next_header(data, off); + continue; } let (list, cap, ok) = match kind { - Magic::Pe => (&mut found.pe, MAX_EMBEDDED_PE, pe_at(data, off)), + Magic::Pe => { + match pe_target(data, off) { + Some(pe) if pe >= held && !in_memory => far.push((pe, off)), + Some(pe) if pe_header_at(data, pe) => accept_pe(found, off), + _ => {} + } + continue; + } Magic::Elf => (&mut found.elf, MAX_EMBEDDED_PE, elf_at(data, off)), Magic::MachoThin(be) => (&mut found.macho, MAX_EMBEDDED_PE, macho_thin_at(data, off, be)), Magic::MachoFat => (&mut found.macho, MAX_EMBEDDED_PE, macho_fat_at(data, off)), Magic::Archive => (&mut found.archives, MAX_EMBEDDED_ARCHIVES, archive_at(data, off)), }; + // The chain follows only a ZIP that will be scanned. + if zip { + *zip_next = None; + } if ok { if list.len() < cap { list.push(off); - } else if matches!(kind, Magic::Pe) { - found.pe_capped = true; + if zip { + *zip_next = zip_next_header(data, off); + } } else { found.others_capped = true; } } } + if self.far.len() >= self.far_max { + self.settle(data); + } + } + + /// Check the far candidates waiting, in the order of the headers they + /// point at. Call it once the object is fed. + pub(crate) fn settle(&mut self, data: &dyn ByteSource) { + self.far.sort_unstable(); + for (pe, off) in std::mem::take(&mut self.far) { + if pe_header_at(data, pe) { + accept_pe(&mut self.found, off); + } + } + } +} + +/// Take the PE at `off`: the lowest [`MAX_EMBEDDED_PE`] offsets are listed +/// whatever order they are found in, and one more makes the object capped. +fn accept_pe(found: &mut Embedded, off: usize) { + let at = found.pe.partition_point(|&o| o < off); + found.pe.insert(at, off); + if found.pe.len() > MAX_EMBEDDED_PE { + found.pe.pop(); + found.pe_capped = true; } } @@ -868,22 +1120,26 @@ fn archive_at(data: &dyn ByteSource, off: usize) -> bool { } } -/// An `MZ` at `off` whose `e_lfanew` points to a sane `PE\0\0` header. +/// An `MZ` at `off` whose `e_lfanew` points to a sane `PE\0\0` header: what +/// carving finds, one candidate at a time, for the tests to compare it to. +#[cfg(test)] fn pe_at(data: &dyn ByteSource, off: usize) -> bool { + pe_target(data, off).is_some_and(|pe| pe_header_at(data, pe)) +} + +/// Where the `MZ` at `off` says its PE header is, if that is in bounds. +fn pe_target(data: &dyn ByteSource, off: usize) -> Option { if off + 0x40 > data.len() { - return false; + return None; } - let Some(e_lfanew) = le_u32_at(data, off + 0x3c) else { - return false; - }; - let e_lfanew = e_lfanew as usize; + let e_lfanew = le_u32_at(data, off + 0x3c)? as usize; // e_lfanew is relative to the MZ; the PE header must be in-bounds. - let Some(pe) = off.checked_add(e_lfanew) else { - return false; - }; - if e_lfanew < 0x40 || pe + 4 > data.len() { - return false; - } + let pe = off.checked_add(e_lfanew)?; + (e_lfanew >= 0x40 && pe + 4 <= data.len()).then_some(pe) +} + +/// A sane `PE\0\0` header at `pe`. +fn pe_header_at(data: &dyn ByteSource, pe: usize) -> bool { data.window(pe, 4)[..] == *b"PE\0\0" && valid_pe_coff(data, pe) } @@ -1126,6 +1382,27 @@ mod tests { assert_eq!(pe.rawaddr(0x1200, 0x10000), None); } + /// Header offsets near the top of u64 are the file's to claim: the table + /// they name is past the file, not an overflow. + #[test] + fn elf_table_offsets_near_the_top_of_u64_are_absent_not_a_panic() { + for off in [u64::MAX, u64::MAX - 1, u64::MAX - 8, u64::MAX - 40] { + let mut elf = vec![0u8; 120]; + elf[..4].copy_from_slice(b"\x7fELF"); + elf[4] = 2; + elf[5] = 1; + elf[32..40].copy_from_slice(&off.to_le_bytes()); + elf[40..48].copy_from_slice(&off.to_le_bytes()); + // e_phentsize, e_phnum, e_shentsize, e_shnum + for (at, v) in [(54, 56u16), (56, 2), (58, 64), (60, 2)] { + elf[at..at + 2].copy_from_slice(&v.to_le_bytes()); + } + assert!(elf_broken(&elf.as_slice()), "{off:#x}"); + let layout = elf_layout(&elf.as_slice()).unwrap(); + assert!(layout.section_rawptrs.is_empty(), "{off:#x}"); + } + } + /// One read finds what looking for each magic at every offset finds: /// every validated image and archive candidate, in order and within its /// cap, whatever window seam it straddles, in memory and read through a @@ -1211,6 +1488,61 @@ mod tests { } } + /// An `MZ` points its header anywhere in the object. Over a source that + /// is not in memory, those headers are checked in order, so many + /// candidates cost about one pass over the object and not a block read + /// each, and a real header far from its `MZ` is still found. + #[test] + fn far_headers_are_read_in_order() { + use crate::byte_source::BlockCache; + use std::sync::atomic::{AtomicU64, Ordering}; + struct Tally(std::io::Cursor>, std::sync::Arc); + impl std::io::Read for Tally { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.1.fetch_add(1, Ordering::Relaxed); + self.0.read(buf) + } + } + impl std::io::Seek for Tally { + fn seek(&mut self, p: std::io::SeekFrom) -> std::io::Result { + self.0.seek(p) + } + } + const BLOCK: usize = 4096; + let len = 1024 * BLOCK; + let mut data = vec![b'a'; len]; + let mut state = 0x9e37_79b9_7f4a_7c15u64; + // Candidates whose headers are somewhere else in the object. + for k in 0..8000 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let off = 1000 + k * 500; + let to = 0x40 + (state as usize % (len - off - 0x80)); + data[off..off + 2].copy_from_slice(b"MZ"); + data[off + 0x3c..off + 0x40].copy_from_slice(&(to as u32).to_le_bytes()); + } + // A real one, whose header is 3 MiB on. + let at = 100; + let header = at + (3 << 20); + data[at..at + 2].copy_from_slice(b"MZ"); + data[at + 0x3c..at + 0x40].copy_from_slice(&(3u32 << 20).to_le_bytes()); + let mut pe = b"PE\0\0\x4c\x01\x03\0".to_vec(); + pe.resize(20, 0); + pe.extend_from_slice(&0xe0u16.to_le_bytes()); + pe.extend_from_slice(&[0, 0, 0x0b, 0x01]); + data[header..header + pe.len()].copy_from_slice(&pe); + + let reads = std::sync::Arc::new(AtomicU64::new(0)); + let src = BlockCache::with_sizes(Tally(std::io::Cursor::new(data), reads.clone()), BLOCK, 8 * BLOCK).unwrap(); + let found = embedded_in(&src); + assert_eq!(found.pe, [at]); + assert!(!found.pe_capped); + let reads = reads.load(Ordering::Relaxed) as usize; + let blocks = len / BLOCK; + assert!(reads <= 3 * blocks, "{reads} reads for {blocks} blocks"); + } + /// A chance `1f 8b 08` run is not a gzip candidate unless the header's /// `XFL` and `OS` hold up, so random data cannot fill the archive cap and /// make an object `LIMITS-EXCEEDED`. @@ -1235,6 +1567,82 @@ mod tests { assert!(found.others_capped); } + /// A ZIP local header for an entry named `name` holding `len` bytes, with + /// the general-purpose `flags` and a declared compressed size of `csize`, + /// followed by `len` bytes of data. + fn zip_entry(name: &str, flags: u16, csize: u32, len: usize) -> Vec { + let mut e = b"PK\x03\x04\x14\0".to_vec(); + e.extend_from_slice(&flags.to_le_bytes()); + e.extend_from_slice(&[0, 0, 0, 0, 0x21, 0, 0, 0, 0, 0]); + e.extend_from_slice(&csize.to_le_bytes()); + e.extend_from_slice(&(len as u32).to_le_bytes()); + e.extend_from_slice(&(name.len() as u16).to_le_bytes()); + e.extend_from_slice(&[0, 0]); + e.extend_from_slice(name.as_bytes()); + e.extend_from_slice(&vec![b'.'; len]); + e + } + + /// `entries` entries of a ZIP (each declaring its size truthfully) after a + /// text prefix. + fn zip_after_prefix(entries: usize, flags: u16) -> (Vec, usize) { + let mut data = b"a carrier with a zip stapled inside it\n".to_vec(); + let first = data.len(); + for k in 0..entries { + data.extend(zip_entry(&format!("m{k}"), flags, 20, 20)); + } + (data, first) + } + + /// The entries of one ZIP stapled into an object are read when the ZIP is, + /// so only the first is a candidate: they neither fill the cap nor are the + /// object rescanned once per entry. + #[test] + fn the_entries_of_a_zip_are_not_archives_of_their_own() { + let (data, first) = zip_after_prefix(MAX_EMBEDDED_ARCHIVES * 2, 0); + let found = embedded_in(&&data[..]); + assert_eq!(found.archives, [first]); + assert!(!found.others_capped); + } + + /// A ZIP that is the object itself: every header after its own is an entry. + #[test] + fn the_entries_of_a_zip_at_offset_zero_are_not_archives_of_their_own() { + let (data, first) = zip_after_prefix(40, 0); + let found = embedded_in(&&data[first..]); + assert!(found.archives.is_empty() && !found.others_capped); + } + + /// Where the header does not say where the next entry begins, each header + /// stays a candidate: a data descriptor, encryption or ZIP64's size leaves + /// the position unknown. + #[test] + fn a_header_that_cannot_place_the_next_one_keeps_every_candidate() { + for flags in [0b1000, 0b0001] { + let (data, _) = zip_after_prefix(5, flags); + assert_eq!(embedded_in(&&data[..]).archives.len(), 5, "flags {flags:#b}"); + } + let mut data = b"carrier\n".to_vec(); + for k in 0..5 { + data.extend(zip_entry(&format!("m{k}"), 0, u32::MAX, 20)); + } + assert_eq!(embedded_in(&&data[..]).archives.len(), 5, "ZIP64 size"); + } + + /// A header that names a size that does not lead to the next header does + /// not hide it: a second archive placed anywhere else is still a + /// candidate. + #[test] + fn a_size_that_misses_the_next_header_keeps_it_a_candidate() { + let mut data = b"carrier\n".to_vec(); + let first = data.len(); + // Declares 10 bytes of data, holds 20: the chain lands inside them. + data.extend(zip_entry("a", 0, 10, 20)); + let second = data.len(); + data.extend(zip_entry("b", 0, 20, 20)); + assert_eq!(embedded_in(&&data[..]).archives, [first, second]); + } + #[test] fn embedded_macho_carved_and_java_rejected() { // A 64-bit LE Mach-O executable header (MH_MAGIC_64 = CF FA ED FE) at a diff --git a/crates/exav-core/src/phishing.rs b/crates/exav-core/src/phishing.rs index 6a012958..635b9a25 100644 --- a/crates/exav-core/src/phishing.rs +++ b/crates/exav-core/src/phishing.rs @@ -69,15 +69,13 @@ impl PhishingDb { disp.trim().to_ascii_lowercase(), )); } - } else if let Some(rest) = line.strip_prefix("X:") { - if let Some((real, disp)) = rest.split_once(':') { - if let (Ok(re), Ok(rd)) = (compile_pattern(real), compile_pattern(disp)) - { - self.allow_regex.push((re, rd)); - self.allow_regex_src - .push((real.to_string(), disp.to_string())); - } - } + } else if let Some(rest) = line.strip_prefix("X:") + && let Some((real, disp)) = rest.split_once(':') + && let (Ok(re), Ok(rd)) = (compile_pattern(real), compile_pattern(disp)) + { + self.allow_regex.push((re, rd)); + self.allow_regex_src + .push((real.to_string(), disp.to_string())); } } _ => {} @@ -235,7 +233,37 @@ pub(crate) fn scan_complete_source( (None, true) } -/// Offset of the next ` (Option, bool) { + let mut allow_checks = 0usize; + for (display, href) in links { + let Some(p) = classify(href, display, db) else { + continue; + }; + if db.has_allow_list() { + if allow_checks == MAX_ALLOW_CHECKS { + return (None, false); + } + allow_checks += 1; + if allow_listed(href, display, db) { + continue; + } + } + return (Some(p), true); + } + (None, true) +} + +/// The bytes HTML5 takes for white space after a tag name. +fn is_tag_space(b: u8) -> bool { + matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0c) +} + +/// Offset of the next ` Option { let disp_host = display_host(display); // Userinfo cloak: `http://paypal.com@evil/` — flag only when the userinfo // itself looks like a hostname of a different registered domain. - if let Some(ui) = userinfo { - if ui.contains('.') && registered(ui) != registered(host) { - return Some(Phish::CloakedUsername); - } + if let Some(ui) = userinfo + && ui.contains('.') + && registered(ui) != registered(host) + { + return Some(Phish::CloakedUsername); } // Raw-IP host under a domain-looking display. if is_ipv4(host) { - if let Some(dh) = &disp_host { - if !is_ipv4(dh) && dh.contains('.') { - return Some(Phish::CloakedIp); - } + if let Some(dh) = &disp_host + && !is_ipv4(dh) + && dh.contains('.') + { + return Some(Phish::CloakedIp); } return None; } @@ -296,13 +326,14 @@ fn classify(href: &str, display: &str, db: &PhishingDb) -> Option { // the scheme is a domain spoof, the more specific finding. (Getting this // order wrong made a paypal.com-spoofing link report as a mere SSL // mismatch — the existing `spoofed_domain` test caught it.) - if let Some(dh) = &disp_host { - if !dh.is_empty() && registered(dh) != registered(host) { - if db.scoped() && !db.is_protected(dh) { - return None; - } - return Some(Phish::SpoofedDomain); + if let Some(dh) = &disp_host + && !dh.is_empty() + && registered(dh) != registered(host) + { + if db.scoped() && !db.is_protected(dh) { + return None; } + return Some(Phish::SpoofedDomain); } // Transport lie: the display promises https, the href is plain http, and the // domains agree — the one phishing shape where everything except the scheme @@ -398,10 +429,10 @@ fn strip_http(url: &str) -> Option<&str> { /// The host named by anchor display text, if it is (or contains) a URL or bare host. fn display_host(display: &str) -> Option { let d = display.trim(); - if let Some((_, h)) = split_host(d) { - if !h.is_empty() { - return Some(h.to_ascii_lowercase()); - } + if let Some((_, h)) = split_host(d) + && !h.is_empty() + { + return Some(h.to_ascii_lowercase()); } let token = d .split_whitespace() @@ -423,15 +454,34 @@ fn display_host(display: &str) -> Option { None } -/// Approximate registered domain: the last two DNS labels, lowercased. +/// Labels that stand before a country code in a two-label public suffix: +/// `co.uk`, `com.au`, `gov.uk`, `ne.jp`, `ac.nz`. Not the whole of the public +/// suffix list, which has thousands of entries (`city.kawasaki.jp`, +/// `pvt.k12.ma.us`): these are the ones that carry nearly all the registrations +/// of the countries that use the form. +const SECOND_LEVEL: &[&str] = &[ + "ac", "asn", "biz", "co", "com", "edu", "go", "gob", "gouv", "gov", "id", "info", "int", "ltd", + "me", "mil", "mod", "ne", "net", "nhs", "nom", "or", "org", "plc", "police", "sch", "web", +]; + +/// Approximate registered domain, lowercased: the last two DNS labels, or the +/// last three when they end in a two-label public suffix of a country +/// (`barclays.co.uk`), where the last two would be the suffix alone and every +/// brand under it one and the same. fn registered(host: &str) -> String { let h = host.trim_end_matches('.').to_ascii_lowercase(); let labels: Vec<&str> = h.split('.').filter(|s| !s.is_empty()).collect(); - if labels.len() <= 2 { - labels.join(".") + let n = labels.len(); + let keep = if n >= 3 + && labels[n - 1].len() == 2 + && labels[n - 1].bytes().all(|b| b.is_ascii_alphabetic()) + && SECOND_LEVEL.contains(&labels[n - 2]) + { + 3 } else { - labels[labels.len() - 2..].join(".") - } + 2 + }; + labels[n.saturating_sub(keep)..].join(".") } /// True for a dotted-quad IPv4 literal. @@ -497,6 +547,78 @@ mod tests { ); } + /// HTML5 takes a space, a tab, a line feed, a form feed and a carriage + /// return alike after a tag name, and so does every browser and mail client. + #[test] + fn an_anchor_may_be_followed_by_any_html_space() { + use crate::byte_source::{BlockCache, CHUNK}; + for space in [" ", "\t", "\n", "\r", "\x0c", "\r\n\t "] { + let html = + format!("http://paypal.com/signin"); + assert_eq!( + scan0(html.as_bytes()), + Some(Phish::SpoofedDomain), + "{space:?}" + ); + let upper = html.replace("http://paypal.com"#), + None + ); + } + + #[test] + fn a_brand_under_a_two_label_suffix_is_its_own_registered_domain() { + assert_eq!(registered("www.barclays.co.uk"), "barclays.co.uk"); + assert_eq!(registered("barclays.co.uk"), "barclays.co.uk"); + assert_eq!(registered("co.uk"), "co.uk"); + assert_eq!(registered("a.b.shop.com.au"), "shop.com.au"); + assert_eq!(registered("x.example.co.jp."), "example.co.jp"); + assert_eq!(registered("www.paypal.com"), "paypal.com"); + assert_eq!(registered("paypal.com"), "paypal.com"); + // A generic label before a generic top-level domain is a domain. + assert_eq!(registered("www.co.com"), "co.com"); + assert_eq!(registered("localhost"), "localhost"); + assert_eq!(registered(""), ""); + } + + #[test] + fn spoofs_under_a_two_label_suffix_are_found() { + let spoof = br#"http://barclays.co.uk"#; + let cloak = br#"your bank"#; + assert_eq!(scan0(spoof), Some(Phish::SpoofedDomain)); + assert_eq!(scan0(cloak), Some(Phish::CloakedUsername)); + // The same brand under its own subdomain is not a spoof. + let same = br#"www.barclays.co.uk"#; + assert_eq!(scan0(same), None); + // A monitored brand under a two-label suffix is monitored as itself, + // not as every domain under the suffix. + let mut db = PhishingDb::default(); + db.add_text("pdb", "H:smile.co.uk\n"); + let hit = br#"www.smile.co.uk"#; + let other = br#"www.example.co.uk"#; + assert_eq!(scan(hit, &db), Some(Phish::SpoofedDomain)); + assert_eq!(scan(other, &db), None); + } + #[test] fn userinfo_and_ip_cloak() { assert_eq!( diff --git a/crates/exav-core/src/profile.rs b/crates/exav-core/src/profile.rs index 3a26fb92..ed2593f4 100644 --- a/crates/exav-core/src/profile.rs +++ b/crates/exav-core/src/profile.rs @@ -4,4 +4,4 @@ //! scan infra at the bottom of the dependency stack, so the packer emulator in //! `exav-unpack` and the matchers here record into the same thread-local //! profile. Re-exported so existing paths keep working. -pub use exav_unpack::profile::{enable, take, timed, Profile, Stat}; +pub use exav_unpack::profile::{Profile, Stat, enable, take, timed}; diff --git a/crates/exav-core/src/source.rs b/crates/exav-core/src/source.rs index 610692ea..3b807e64 100644 --- a/crates/exav-core/src/source.rs +++ b/crates/exav-core/src/source.rs @@ -14,19 +14,20 @@ mod http { use std::io::{self, Read, Seek, SeekFrom}; /// Bytes fetched by a range request that does not continue the previous - /// one. - const BLOCK: u64 = 64 * 1024; + /// one, unless the caller says otherwise: the block of the cache that + /// reads through the reader, so that a block is one request. + pub const BLOCK: u64 = 1024 * 1024; - /// Most bytes one range request fetches. - const MAX_BLOCK: u64 = 8 * 1024 * 1024; + /// Most bytes one range request fetches, unless the caller says otherwise. + pub const MAX_BLOCK: u64 = 8 * 1024 * 1024; /// A `Read + Seek` view over an HTTP(S) resource, served by range /// requests, the last one's bytes kept. /// /// A request that continues the previous one fetches twice as much, up to - /// [`MAX_BLOCK`]: a pass through the object costs a request per 8 MiB - /// rather than per 64 KiB, while reads scattered through it (a ZIP's - /// directory, then its members) stay small. + /// the largest request (8 MiB unless set): a pass through the object costs a + /// request per 8 MiB rather than per block, while reads scattered through it + /// (a ZIP's directory, then its members) stay one block. pub struct HttpRangeReader { agent: ureq::Agent, url: String, @@ -38,12 +39,25 @@ mod http { pub bytes_fetched: u64, /// Number of range requests issued. pub requests: u64, + /// What a request that does not continue the previous one fetches. + first: u64, + /// The most one request fetches. + most: u64, } impl HttpRangeReader { /// Open a URL, probing its length and range support with a single /// `bytes=0-0` request. pub fn open(url: &str) -> io::Result { + Self::open_with(url, BLOCK, MAX_BLOCK) + } + + /// [`Self::open`] with the size of a request that does not continue + /// the previous one (`first`, at least 1) and the most any request + /// fetches (`most`, never below `first`). + pub fn open_with(url: &str, first: u64, most: u64) -> io::Result { + let first = first.max(1); + let most = most.max(first); let agent: ureq::Agent = ureq::Agent::config_builder() .user_agent(concat!("exav/", env!("CARGO_PKG_VERSION"))) .build() @@ -74,6 +88,8 @@ mod http { block_start: 0, bytes_fetched: 0, requests: 0, + first, + most, }) } @@ -93,8 +109,8 @@ mod http { let continues = !self.block.is_empty() && start == self.block_start + self.block.len() as u64; let size = match continues { - true => (self.block.len() as u64 * 2).min(MAX_BLOCK), - false => BLOCK, + true => (self.block.len() as u64 * 2).min(self.most), + false => self.first, }; self.block.clear(); self.block_start = start; @@ -116,6 +132,33 @@ mod http { resp.status() ))); } + // The range it says it sent, of the object it said it was: a server + // that answers another range hands over bytes of another place, and + // one whose object has changed since a scan began hands over a + // mixture. Neither is the object that was asked to be scanned. + let range = resp + .headers() + .get("Content-Range") + .and_then(|cr| cr.to_str().ok()) + .and_then(|cr| cr.trim().strip_prefix("bytes ")) + .and_then(|cr| { + let (span, total) = cr.split_once('/')?; + let (from, _) = span.split_once('-')?; + Some(( + from.trim().parse::().ok()?, + total.trim().parse::().ok(), + )) + }); + match range { + Some((from, total)) if from == start && total.is_none_or(|t| t == self.len) => {} + other => { + return Err(io::Error::other(format!( + "range request for bytes {start}-{last} of {} answered with {other:?} \ + (the object is {} bytes)", + self.url, self.len + ))); + } + } let mut buf = Vec::new(); resp.into_body() .into_reader() @@ -139,7 +182,7 @@ mod http { // holding a position elsewhere. let start = match self.pos == next { true => next, - false => (self.pos / BLOCK) * BLOCK, + false => (self.pos / self.first) * self.first, }; self.fetch_block(start)?; } @@ -240,6 +283,68 @@ mod tests { url } + /// A server that answers the first request as it should and every later one + /// with the range `shift` bytes on from the one asked for (`Offset`), or with + /// the range asked for of an object of another size (`Total`). + enum Wrong { + Offset, + Total, + } + + fn serve_wrong(body: Vec, wrong: Wrong) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/obj", listener.local_addr().unwrap()); + std::thread::spawn(move || { + for (n, conn) in listener.incoming().enumerate() { + let Ok(mut conn) = conn else { return }; + let mut range = (0usize, 0usize); + let mut reader = BufReader::new(conn.try_clone().unwrap()); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(r) = line.to_ascii_lowercase().strip_prefix("range: bytes=") { + let (a, b) = r.trim().split_once('-').unwrap(); + range = (a.parse().unwrap(), b.parse().unwrap()); + } + } + let (a, b) = range; + let (from, to, total) = match (&wrong, n) { + (_, 0) => (a, b, body.len()), + (Wrong::Offset, _) => ( + (a + 1).min(body.len() - 1), + (b + 1).min(body.len() - 1), + body.len(), + ), + (Wrong::Total, _) => (a, b, body.len() + 7), + }; + let part = &body[from..=to]; + let h = format!( + "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes {from}-{to}/{total}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + part.len() + ); + let _ = conn.write_all(&[h.as_bytes(), part].concat()); + } + }); + url + } + + /// The bytes of a range are the bytes of the range that was asked for, of + /// the object that was asked about. + #[test] + fn an_answer_for_another_range_or_another_object_is_refused() { + let body: Vec = (0..200_000u32).map(|i| (i % 251) as u8).collect(); + for wrong in [Wrong::Offset, Wrong::Total] { + let mut r = HttpRangeReader::open(&serve_wrong(body.clone(), wrong)).unwrap(); + let mut all = Vec::new(); + let e = r + .read_to_end(&mut all) + .expect_err("the answer is not the range asked for"); + assert!(e.to_string().contains("answered with"), "{e}"); + } + } + #[test] fn ranges_are_read_where_they_are() { let body: Vec = (0..200_000u32).map(|i| (i % 251) as u8).collect(); @@ -255,6 +360,38 @@ mod tests { assert_eq!(all, body); } + /// The size of a request that starts a read, and the most one may grow to, + /// are the caller's. + #[test] + fn the_request_sizes_are_the_callers() { + let body = vec![7u8; 3_000_000]; + let sequential = |first, most| { + let mut r = + HttpRangeReader::open_with(&serve(body.clone(), false), first, most).unwrap(); + let mut all = Vec::new(); + r.read_to_end(&mut all).unwrap(); + assert_eq!(all, body); + r + }; + // One request per 100,000 bytes. + assert_eq!(sequential(100_000, 100_000).requests, 30); + // 10 KB, doubling up to 1 MB: seven requests to 1,270,000 bytes, two + // of 1 MB, the rest. + let mut r = sequential(10_000, 1_000_000); + assert_eq!(r.requests, 9); + // A read elsewhere is one request of the first size, from its block. + let (requests, fetched) = (r.requests, r.bytes_fetched); + r.seek(SeekFrom::Start(123)).unwrap(); + r.read_exact(&mut [0u8; 10]).unwrap(); + assert_eq!(r.requests, requests + 1); + assert_eq!(r.bytes_fetched, fetched + 10_000); + // The defaults are the ones `open` has. + assert_eq!( + (super::http::BLOCK, super::http::MAX_BLOCK), + (1 << 20, 8 << 20) + ); + } + /// A seek past the largest offset is refused, not wrapped to a small one. #[test] fn a_seek_past_u64_is_an_error_not_a_wrap() { diff --git a/crates/exav-core/src/spill.rs b/crates/exav-core/src/spill.rs index 7e16e720..4d661325 100644 --- a/crates/exav-core/src/spill.rs +++ b/crates/exav-core/src/spill.rs @@ -61,22 +61,24 @@ impl SpillOut { } fn flush(&mut self) { - if self.failed.is_none() && !self.batch.is_empty() { - if let Err(e) = self.file.write(&self.batch) { - self.failed = Some(e); - } + if self.failed.is_none() + && !self.batch.is_empty() + && let Err(e) = self.file.write(&self.batch) + { + self.failed = Some(e); } self.batch.clear(); } /// What was written, to read back. - pub(crate) fn finish(mut self) -> Result { + pub(crate) fn finish(mut self, cache: (usize, usize)) -> Result { self.flush(); if let Some(e) = self.failed { return Err(e); } let reader = self.file.finish()?; - BlockCache::new(reader).map_err(|e| format!("spill file unreadable: {e}")) + BlockCache::with_sizes(reader, cache.0, cache.1) + .map_err(|e| format!("spill file unreadable: {e}")) } } @@ -108,6 +110,7 @@ pub(crate) enum SpillStreamError { pub(crate) fn spill_stream( spill: &dyn Spill, reader: &mut dyn Read, + cache: (usize, usize), ) -> Result { let mut out = SpillOut::new(spill.create().map_err(SpillStreamError::Spill)?); let mut buf = vec![0u8; CHUNK]; @@ -122,7 +125,7 @@ pub(crate) fn spill_stream( return Err(SpillStreamError::Spill(e)); } } - out.finish().map_err(SpillStreamError::Spill) + out.finish(cache).map_err(SpillStreamError::Spill) } #[cfg(test)] @@ -168,12 +171,12 @@ pub(crate) mod tests { budget: usize::MAX, made: Arc::default(), }; - let back = spill_stream(&spill, &mut Cursor::new(&data)).unwrap(); + let back = spill_stream(&spill, &mut Cursor::new(&data), (64 * 1024, 8 << 20)).unwrap(); assert_eq!(back.window(0, back.len()), &data[..]); let small = MemSpill { budget: CHUNK, made: Arc::default(), }; - assert!(spill_stream(&small, &mut Cursor::new(&data)).is_err()); + assert!(spill_stream(&small, &mut Cursor::new(&data), (64 * 1024, 8 << 20)).is_err()); } } diff --git a/crates/exav-core/src/yara/atoms.rs b/crates/exav-core/src/yara/atoms.rs index 42d092a5..c7c1dfe0 100644 --- a/crates/exav-core/src/yara/atoms.rs +++ b/crates/exav-core/src/yara/atoms.rs @@ -32,8 +32,8 @@ use regex_syntax::hir::literal::{ExtractKind, Extractor}; use serde::{Deserialize, Serialize}; use crate::byte_source::ByteSource; -use crate::grams::{structural, sweep, GramStats, Grams, IndexAnchor}; -use crate::yara::matcher::{parse_hir, widen_hir, Base64Sub, Needle}; +use crate::grams::{GramStats, Grams, IndexAnchor, structural, sweep}; +use crate::yara::matcher::{Base64Sub, Needle, parse_hir, widen_hir}; /// Maximum atom window length taken from a literal / base64 needle. A longer /// window is a strictly more selective (rarer) gate; the cap bounds what the diff --git a/crates/exav-core/src/yara/compiler.rs b/crates/exav-core/src/yara/compiler.rs index 91323b5a..d9a8da8e 100644 --- a/crates/exav-core/src/yara/compiler.rs +++ b/crates/exav-core/src/yara/compiler.rs @@ -18,7 +18,7 @@ use crate::yara::ir::{ Quant, StrOp, Value, }; use crate::yara::matcher::{ - build_regex, build_wide_regex, widen, Base64SubDef, Needle, PatternDef, PatternMatcher, + Base64SubDef, Needle, PatternDef, PatternMatcher, build_regex, build_wide_regex, widen, }; use crate::yara::modules::{self, FieldName, ModuleKind, SchemaNode}; use crate::yara::scanner::{CompiledRule, Rules}; @@ -147,6 +147,7 @@ impl Compiler { /// sources may reference rules defined in earlier ones. Returns an error on /// the first parse error or unsupported construct. pub fn add_source(&mut self, src: &str) -> Result<&mut Self> { + check_paren_depth(src)?; let ast = AST::from(src); if let Some(err) = ast.errors().first() { return Err(Error::new(format!("parse error: {err:?}"))); @@ -190,6 +191,10 @@ impl Compiler { /// the returned list. pub fn add_source_lenient(&mut self, src: &str) -> Vec<(String, Error)> { let mut rejected = Vec::new(); + if let Err(e) = check_paren_depth(src) { + rejected.push((String::new(), e)); + return rejected; + } let ast = AST::from(src); if let Some(err) = ast.errors().first() { rejected.push((String::new(), Error::new(format!("parse error: {err:?}")))); @@ -760,7 +765,7 @@ impl RuleCtx<'_> { Expr::LiteralFloat(f) => Cond::Float(f.value), Expr::LiteralString(s) => Cond::Str(ls_bytes(s)), Expr::Regexp(_) => { - return Err(Error::unsupported("bare regular expression in condition")) + return Err(Error::unsupported("bare regular expression in condition")); } Expr::Ident(id) => { @@ -772,7 +777,7 @@ impl RuleCtx<'_> { "variable `{}` is a struct and cannot be used as a value \ (access one of its fields)", id.name - ))) + ))); } } } else if let Some(&idx) = self.rule_index.get(id.name) { @@ -930,7 +935,7 @@ impl RuleCtx<'_> { _ => { return Err(Error::unsupported(format!( "method call `{name}()` on a non-module value" - ))) + ))); } }; let args: Vec = fc @@ -1327,7 +1332,7 @@ impl RuleCtx<'_> { _ => { return Err(Error::new( "expression is not iterable (expected an array or map)", - )) + )); } }; let (elem, nvars) = node.iterable().ok_or_else(|| { @@ -1419,6 +1424,137 @@ fn const_to_cond(v: crate::yara::ir::Value) -> Cond { /// Maps an integer file-read function name to its [`IntKind`], or `None` for /// non-integer reads (e.g. `float32`, which is not implemented). Single-byte /// `_be` variants are equivalent to their little-endian forms. +/// Deepest parenthesis nesting a rule source may have. The parser tries each +/// `(` twice, as a boolean group and as an arithmetic one, and does not keep +/// what the first try parsed, so a rule with an error inside `d` open +/// parentheses costs about 2^d steps (a 3.5 KB rule with 15 took 5.6 s, and +/// one of 3 KB with 12 took 0.3 s, which is 6 s under a sanitizer). +const MAX_PAREN_DEPTH: usize = 10; + +/// Most operators (characters and keywords) one expression of a condition may +/// chain, between `and`, `or`, a comma or a parenthesis. The parser and what +/// is built from it recurse once per operator of a chain (`1 << 1 << 1 ...`, +/// `not not not ...`, `a contains b contains c ...`): 20,000 of them, a 40 KB +/// rule, overflowed a 2 MiB stack and aborted the process. +const MAX_OP_CHAIN: usize = 256; + +/// Words of a condition that are operators. +const OP_WORDS: [&[u8]; 10] = [ + b"not", + b"defined", + b"contains", + b"icontains", + b"startswith", + b"istartswith", + b"endswith", + b"iendswith", + b"iequals", + b"matches", +]; + +/// Refuses a source nested deeper than [`MAX_PAREN_DEPTH`], or with more than +/// [`MAX_OP_CHAIN`] chained operators, before it is parsed. Strings, comments +/// and regular expressions are skipped. +fn check_paren_depth(src: &str) -> Result<()> { + let b = src.as_bytes(); + let mut i = 0; + // `ops` at the start of each open parenthesis or bracket: the chain that + // `and` and `or` inside it go back to. + let mut open: Vec = vec![]; + let (mut ops, mut in_condition) = (0usize, false); + // The last byte that was not whitespace, to tell a regex from a division. + let mut prev = 0u8; + while i < b.len() { + let c = b[i]; + match c { + b'"' => { + i += 1; + while i < b.len() && b[i] != b'"' { + i += if b[i] == b'\\' { 2 } else { 1 }; + } + } + b'/' if b.get(i + 1) == Some(&b'/') => { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } + b'/' if b.get(i + 1) == Some(&b'*') => { + i += 2; + while i < b.len() && !b[i..].starts_with(b"*/") { + i += 1; + } + i += 1; + } + b'/' if prev == b'=' || b[..i].trim_ascii_end().ends_with(b"matches") => { + i += 1; + let mut class = false; + while i < b.len() && (class || b[i] != b'/') { + match b[i] { + b'\\' => i += 1, + b'[' => class = true, + b']' => class = false, + _ => {} + } + i += 1; + } + } + b'(' | b'[' if c == b'(' || in_condition => { + open.push(ops); + if open.len() > MAX_PAREN_DEPTH { + return Err(Error::new(format!( + "parentheses nested more than {MAX_PAREN_DEPTH} deep" + ))); + } + } + b')' | b']' if c == b')' || in_condition => { + // The group is one operand of the chain it sits in. + let at = open.pop(); + if in_condition { + ops = at.map_or(ops, |at| at + 1); + } + } + b',' => ops = open.last().copied().unwrap_or(0), + _ if c.is_ascii_alphabetic() || c == b'_' => { + let end = b[i..] + .iter() + .position(|x| !(x.is_ascii_alphanumeric() || *x == b'_')) + .map_or(b.len(), |n| i + n); + let word = &b[i..end]; + // `$rule`, `#and`: the name of a string. + if !(i > 0 && matches!(b[i - 1], b'$' | b'#' | b'@' | b'!')) { + if word == b"rule" { + in_condition = false; + } else if word == b"condition" { + (in_condition, ops) = (true, 0); + } else if in_condition && (word == b"and" || word == b"or") { + ops = open.last().copied().unwrap_or(0); + } else if in_condition && OP_WORDS.contains(&word) { + ops += 1; + } + } + i = end - 1; + } + b'<' | b'>' | b'=' | b'!' | b'-' | b'+' | b'*' | b'%' | b'&' | b'|' | b'^' | b'~' + | b'/' + if in_condition => + { + ops += 1; + } + _ => {} + } + if ops > MAX_OP_CHAIN { + return Err(Error::new(format!( + "more than {MAX_OP_CHAIN} operators chained in one expression" + ))); + } + if !c.is_ascii_whitespace() { + prev = c; + } + i += 1; + } + Ok(()) +} + fn int_kind(name: &str) -> Option { Some(match name { "uint8" | "uint8be" => IntKind::U8, @@ -1434,3 +1570,163 @@ fn int_kind(name: &str) -> Option { _ => return None, }) } + +#[cfg(test)] +mod paren_depth_tests { + use super::{Compiler, MAX_OP_CHAIN, MAX_PAREN_DEPTH}; + use std::time::{Duration, Instant}; + + fn nested(depth: usize) -> String { + format!( + "rule r {{ condition: {}true{} }}", + "(".repeat(depth), + ")".repeat(depth) + ) + } + + /// A 3.5 KB rule with an error inside 15 open parentheses (`yara`, fuzz + /// timeout): the parser took 5.6 s to refuse it. + #[test] + fn a_rule_with_an_error_deep_in_parentheses_is_refused_fast() { + let src = include_str!("../../tests/fixtures/fuzz/yara_nested_parens.yar"); + let start = Instant::now(); + let err = Compiler::new().add_source(src).err().expect("refused"); + assert!( + start.elapsed() < Duration::from_millis(500), + "{:?}", + start.elapsed() + ); + assert!(err.to_string().contains("nested"), "{err}"); + let start = Instant::now(); + let rejected = Compiler::new().add_source_lenient(src); + assert!( + start.elapsed() < Duration::from_millis(500), + "{:?}", + start.elapsed() + ); + assert_eq!(rejected.len(), 1); + } + + /// A 3 KB rule with an error inside 12 open parentheses (`yara`, fuzz run + /// of the 2026-10-30 campaign): 0.3 s in a release build, 6 s under the + /// sanitizer, so it was close to the fuzzer's timeout. + #[test] + fn a_rule_with_an_error_in_twelve_parentheses_is_refused_fast() { + let src = include_str!("../../tests/fixtures/fuzz/yara_nested_parens_12.yar"); + let start = Instant::now(); + let err = Compiler::new().add_source(src).err().expect("refused"); + assert!( + start.elapsed() < Duration::from_millis(100), + "{:?}", + start.elapsed() + ); + assert!(err.to_string().contains("nested"), "{err}"); + } + + #[test] + fn nesting_up_to_the_bound_compiles_and_one_more_is_refused() { + assert!(Compiler::new().add_source(&nested(MAX_PAREN_DEPTH)).is_ok()); + assert!( + Compiler::new() + .add_source(&nested(MAX_PAREN_DEPTH + 1)) + .is_err() + ); + } + + fn rule(condition: &str) -> String { + format!("import \"pe\"\nrule r {{ strings: $a = \"x\" condition: {condition} }}") + } + + /// Compiles `src` on a 1 MiB stack, where a chain this long recursed to + /// an overflow before it was bounded. + fn compile_small_stack(src: String) -> bool { + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || Compiler::new().add_source(&src).is_ok()) + .unwrap() + .join() + .unwrap() + } + + #[test] + fn a_long_chain_of_operators_is_refused_not_recursed_into() { + let n = 20_000; + let chain = |op: &str| format!("{} == 1", vec!["1"; n].join(op)); + let conditions = [ + format!("{}true", "not ".repeat(n)), + format!("{}true", "defined ".repeat(n)), + format!("{}1 == 1", "- ".repeat(n)), + format!("{}1 == 1", "~ ".repeat(n)), + chain(" << "), + chain(" | "), + chain(" & "), + chain(" ^ "), + chain(" == "), + chain(" - 1 + "), + format!("\"a\"{}", " contains \"a\"".repeat(n)), + format!("\"a\" matches /x/{}", " matches /x/".repeat(n)), + format!("{}0{} == 1", "pe.sections[".repeat(n), "]".repeat(n)), + ]; + for c in conditions { + assert!(!compile_small_stack(rule(&c)), "{}", &c[..40]); + } + } + + #[test] + fn a_chain_up_to_the_bound_compiles_and_one_more_is_refused() { + let chain = |terms: usize| rule(&format!("{} == 1", vec!["1"; terms].join(" << "))); + // A term is an operator and its operand, and the `==` is two characters. + let ok = (MAX_OP_CHAIN - 2) / 2 + 1; + assert!(compile_small_stack(chain(ok))); + let err = Compiler::new().add_source(&chain(ok + 1)).err().unwrap(); + assert!(err.to_string().contains("chained"), "{err}"); + } + + #[test] + fn and_or_and_commas_start_a_new_chain() { + let each = "1 << 1 << 1 << 1 == 1"; + let ors = vec![each; 5_000].join(" or "); + assert!(compile_small_stack(rule(&ors))); + let ands = vec![each; 5_000].join(" and "); + assert!(compile_small_stack(rule(&ands))); + let of = format!("any of ({})", vec!["$a"; 5_000].join(", ")); + assert!(compile_small_stack(rule(&of))); + } + + #[test] + fn a_group_is_one_operand_of_the_chain_it_is_in() { + // `or` inside each group must not hide the chain outside them. + let mut c = String::from("true"); + for _ in 0..200 { + c.push_str(" == (true or true)"); + } + let err = Compiler::new().add_source(&rule(&c)).err().unwrap(); + assert!(err.to_string().contains("chained"), "{err}"); + } + + #[test] + fn operators_outside_the_condition_are_not_counted() { + let mut src = String::from("rule r { strings:"); + for i in 0..400 { + src.push_str(&format!( + " $s{i} = \"abc\" xor(1-2) $h{i} = {{ 41 [1-2] 42 }}" + )); + } + src.push_str(" condition: $s0 }"); + Compiler::new().add_source(&src).expect("compiles"); + // Nor the name of a string that looks like a keyword. + let src = rule("$a and #a > 0 and @a[1] > 0 and !a[1] > 0"); + Compiler::new().add_source(&src).expect("compiles"); + } + + #[test] + fn parentheses_in_strings_comments_and_regexes_are_not_counted() { + let many = "(".repeat(40); + let src = format!( + "rule r {{ strings: $a = \"{many}\" $b = /x\\({many_esc}y/ $c = /[{many}]/ \ + condition: // {many}\n /* {many} */ ($a or $b or $c) and \"{many}\" contains \"(\" }}", + many_esc = "\\(".repeat(40) + ); + Compiler::new().add_source(&src).expect("compiles"); + } +} diff --git a/crates/exav-core/src/yara/ir.rs b/crates/exav-core/src/yara/ir.rs index 4ab2ebaa..dc8ea0e5 100644 --- a/crates/exav-core/src/yara/ir.rs +++ b/crates/exav-core/src/yara/ir.rs @@ -6,11 +6,11 @@ use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::Rc; -use regex_automata::{meta::Regex, Input}; +use regex_automata::{Input, meta::Regex}; use serde::{Deserialize, Serialize}; use crate::byte_source::ByteSource; -use crate::yara::matcher::{build_regex, Match}; +use crate::yara::matcher::{Match, build_regex}; use crate::yara::modules::{self, FuncId, ModuleCtx, ModuleKind}; /// A compiled `matches` regexp plus the source it was built from. @@ -641,7 +641,7 @@ fn count_in_range(id: usize, range: &Option<(Box, Box)>, ctx: &EvalC fn read_int(kind: IntKind, data: &dyn ByteSource, offset: i64) -> Option { let off: usize = offset.try_into().ok()?; macro_rules! rd { - ($n:literal, $conv:expr) => {{ + ($n:literal, $conv:expr_2021) => {{ let bytes: [u8; $n] = data.window(off, $n).as_ref().try_into().ok()?; $conv(bytes) }}; @@ -726,11 +726,7 @@ fn index_value(idx: &Option>, ctx: &EvalCtx) -> Option { None => 1, Some(e) => eval_int(e, ctx)?, }; - if i < 1 { - None - } else { - Some((i - 1) as usize) - } + if i < 1 { None } else { Some((i - 1) as usize) } } fn eval_arith(op: ArithOp, a: &Cond, b: &Cond, ctx: &EvalCtx) -> Option { diff --git a/crates/exav-core/src/yara/matcher.rs b/crates/exav-core/src/yara/matcher.rs index bcec07c8..b4c2ccc9 100644 --- a/crates/exav-core/src/yara/matcher.rs +++ b/crates/exav-core/src/yara/matcher.rs @@ -13,9 +13,9 @@ use std::ops::Range; use base64::Engine; -use regex_automata::{meta, meta::Regex, util::syntax, Input}; -use regex_syntax::hir::{self, Hir, HirKind}; +use regex_automata::{Input, meta, meta::Regex, util::syntax}; use regex_syntax::ParserBuilder; +use regex_syntax::hir::{self, Hir, HirKind}; use serde::{Deserialize, Serialize}; use crate::byte_source::{ByteSource, CHUNK}; @@ -241,15 +241,17 @@ fn verify_full_word(data: &[u8], start: usize, end: usize, wide: bool, xor_key: /// (up to) two bytes after it. fn full_word_between(before: &[u8], after: &[u8], wide: bool, xor_key: u8) -> bool { if wide { - if let [a, b] = before { - if (b ^ xor_key) == 0 && is_word(a ^ xor_key) { - return false; - } + if let [a, b] = before + && (b ^ xor_key) == 0 + && is_word(a ^ xor_key) + { + return false; } - if let [a, b] = after { - if (b ^ xor_key) == 0 && is_word(a ^ xor_key) { - return false; - } + if let [a, b] = after + && (b ^ xor_key) == 0 + && is_word(a ^ xor_key) + { + return false; } } else { if before.last().is_some_and(|b| is_word(b ^ xor_key)) { @@ -553,17 +555,17 @@ impl PatternMatcher { } let last = data.len() - e.searched.len(); for pos in keep.start..keep.end.min(last + 1) { - if data[pos..].starts_with(&e.searched) { - if let Some(r) = verify_base64( + if data[pos..].starts_with(&e.searched) + && let Some(r) = verify_base64( &e.pattern, data, e.padding as usize, pos, &e.engine, e.wide, - ) { - out.push(origin + r.0, r.1 - r.0); - } + ) + { + out.push(origin + r.0, r.1 - r.0); } } } diff --git a/crates/exav-core/src/yara/modules/dotnet.rs b/crates/exav-core/src/yara/modules/dotnet.rs index 1394d18b..b9303242 100644 --- a/crates/exav-core/src/yara/modules/dotnet.rs +++ b/crates/exav-core/src/yara/modules/dotnet.rs @@ -242,34 +242,18 @@ impl<'a> Meta<'a> { } fn str_w(&self) -> usize { - if self.wide_str { - 4 - } else { - 2 - } + if self.wide_str { 4 } else { 2 } } fn guid_w(&self) -> usize { - if self.wide_guid { - 4 - } else { - 2 - } + if self.wide_guid { 4 } else { 2 } } fn blob_w(&self) -> usize { - if self.wide_blob { - 4 - } else { - 2 - } + if self.wide_blob { 4 } else { 2 } } /// A simple index into one table: two bytes unless that table is large. fn idx_w(&self, table: usize) -> usize { - if self.rows[table] >= 1 << 16 { - 4 - } else { - 2 - } + if self.rows[table] >= 1 << 16 { 4 } else { 2 } } /// A coded index packs a table tag into the low bits, so it widens once any diff --git a/crates/exav-core/src/yara/modules/hash.rs b/crates/exav-core/src/yara/modules/hash.rs index e7031419..425ed38e 100644 --- a/crates/exav-core/src/yara/modules/hash.rs +++ b/crates/exav-core/src/yara/modules/hash.rs @@ -182,7 +182,7 @@ mod tests { #[test] fn a_range_read_in_chunks_hashes_as_the_slice_does() { use super::super::FuncId::*; - use super::{call, Value}; + use super::{Value, call}; use crate::byte_source::{BlockCache, CHUNK}; let h: Vec = (0..3 * CHUNK + 5).map(|i| (i * 31 % 251) as u8).collect(); diff --git a/crates/exav-core/src/yara/modules/math.rs b/crates/exav-core/src/yara/modules/math.rs index fe738f3a..c4cd5983 100644 --- a/crates/exav-core/src/yara/modules/math.rs +++ b/crates/exav-core/src/yara/modules/math.rs @@ -299,11 +299,7 @@ impl SerialCorrelation { let scc = (len * adjacent_product_sum - byte_sum_squared) / (len * self.byte_square_sum - byte_sum_squared); - if scc.is_nan() { - -100000.0 - } else { - scc - } + if scc.is_nan() { -100000.0 } else { scc } } } @@ -596,7 +592,7 @@ mod tests { #[test] fn a_range_read_in_chunks_computes_what_the_slice_does() { use super::super::FuncId::*; - use super::{call, Value}; + use super::{Value, call}; use crate::byte_source::{BlockCache, CHUNK}; let h: Vec = (0..3 * CHUNK + 5).map(|i| (i * 31 % 251) as u8).collect(); diff --git a/crates/exav-core/src/yara/modules/mod.rs b/crates/exav-core/src/yara/modules/mod.rs index 7fb1c13e..6c2cc4b2 100644 --- a/crates/exav-core/src/yara/modules/mod.rs +++ b/crates/exav-core/src/yara/modules/mod.rs @@ -445,7 +445,7 @@ pub(crate) fn resolve_func(module: ModuleKind, name: &str, args: &[Cond]) -> Res "unsupported pe.imports() overload (only string dll, \ (dll, func) and (dll, ordinal) forms are implemented; \ regexp and import_flags forms are not)", - )) + )); } }, "exports" => match kinds.as_slice() { @@ -456,7 +456,7 @@ pub(crate) fn resolve_func(module: ModuleKind, name: &str, args: &[Cond]) -> Res "unsupported pe.exports() overload (only string name and \ integer ordinal forms are implemented; the regexp form \ is not)", - )) + )); } }, "section_index" if kinds.as_slice() == [ArgKind::Str] => FuncId::PeSectionIndexName, diff --git a/crates/exav-core/src/yara/modules/pe.rs b/crates/exav-core/src/yara/modules/pe.rs index ad22e9ce..8736845d 100644 --- a/crates/exav-core/src/yara/modules/pe.rs +++ b/crates/exav-core/src/yara/modules/pe.rs @@ -194,10 +194,7 @@ fn build_pe(_data: &[u8], pe: &goblin::pe::PE) -> (Value, PeExtra) { let dll = imp.dll.to_string(); let entry = match imports.iter_mut().find(|(d, _)| *d == dll) { Some(e) => e, - None => { - imports.push((dll, Vec::new())); - imports.last_mut().unwrap() - } + None => imports.push_mut((dll, Vec::new())), }; entry.1.push(ImpFunc { name: Some(imp.name.to_string()), diff --git a/crates/exav-core/src/yara/scanner.rs b/crates/exav-core/src/yara/scanner.rs index cf4a9010..bbfbc9d3 100644 --- a/crates/exav-core/src/yara/scanner.rs +++ b/crates/exav-core/src/yara/scanner.rs @@ -10,7 +10,7 @@ use crate::yara::atoms::PatternGate; use crate::yara::compiler::ExternalValue; use crate::yara::error::{Error, Result}; use crate::yara::ir::{Cond, EvalCtx, Value}; -use crate::yara::matcher::{find_all_windowed, Match, PatternDef, PatternMatcher, StreamRegexes}; +use crate::yara::matcher::{Match, PatternDef, PatternMatcher, StreamRegexes, find_all_windowed}; use crate::yara::modules::{ModuleCtx, ModuleKind}; /// A compiled rule (internal representation). @@ -293,7 +293,7 @@ impl<'r> Scanner<'r> { .iter() .zip(&results) .enumerate() - .filter(|(_, (r, &res))| res && !r.private) + .filter(|&(_, (r, &res))| res && !r.private) .map(|(i, _)| i) .collect() } else { @@ -401,7 +401,7 @@ impl<'a, 'r, 'd> Rule<'a, 'r, 'd> { } /// The rule's metadata entries as `(identifier, value-as-string)` pairs. - pub fn metadata(&self) -> impl Iterator { + pub fn metadata(&self) -> impl Iterator + use<'a> { self.res.rules.rules[self.idx] .meta .iter() @@ -753,7 +753,9 @@ mod source_tests { .collect(); assert_eq!( names, - ["literals", "b64", "hexes", "regexes", "counts", "reads", "maths", "hashes"] + [ + "literals", "b64", "hexes", "regexes", "counts", "reads", "maths", "hashes" + ] ); } diff --git a/crates/exav-core/tests/fixtures/eicar.rpm.xor b/crates/exav-core/tests/fixtures/eicar.rpm.xor new file mode 100644 index 00000000..d2931965 --- /dev/null +++ b/crates/exav-core/tests/fixtures/eicar.rpm.xor @@ -0,0 +1,3 @@ +·ñ´�YZZZZ[2?665ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ[Z_ZZZZZZZZZZZZZZZZÔ÷²[ZZZZZZZ[ZZZ^ZZY²ZZZ]ZZZZZZZ^ZZZZZZZZÔ÷²[ZZZZZZZYZZZJZZY²ZZZ\ZZZZZZZ[ZZ^>ZZZ\ZZZ\ZZZ[ZZ^?ZZZ\ZZZQZZZ[2?665Z9*35Z= 3*ZEÑRZZZZZX¥ijmjmjnÚZj7;²28ÚGBø(WÒXÜ”jÌÄ$3+Ë$+8 ð$0ä$0<(8Ë($tYYÞóå8ÚðÙ9îÞóÓÆüãééü#÷Ðñýé9Ê4*ÒùÅÑ9ÊÑôùÒ=Â= +2ê4Ò1*ÒôÁýÕñøÐÝìÝLMŠFY>eK.Æ[#šSœRS(^€DþòòRîÀ[Z Ùä +[ZZ \ No newline at end of file diff --git a/crates/exav-core/tests/fixtures/eicar.txt.lzma.xor b/crates/exav-core/tests/fixtures/eicar.txt.lzma.xor new file mode 100644 index 00000000..61a18d47 Binary files /dev/null and b/crates/exav-core/tests/fixtures/eicar.txt.lzma.xor differ diff --git a/crates/exav-core/tests/fixtures/fuzz/yara_nested_parens.yar b/crates/exav-core/tests/fixtures/fuzz/yara_nested_parens.yar new file mode 100644 index 00000000..68cb702f Binary files /dev/null and b/crates/exav-core/tests/fixtures/fuzz/yara_nested_parens.yar differ diff --git a/crates/exav-core/tests/fixtures/fuzz/yara_nested_parens_12.yar b/crates/exav-core/tests/fixtures/fuzz/yara_nested_parens_12.yar new file mode 100644 index 00000000..06a1564e Binary files /dev/null and b/crates/exav-core/tests/fixtures/fuzz/yara_nested_parens_12.yar differ diff --git a/crates/exav-core/tests/fixtures/upx_damaged.elf b/crates/exav-core/tests/fixtures/upx_damaged.elf new file mode 100644 index 00000000..e4c3ea02 Binary files /dev/null and b/crates/exav-core/tests/fixtures/upx_damaged.elf differ diff --git a/crates/exav-core/tests/fixtures/volumes/set4.r00 b/crates/exav-core/tests/fixtures/volumes/set4.r00 new file mode 100644 index 00000000..3cd62dcc Binary files /dev/null and b/crates/exav-core/tests/fixtures/volumes/set4.r00 differ diff --git a/crates/exav-core/tests/fixtures/volumes/set4.r01 b/crates/exav-core/tests/fixtures/volumes/set4.r01 new file mode 100644 index 00000000..a2f7ed66 Binary files /dev/null and b/crates/exav-core/tests/fixtures/volumes/set4.r01 differ diff --git a/crates/exav-core/tests/fixtures/volumes/set4.rar b/crates/exav-core/tests/fixtures/volumes/set4.rar new file mode 100644 index 00000000..a7a9a8a9 Binary files /dev/null and b/crates/exav-core/tests/fixtures/volumes/set4.rar differ diff --git a/crates/exav-core/tests/fixtures/volumes/set5.part1.rar b/crates/exav-core/tests/fixtures/volumes/set5.part1.rar new file mode 100644 index 00000000..b95cd62b Binary files /dev/null and b/crates/exav-core/tests/fixtures/volumes/set5.part1.rar differ diff --git a/crates/exav-core/tests/fixtures/volumes/set5.part2.rar b/crates/exav-core/tests/fixtures/volumes/set5.part2.rar new file mode 100644 index 00000000..344e4d8c Binary files /dev/null and b/crates/exav-core/tests/fixtures/volumes/set5.part2.rar differ diff --git a/crates/exav-core/tests/fixtures/volumes/set5.part3.rar b/crates/exav-core/tests/fixtures/volumes/set5.part3.rar new file mode 100644 index 00000000..dedba0a2 Binary files /dev/null and b/crates/exav-core/tests/fixtures/volumes/set5.part3.rar differ diff --git a/crates/exav-core/tests/fixtures/volumes/split.z01 b/crates/exav-core/tests/fixtures/volumes/split.z01 new file mode 100644 index 00000000..24bd1033 Binary files /dev/null and b/crates/exav-core/tests/fixtures/volumes/split.z01 differ diff --git a/crates/exav-core/tests/fixtures/volumes/split.zip b/crates/exav-core/tests/fixtures/volumes/split.zip new file mode 100644 index 00000000..05bd7431 Binary files /dev/null and b/crates/exav-core/tests/fixtures/volumes/split.zip differ diff --git a/crates/exav-core/tests/suites/allmatch_parity.rs b/crates/exav-core/tests/suites/allmatch_parity.rs index b19ddac6..4b79b28a 100644 --- a/crates/exav-core/tests/suites/allmatch_parity.rs +++ b/crates/exav-core/tests/suites/allmatch_parity.rs @@ -15,7 +15,7 @@ //! //! This test pins the invariant rather than the one bug. -use exav_core::{analyze, analyze_all, loader, ScanOptions, Scanner}; +use exav_core::{ScanOptions, Scanner, analyze, analyze_all, loader}; use std::io::Write; /// A macro-stream signature: `Target:2` (OLE2) with a literal body, the exact diff --git a/crates/exav-core/tests/suites/authenticode.rs b/crates/exav-core/tests/suites/authenticode.rs index 728c36c5..4a36920d 100644 --- a/crates/exav-core/tests/suites/authenticode.rs +++ b/crates/exav-core/tests/suites/authenticode.rs @@ -2,7 +2,7 @@ //! embedded digest does not cover the file (tampered / appended-to after //! signing), and stays silent unless the flag is set. -use exav_core::{analyze, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze}; fn fixture(name: &str) -> Vec { let p = format!( diff --git a/crates/exav-core/tests/suites/base64_scan.rs b/crates/exav-core/tests/suites/base64_scan.rs index 1ac0ceaf..2c5ef3c7 100644 --- a/crates/exav-core/tests/suites/base64_scan.rs +++ b/crates/exav-core/tests/suites/base64_scan.rs @@ -3,7 +3,7 @@ //! pattern) is detected when — and only when — `decode_base64` is on. use base64::Engine; -use exav_core::{analyze, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze}; // The standard EICAR test string — the built-in DB carries a signature for it. // Assembled at runtime; see `exav_core::unpack::eicar` for why it is never a @@ -91,3 +91,171 @@ fn base64_scan_off_leaves_it_clean() { "base64 scan disabled should leave the carrier clean" ); } + +// ---- What the scan decodes, and what it says when it stops ------------------ + +/// A decoy: it starts as an executable, so the scan decodes it, and it is none. +fn decoy() -> Vec { + let mut d = vec![0u8; 2048]; + d[0] = b'M'; + d[1] = b'Z'; + d +} + +/// A PE that is valid and holds nothing. +fn clean_pe() -> Vec { + let mut pe = vec![0u8; 2048]; + pe[0] = b'M'; + pe[1] = b'Z'; + pe[0x3c..0x40].copy_from_slice(&0x40u32.to_le_bytes()); + pe[0x40..0x44].copy_from_slice(b"PE\x00\x00"); + pe +} + +fn script_with_all(runs: &[Vec]) -> Vec { + let mut s = String::from("# dropper\r\n"); + for (i, r) in runs.iter().enumerate() { + let b64 = base64::engine::general_purpose::STANDARD.encode(r); + s.push_str(&format!("$p{i} = \"{b64}\"\r\n")); + } + s.into_bytes() +} + +fn verdict_of(blob: &[u8]) -> Verdict { + let eicar = eicar(); + assert!( + !blob.windows(eicar.len()).any(|w| w == eicar), + "the string must not be in the file's bytes" + ); + analyze(&Scanner::builtin(), blob, &ScanOptions::default()).verdict +} + +fn limited(v: &Verdict) -> bool { + matches!(v, Verdict::LimitsExceeded { reason } if reason.contains("base64")) +} + +#[test] +fn a_payload_behind_many_decoys_is_found_or_the_scan_says_it_stopped() { + let payload = eicar_pe(); + // Within the number of runs it tries. + let mut runs = vec![decoy(); 20]; + runs.push(payload.clone()); + assert!(matches!( + verdict_of(&script_with_all(&runs)), + Verdict::Infected { .. } + )); + // Past it: not a clean file. + let mut runs = vec![decoy(); 80]; + runs.push(payload); + let v = verdict_of(&script_with_all(&runs)); + assert!(limited(&v), "{v:?}"); +} + +#[test] +fn a_payload_behind_many_executables_is_found_or_the_scan_says_it_stopped() { + let payload = eicar_pe(); + let mut runs = vec![clean_pe(); 5]; + runs.push(payload.clone()); + assert!(matches!( + verdict_of(&script_with_all(&runs)), + Verdict::Infected { .. } + )); + let mut runs = vec![clean_pe(); 12]; + runs.push(payload); + let v = verdict_of(&script_with_all(&runs)); + assert!(limited(&v), "{v:?}"); +} + +#[test] +fn a_script_behind_a_long_run_of_high_bytes_is_still_read_as_text() { + let mut blob = vec![0xE9u8; 4000]; + blob.extend_from_slice(b"\r\n"); + blob.extend_from_slice(&script_with_base64(&eicar_pe())); + // Text over most of the file, and bytes over the start of it. + let mut long = blob.clone(); + long.extend_from_slice(&vec![b'#'; 20000]); + assert!( + matches!(verdict_of(&long), Verdict::Infected { .. }), + "{:?}", + verdict_of(&long) + ); +} + +/// A ZIP with a member of deflated EICAR and a member of bytes that do not +/// compress, which is also how it comes to be long enough for the scan to read. +fn zip_with_payload() -> Vec { + use std::io::Write; + let deflated = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + let stored = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + z.start_file("e.com", deflated).unwrap(); + z.write_all(&[eicar(), b"\n"].concat().repeat(40)).unwrap(); + z.start_file("pad.bin", stored).unwrap(); + let mut x = 0x2545_f491u32; + let pad: Vec = (0..1500) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect(); + z.write_all(&pad).unwrap(); + z.finish().unwrap().into_inner() +} + +#[test] +fn a_zip_in_base64_in_a_script_is_opened() { + let b64 = base64::engine::general_purpose::STANDARD.encode(zip_with_payload()); + let blob = format!("var z = atob(\"{b64}\");\r\nsave(z, 'invoice.zip');\r\n").into_bytes(); + assert!( + matches!(verdict_of(&blob), Verdict::Infected { .. }), + "{:?}", + verdict_of(&blob) + ); +} + +#[test] +fn a_zip_in_a_data_uri_of_a_page_is_opened() { + let b64 = base64::engine::general_purpose::STANDARD.encode(zip_with_payload()); + let blob = format!( + "invoice" + ) + .into_bytes(); + assert!( + matches!(verdict_of(&blob), Verdict::Infected { .. }), + "{:?}", + verdict_of(&blob) + ); +} + +#[test] +fn a_run_that_only_starts_like_a_zip_is_not_an_archive() { + // PK\x03\x04 and noise: a coincidence of four bytes must not make a file + // that fails to open. + let mut noise = b"PK\x03\x04".to_vec(); + noise.resize(2000, 0x41); + let b64 = base64::engine::general_purpose::STANDARD.encode(&noise); + let blob = format!("var z = \"{b64}\";\r\n").into_bytes(); + assert!( + matches!(verdict_of(&blob), Verdict::Clean), + "{:?}", + verdict_of(&blob) + ); +} + +#[test] +fn a_small_script_behind_a_block_of_high_bytes_is_read_as_text() { + // Under one sample, and a fifth of it bytes that are not text. + let mut blob = vec![0xE9u8; 500]; + blob.extend_from_slice(b"\r\n"); + blob.extend_from_slice(&script_with_base64(&eicar_pe())); + assert!(blob.len() < 8192); + assert!( + matches!(verdict_of(&blob), Verdict::Infected { .. }), + "{:?}", + verdict_of(&blob) + ); +} diff --git a/crates/exav-core/tests/suites/broken_executable.rs b/crates/exav-core/tests/suites/broken_executable.rs index c5fd00d3..b850115e 100644 --- a/crates/exav-core/tests/suites/broken_executable.rs +++ b/crates/exav-core/tests/suites/broken_executable.rs @@ -12,7 +12,7 @@ //! `.so` with junk section headers. Meanwhile PEs whose alignment or section //! layout no loader accepts parsed fine and passed. -use exav_core::{analyze, loader, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze, loader}; fn empty_db() -> Scanner { let mut l = loader::Builder::new(); diff --git a/crates/exav-core/tests/suites/bytecode_gating.rs b/crates/exav-core/tests/suites/bytecode_gating.rs index 58460acb..fa0cd6ec 100644 --- a/crates/exav-core/tests/suites/bytecode_gating.rs +++ b/crates/exav-core/tests/suites/bytecode_gating.rs @@ -5,7 +5,7 @@ use exav_core::bytecode::runtime::BytecodeRuntime; use exav_core::filetype::FileType; -use exav_core::{analyze, analyze_all, ScanOptions, Verdict}; +use exav_core::{ScanOptions, Verdict, analyze, analyze_all}; const HEADER_MAGIC: u64 = 0x53e5_493e_9f3d_1c30; @@ -91,7 +91,7 @@ fn synth_cbc_when(name: &str, marker: &[u8], attrs: &str) -> String { b.push(nib(2)); // opcode hi nibble b.push(nib(0)); // numOps = fixed(1) 0 b.push_str(&num(5)); // funcid number (decoded = 5-1 = 4 -> global id 5) - // terminator: ret_void (opcode 20 = 0x14). + // terminator: ret_void (opcode 20 = 0x14). b.push('T'); b.push(nib(4)); b.push(nib(1)); diff --git a/crates/exav-core/tests/suites/cdb_stream.rs b/crates/exav-core/tests/suites/cdb_stream.rs index e0c8b526..589cd219 100644 --- a/crates/exav-core/tests/suites/cdb_stream.rs +++ b/crates/exav-core/tests/suites/cdb_stream.rs @@ -5,7 +5,7 @@ //! no CDB matching, so these detections were silently missed. It also exercises //! the 1-based `FilePos` semantics (ClamAV counts members from 1). -use exav_core::{loader, scan_seekable, ScanOptions, Verdict}; +use exav_core::{ScanOptions, Verdict, loader, scan_seekable}; use std::io::Cursor; fn db_with_dualext() -> exav_core::Scanner { diff --git a/crates/exav-core/tests/suites/checksum_mismatch.rs b/crates/exav-core/tests/suites/checksum_mismatch.rs index ba002bab..7f46b96a 100644 --- a/crates/exav-core/tests/suites/checksum_mismatch.rs +++ b/crates/exav-core/tests/suites/checksum_mismatch.rs @@ -10,7 +10,7 @@ //! written by RAR and zoo. EICAR is compressed in every one, so it is found //! only in the decoded member, never in the archive's own bytes. -use exav_core::{analyze, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze}; #[derive(Clone, Copy, Debug, PartialEq)] enum Want { diff --git a/crates/exav-core/tests/suites/cut_short_members.rs b/crates/exav-core/tests/suites/cut_short_members.rs index 730ddd4d..8e24d294 100644 --- a/crates/exav-core/tests/suites/cut_short_members.rs +++ b/crates/exav-core/tests/suites/cut_short_members.rs @@ -14,7 +14,7 @@ use std::io::{Read, Write}; -use exav_core::{analyze, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze}; #[derive(Clone, Copy, Debug)] enum Want { diff --git a/crates/exav-core/tests/suites/embedded_carve.rs b/crates/exav-core/tests/suites/embedded_carve.rs index 0331c0f5..ae88d511 100644 --- a/crates/exav-core/tests/suites/embedded_carve.rs +++ b/crates/exav-core/tests/suites/embedded_carve.rs @@ -2,7 +2,7 @@ //! dropper — must be carved and scanned. The normal scan only types offset 0, so //! without embedded-archive carving the payload in the overlay is invisible. -use exav_core::{analyze, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze}; use std::io::Write; /// A text carrier holding `n` copies of `item`, each after some filler, so no @@ -110,6 +110,80 @@ fn appended_overlay_zip_is_carved_and_scanned() { } } +/// A zip of a few hundred entries stapled into a document is one archive, not +/// one per entry: it is scanned once and does not trip the carving cap. +#[test] +fn a_zip_of_many_entries_stapled_into_a_document_is_clean() { + let stored = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for k in 0..300 { + z.start_file(format!("entry{k}.txt"), stored).unwrap(); + z.write_all(b"nothing to see here").unwrap(); + } + let z = z.finish().unwrap().into_inner(); + let mut doc = b"a document with a zip stapled inside it\n".to_vec(); + doc.extend_from_slice(&z); + doc.extend_from_slice(&[b'.'; 100]); + assert_eq!(verdict(&doc), Verdict::Clean); +} + +/// A zip whose first header names a size that lands on a second archive does +/// not hide that archive: the first one's walk reaches every local header +/// after its own, whether the second sits inside the first one's span (before +/// its central directory) or after its end. +#[test] +fn a_second_zip_at_the_offset_the_first_one_names_is_still_found() { + let eicar = exav_core::unpack::eicar(); + let deflated = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + let mut x = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + x.start_file("p.txt", deflated).unwrap(); + x.write_all(&[eicar, b"\n"].concat().repeat(64)).unwrap(); + let x = x.finish().unwrap().into_inner(); + assert!(!x.windows(eicar.len()).any(|w| w == eicar)); + + // A: one stored entry "a.txt", then `middle`, then its directory. + let a = |middle: &[u8]| { + let data = b"hello"; + let mut crc = flate2::Crc::new(); + crc.update(data); + let mut out = b"PK\x03\x04\x14\0\0\0\0\0\0\0\x21\0".to_vec(); + out.extend_from_slice(&crc.sum().to_le_bytes()); + out.extend_from_slice(&[5, 0, 0, 0, 5, 0, 0, 0, 5, 0, 0, 0]); + out.extend_from_slice(b"a.txt"); + out.extend_from_slice(data); + let cd_at = out.len() + middle.len(); + out.extend_from_slice(middle); + let cd = out.len(); + out.extend_from_slice(b"PK\x01\x02\x14\0\x14\0\0\0\0\0\0\0\x21\0"); + out.extend_from_slice(&crc.sum().to_le_bytes()); + // Sizes, name length 5, then the extra, comment, disk, attribute and + // local header offset fields, all zero. + out.extend_from_slice(&[5, 0, 0, 0, 5, 0, 0, 0, 5, 0]); + out.extend_from_slice(&[0; 16]); + out.extend_from_slice(b"a.txt"); + let size = (out.len() - cd) as u32; + out.extend_from_slice(b"PK\x05\x06\0\0\0\0\x01\0\x01\0"); + out.extend_from_slice(&size.to_le_bytes()); + out.extend_from_slice(&(cd_at as u32).to_le_bytes()); + out.extend_from_slice(&[0, 0]); + out + }; + for (what, zip) in [("inside", a(&x)), ("after", [a(b""), x.clone()].concat())] { + let mut doc = b"a document with two zips stapled inside it\n".to_vec(); + doc.extend_from_slice(&zip); + doc.extend_from_slice(&[b'.'; 100]); + match verdict(&doc) { + Verdict::Infected { signature, .. } => assert!( + signature.to_ascii_uppercase().contains("EICAR"), + "{what}: unexpected signature {signature}" + ), + other => panic!("{what}: EICAR in the second zip must be found, got {other:?}"), + } + } +} + /// An executable in an OLE2 file is also carved from the file's own bytes and /// scanned to the end of the file, as ClamAV carves it: a PE-only signature on /// bytes of a later stream matches, which carving inside the executable's own @@ -147,3 +221,46 @@ fn an_ole2_file_is_carved_from_its_own_bytes() { other => panic!("expected the carved executable to match, got {other:?}"), } } + +/// A carved executable that cannot be read whole is a finding of its own, but +/// not the end of the scan: an archive stapled before it is still opened. Else a +/// damaged decoy after a payload turns `FOUND` into `PARTIAL`, or into `OK` +/// under `--partial-as ok`. +#[test] +fn a_damaged_executable_in_a_carrier_does_not_hide_the_archive_with_the_payload() { + // `upx_nrv2d.upx` with five bytes of its compressed data changed. + let decoy = include_bytes!("../fixtures/upx_damaged.elf"); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflated = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + zip.start_file("e.com", deflated).unwrap(); + // Repetitive, so the compressor codes it and the string is not in the bytes. + zip.write_all(&[exav_core::unpack::eicar(), b"\n"].concat().repeat(64)) + .unwrap(); + let zip = zip.finish().unwrap().into_inner(); + let eicar = exav_core::unpack::eicar(); + assert!( + !zip.windows(eicar.len()).any(|w| w == eicar), + "the string must not be in the archive's bytes" + ); + + let mut stub = b"MZ".to_vec(); + stub.resize(62, 0); + let control = [stub.as_slice(), &zip].concat(); + assert!( + matches!(verdict(&control), Verdict::Infected { .. }), + "the premise" + ); + let alone = [stub.as_slice(), decoy.as_slice()].concat(); + assert!( + matches!(verdict(&alone), Verdict::Unscannable { .. }), + "the decoy must not unpack" + ); + let both = [stub.as_slice(), &zip, decoy.as_slice()].concat(); + match verdict(&both) { + Verdict::Infected { .. } => {} + other => { + panic!("the archive before a damaged executable must still be found, got {other:?}") + } + } +} diff --git a/crates/exav-core/tests/suites/encrypted_archives.rs b/crates/exav-core/tests/suites/encrypted_archives.rs index da232959..e9961235 100644 --- a/crates/exav-core/tests/suites/encrypted_archives.rs +++ b/crates/exav-core/tests/suites/encrypted_archives.rs @@ -11,7 +11,7 @@ //! With the password the inner EICAR is FOUND (`Infected`); without it the scan //! is `PasswordProtected` — never `Clean`, never `Infected`. -use exav_core::{analyze, loader, ScanOptions, Verdict}; +use exav_core::{ScanOptions, Verdict, analyze, loader}; fn builtin_db() -> exav_core::Scanner { // The built-in DB carries the EICAR signature, which is all we need. diff --git a/crates/exav-core/tests/suites/exec_offsets.rs b/crates/exav-core/tests/suites/exec_offsets.rs new file mode 100644 index 00000000..b44c5db0 --- /dev/null +++ b/crates/exav-core/tests/suites/exec_offsets.rs @@ -0,0 +1,370 @@ +//! `EP`, `Sn`, `SL` and `SEn` offsets on ELF (`Target:6`) and Mach-O +//! (`Target:9`) files. +//! +//! Until a layout was read from their headers these offsets matched nothing +//! there: a signature anchored at an ELF's entry point was in the database, the +//! file was that ELF, and the scan said `OK`. +//! +//! The expectations are clamscan 1.4.3's, measured one signature at a time on +//! a gcc-built ELF and on hand-built Mach-O files, each offset with a control +//! (the same bytes at an offset wrong by three, which must not fire): +//! +//! * ELF `EP`: the entry point's file offset, through the loadable segment +//! whose memory image holds it, the `.bss` part included; nothing past it, +//! nothing for another file type. +//! * ELF and Mach-O `Sn`: section `n`'s file offset, counted from the null +//! section 0 (ELF) or over the segments in load order (Mach-O); `SL` the last. +//! * `SEn`: the match starts inside section `n`, its end included. +//! * Mach-O `EP`: never resolved (`LC_MAIN` and `LC_UNIXTHREAD`, both widths). + +use exav_core::{ScanOptions, Verdict, analyze, loader}; + +/// 64 bytes of distinct content for a section: `seed` and the position. +fn block(seed: u8) -> Vec { + (0..64u32) + .map(|j| ((u32::from(seed) * 61 + j * 7 + 13) % 251 + 1) as u8) + .collect() +} + +fn put(v: &mut Vec, at: usize, bytes: &[u8]) { + if v.len() < at + bytes.len() { + v.resize(at + bytes.len(), 0); + } + v[at..at + bytes.len()].copy_from_slice(bytes); +} + +/// Bytes of an integer field of `width` bytes in the file's order. +fn int(v: u64, width: usize, be: bool) -> Vec { + let mut b = v.to_le_bytes()[..width].to_vec(); + if be { + b.reverse(); + } + b +} + +/// An ELF with a null section, `.text`, `.rodata`, `.data` and a `.bss`: +/// two loadable segments, the second at a virtual address that is not its file +/// offset plus the first's, and with memory past its bytes. Returns the file, +/// and the file offsets of the sections' contents. +fn elf(wide: bool, be: bool, entry: u64) -> Vec { + let w = if wide { 8 } else { 4 }; + let (ehsize, phsize, shsize) = if wide { (64, 56, 64) } else { (52, 32, 40) }; + let mut f = vec![0u8; 0x600]; + put(&mut f, 0, b"\x7fELF"); + f[4] = if wide { 2 } else { 1 }; + f[5] = if be { 2 } else { 1 }; + f[6] = 1; + let mut at = 16; + for (v, n) in [(2u64, 2), (62, 2), (1, 4)] { + put(&mut f, at, &int(v, n, be)); + at += n; + } + let (phoff, shoff) = (ehsize as u64, 0x400u64); + for v in [entry, phoff, shoff] { + put(&mut f, at, &int(v, w, be)); + at += w; + } + put(&mut f, at, &int(0, 4, be)); // e_flags + at += 4; + for v in [ehsize as u64, phsize as u64, 2, shsize as u64, 5, 0] { + put(&mut f, at, &int(v, 2, be)); + at += 2; + } + // Program headers: (type, offset, vaddr, filesz, memsz). + let segments = [ + (1u64, 0u64, 0x10000u64, 0x300u64, 0x300u64), + (1, 0x300, 0x30000, 0x40, 0x80), + ]; + for (i, (kind, offset, vaddr, filesz, memsz)) in segments.iter().enumerate() { + let p = ehsize + i * phsize; + put(&mut f, p, &int(*kind, 4, be)); + if wide { + for (off, v) in [ + (8, offset), + (16, vaddr), + (24, vaddr), + (32, filesz), + (40, memsz), + ] { + put(&mut f, p + off, &int(*v, 8, be)); + } + } else { + for (off, v) in [ + (4, offset), + (8, vaddr), + (12, vaddr), + (16, filesz), + (20, memsz), + ] { + put(&mut f, p + off, &int(*v, 4, be)); + } + } + } + // Section contents, then the section headers: (type, offset, size). + for (i, off) in [0x200usize, 0x240, 0x300].iter().enumerate() { + put(&mut f, *off, &block(i as u8)); + } + // What follows `.rodata`, which is not in it, and what the file holds + // where the `.bss` would be, so that a wrong offset reads other bytes. + put(&mut f, 0x280, &block(9)[..32]); + put(&mut f, 0x340, &block(7)); + let sections = [ + (0u64, 0u64, 0u64), + (1, 0x200, 0x40), + (1, 0x240, 0x40), + (1, 0x300, 0x40), + (8, 0x340, 0x40), + ]; + for (i, (kind, offset, size)) in sections.iter().enumerate() { + let s = shoff as usize + i * shsize; + put(&mut f, s + 4, &int(*kind, 4, be)); + let (off_at, size_at) = if wide { (24, 32) } else { (16, 20) }; + put(&mut f, s + off_at, &int(*offset, w, be)); + put(&mut f, s + size_at, &int(*size, w, be)); + } + f +} + +/// A Mach-O with `__text` and `__cstring` in `__TEXT`, `__data` in `__DATA`, +/// and an `LC_MAIN` whose entry is at 0x210. +fn macho(wide: bool, be: bool) -> Vec { + let (seg_cmd, seg_head, sect_len, sect_fields) = if wide { + (0x19u64, 72usize, 80usize, 8usize) + } else { + (0x1, 56, 68, 4) + }; + let sections = [ + ("__text", "__TEXT", 0x200u64), + ("__cstring", "__TEXT", 0x240), + ("__data", "__DATA", 0x280), + ]; + let mut cmds = Vec::new(); + for (segname, group) in [("__TEXT", §ions[..2]), ("__DATA", §ions[2..])] { + let size = seg_head + group.len() * sect_len; + let mut c = vec![0u8; size]; + put(&mut c, 0, &int(seg_cmd, 4, be)); + put(&mut c, 4, &int(size as u64, 4, be)); + put(&mut c, 8, segname.as_bytes()); + let nsects_at = if wide { 64 } else { 48 }; + put(&mut c, nsects_at, &int(group.len() as u64, 4, be)); + for (i, (name, seg, offset)) in group.iter().enumerate() { + let s = seg_head + i * sect_len; + put(&mut c, s, name.as_bytes()); + put(&mut c, s + 16, seg.as_bytes()); + put( + &mut c, + s + 32, + &int(0x1_0000_0000 + offset, sect_fields, be), + ); + put(&mut c, s + 32 + sect_fields, &int(0x40, sect_fields, be)); + put(&mut c, s + 32 + 2 * sect_fields, &int(*offset, 4, be)); + } + cmds.extend(c); + } + let mut main = vec![0u8; 24]; + put(&mut main, 0, &int(0x8000_0028, 4, be)); + put(&mut main, 4, &int(24, 4, be)); + put(&mut main, 8, &int(0x210, 8, be)); + cmds.extend(main); + let header = if wide { 32 } else { 28 }; + let mut f = vec![0u8; 0x300]; + let magic: [u8; 4] = match (wide, be) { + (true, false) => [0xCF, 0xFA, 0xED, 0xFE], + (false, false) => [0xCE, 0xFA, 0xED, 0xFE], + (true, true) => [0xFE, 0xED, 0xFA, 0xCF], + (false, true) => [0xFE, 0xED, 0xFA, 0xCE], + }; + put(&mut f, 0, &magic); + put(&mut f, 4, &int(if wide { 0x0100_000c } else { 7 }, 4, be)); + put(&mut f, 12, &int(2, 4, be)); + put(&mut f, 16, &int(3, 4, be)); + put(&mut f, 20, &int(cmds.len() as u64, 4, be)); + put(&mut f, header, &cmds); + for (i, (_, _, offset)) in sections.iter().enumerate() { + put(&mut f, *offset as usize, &block(i as u8)); + } + f +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// The names of the signatures among `lines` (`name:target:offset:bytes`) +/// that fire on `file`, all of them listed. +fn fired(file: &[u8], lines: &[(&str, &str, &str, &[u8])]) -> Vec { + let mut found = Vec::new(); + // One signature a database: a first-match scan would stop at the first. + for (name, target, offset, bytes) in lines { + let mut b = loader::Builder::new(); + b.add_named_bytes( + "t.ndb", + format!("{name}:{target}:{offset}:{}\n", hex(bytes)).as_bytes(), + true, + ); + let Ok(db) = b.build() else { + continue; + }; + if matches!( + analyze(&db, file, &ScanOptions::default()).verdict, + Verdict::Infected { .. } + ) { + found.push((*name).to_string()); + } + } + found +} + +fn names(v: &[&str]) -> Vec { + v.iter().map(|s| (*s).to_string()).collect() +} + +#[test] +fn an_elf_resolves_its_entry_point_and_its_sections_in_every_class_and_order() { + for (wide, be) in [(true, false), (false, false), (true, true), (false, true)] { + let f = elf(wide, be, 0x10210); + let at = |o: usize| f[o..o + 12].to_vec(); + let got = fired( + &f, + &[ + ("EP", "6", "EP+0", &at(0x210)), + ("EPwrong", "6", "EP+3", &at(0x210)), + ("EPelsewhere", "6", "EP+0", &at(0x200)), + ("S0", "6", "S0+0", &at(0)), + ("S1", "6", "S1+0", &at(0x200)), + ("S1wrong", "6", "S1+3", &at(0x200)), + ("S2", "6", "S2+0", &at(0x240)), + ("S3", "6", "S3+0", &at(0x300)), + ("S3wrong", "6", "S3+3", &at(0x300)), + ("S4", "6", "S4+0", &at(0x340)), + ("S5none", "6", "S5+0", &at(0x200)), + ("SL", "6", "SL+0", &at(0x340)), + ("SLwrong", "6", "SL+3", &at(0x340)), + // Another file type's target. + ("EPasPe", "1", "EP+0", &at(0x210)), + ], + ); + assert_eq!( + got, + names(&["EP", "S0", "S1", "S2", "S3", "S4", "SL"]), + "wide {wide} big-endian {be}" + ); + } +} + +#[test] +fn an_elf_entry_point_is_found_through_the_segment_that_maps_it() { + // In the second segment, whose address is not its offset plus the first's. + let f = elf(true, false, 0x30010); + let sig = |o: usize| f[o..o + 12].to_vec(); + assert_eq!( + fired( + &f, + &[ + ("EP", "6", "EP+0", &sig(0x310)), + ("EPasAddr", "6", "EP+0", &sig(0x10)), + ("EPwrong", "6", "EP+3", &sig(0x310)) + ] + ), + names(&["EP"]) + ); + // In the memory past the segment's bytes, which has an offset all the same. + let f = elf(true, false, 0x30050); + assert_eq!( + fired(&f, &[("EP", "6", "EP+0", &f[0x350..0x35c])]), + names(&["EP"]) + ); + // Past the segment's memory, in none. + let f = elf(true, false, 0x30080); + assert_eq!( + fired( + &f, + &[ + ("EP", "6", "EP+0", &f[0x380..0x38c]), + ("EP0", "6", "EP+0", &f[0..12]) + ] + ), + Vec::::new() + ); +} + +#[test] +fn se_means_a_start_inside_the_section_its_end_included() { + let f = elf(true, false, 0x10210); + // `.rodata` is 0x240..0x280. + let got = fired( + &f, + &[ + ("inside", "6", "SE2", &f[0x244..0x24a]), + ("first", "6", "SE2", &f[0x240..0x246]), + ("lastByteRunningOut", "6", "SE2", &f[0x27e..0x286]), + ("atTheEnd", "6", "SE2", &f[0x280..0x288]), + ("inTheNextOne", "6", "SE2", &f[0x304..0x30a]), + ("inThePrevious", "6", "SE2", &f[0x204..0x20a]), + ], + ); + assert_eq!( + got, + names(&["inside", "first", "lastByteRunningOut", "atTheEnd"]) + ); +} + +#[test] +fn a_macho_resolves_its_sections_in_load_order_and_never_its_entry_point() { + for (wide, be) in [(true, false), (false, false), (true, true), (false, true)] { + let f = macho(wide, be); + let at = |o: usize| f[o..o + 8].to_vec(); + let got = fired( + &f, + &[ + ("S0", "9", "S0+0", &at(0x200)), + ("S0wrong", "9", "S0+3", &at(0x200)), + ("S1", "9", "S1+0", &at(0x240)), + ("S2", "9", "S2+0", &at(0x280)), + ("S3none", "9", "S3+0", &at(0x280)), + ("SL", "9", "SL+0", &at(0x280)), + ("SLwrong", "9", "SL+3", &at(0x280)), + ("SE1", "9", "SE1", &f[0x244..0x24a]), + ("SE1elsewhere", "9", "SE1", &f[0x284..0x28a]), + ("EP", "9", "EP+0", &at(0x210)), + ], + ); + assert_eq!( + got, + names(&["S0", "S1", "S2", "SL", "SE1"]), + "wide {wide} big-endian {be}" + ); + } +} + +#[test] +fn headers_that_lie_give_no_layout_and_no_panic() { + let elf = elf(true, false, 0x10210); + let macho = macho(true, false); + for file in [&elf, &macho] { + // Every field at its extremes, 4 bytes at a time, and the file cut short. + for at in (0..file.len().min(0x120)).step_by(4) { + for v in [0xFFFF_FFFFu32, 0x8000_0000, 0x7FFF_FFFF, 0] { + let mut f = file.clone(); + f[at..at + 4].copy_from_slice(&v.to_le_bytes()); + let _ = fired( + &f, + &[ + ("S1", "6", "S1+0", &[1, 2, 3, 4]), + ("S1m", "9", "S1+0", &[1, 2, 3, 4]), + ("EP", "6", "EP+0", &[1, 2, 3, 4]), + ], + ); + } + } + for cut in (0..file.len()).step_by(37) { + let _ = fired( + &file[..cut], + &[ + ("SL", "6", "SL+0", &[1, 2, 3, 4]), + ("SLm", "9", "SL+0", &[1, 2, 3, 4]), + ], + ); + } + } +} diff --git a/crates/exav-core/tests/suites/format_registry.rs b/crates/exav-core/tests/suites/format_registry.rs new file mode 100644 index 00000000..4cd403c9 --- /dev/null +++ b/crates/exav-core/tests/suites/format_registry.rs @@ -0,0 +1,82 @@ +//! The scanner's detection against the format registry (`formats/registry.json`, +//! checked against the rest of the code by `scripts/formats.mjs`): the magic +//! bytes the registry gives a format, written into an otherwise empty buffer, +//! are typed as the format's scanner type. + +use exav_core::filetype::identify; + +fn registry() -> serde_json::Value { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../formats/registry.json"); + serde_json::from_str(&std::fs::read_to_string(path).expect("formats/registry.json")).unwrap() +} + +fn hex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +/// A buffer holding `conditions` (`offset:hex`) and zeros elsewhere. +fn buffer(conditions: &[&str]) -> Vec { + let parts: Vec<(usize, Vec)> = conditions + .iter() + .map(|c| { + let (offset, bytes) = c.split_once(':').unwrap(); + (offset.parse().unwrap(), hex(bytes)) + }) + .collect(); + let end = parts.iter().map(|(o, b)| o + b.len()).max().unwrap(); + let mut buf = vec![0u8; end.max(1024)]; + for (offset, bytes) in parts { + buf[offset..offset + bytes.len()].copy_from_slice(&bytes); + } + buf +} + +#[test] +fn the_magic_of_a_format_is_typed_as_that_format() { + let registry = registry(); + let mut checked = 0; + let mut skipped = 0; + let mut wrong = Vec::new(); + for f in registry["formats"].as_array().unwrap() { + let scan = &f["scan"]; + let Some(want) = scan["type"].as_str() else { + continue; + }; + if !matches!(scan["mode"].as_str(), Some("container" | "reported")) { + continue; + } + let id = f["id"].as_str().unwrap(); + // A format the scanner types only when more than the magic holds (a + // header's checksum, a structure that parses) says so in the registry. + if scan.get("needs").is_some() { + skipped += 1; + continue; + } + for alt in f["magic"].as_array().into_iter().flatten() { + let conditions: Vec<&str> = alt + .as_array() + .unwrap() + .iter() + .map(|c| c.as_str().unwrap()) + .collect(); + let got = identify(&buffer(&conditions)).as_str(); + checked += 1; + if got != want { + wrong.push(format!( + "{id} {conditions:?}: typed {got}, the registry says {want}" + )); + } + } + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); + assert!(checked > 20, "only {checked} magics were checked"); + assert!(skipped <= 3, "{skipped} formats are excused from the check"); +} + +#[test] +fn a_buffer_with_no_magic_of_the_registry_is_data() { + assert_eq!(identify(&vec![0u8; 4096]).as_str(), "data"); +} diff --git a/crates/exav-core/tests/suites/fuzzy_img_formats.rs b/crates/exav-core/tests/suites/fuzzy_img_formats.rs index 10b5ac78..fdc7b93b 100644 --- a/crates/exav-core/tests/suites/fuzzy_img_formats.rs +++ b/crates/exav-core/tests/suites/fuzzy_img_formats.rs @@ -10,7 +10,7 @@ //! the same segments reordered. The hashes are what `sigtool --fuzzy-img` //! (ClamAV 1.4.3) prints for the two PNGs. -use exav_core::{analyze, loader, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze, loader}; fn fixture(name: &str) -> Vec { let path = format!( diff --git a/crates/exav-core/tests/suites/heuristic_alerts.rs b/crates/exav-core/tests/suites/heuristic_alerts.rs index b33b3906..b7eb6990 100644 --- a/crates/exav-core/tests/suites/heuristic_alerts.rs +++ b/crates/exav-core/tests/suites/heuristic_alerts.rs @@ -16,7 +16,7 @@ use std::io::Write; -use exav_core::{analyze, Method, ScanOptions, Scanner, Verdict}; +use exav_core::{Method, ScanOptions, Scanner, Verdict, analyze}; fn builtin_db() -> Scanner { // The built-in DB carries only the EICAR signature; the heuristics under diff --git a/crates/exav-core/tests/suites/heuristic_names.rs b/crates/exav-core/tests/suites/heuristic_names.rs index 8e20610e..36d7ed77 100644 --- a/crates/exav-core/tests/suites/heuristic_names.rs +++ b/crates/exav-core/tests/suites/heuristic_names.rs @@ -16,7 +16,7 @@ //! Both were found by enumerating ClamAV's emitted names rather than by reading //! exav, which is the only way this class of bug shows up. -use exav_core::{analyze, loader, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze, loader}; use std::io::Write; fn empty_db() -> Scanner { diff --git a/crates/exav-core/tests/suites/limits_alerts.rs b/crates/exav-core/tests/suites/limits_alerts.rs index 87f71f1e..ea4102ae 100644 --- a/crates/exav-core/tests/suites/limits_alerts.rs +++ b/crates/exav-core/tests/suites/limits_alerts.rs @@ -14,7 +14,7 @@ //! detection name from prose is how a reworded message silently becomes a //! different alert. -use exav_core::{analyze, loader, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze, loader}; use std::io::Write; fn scanner() -> Scanner { diff --git a/crates/exav-core/tests/suites/match_location.rs b/crates/exav-core/tests/suites/match_location.rs index 5b763575..1cc8869b 100644 --- a/crates/exav-core/tests/suites/match_location.rs +++ b/crates/exav-core/tests/suites/match_location.rs @@ -12,7 +12,7 @@ //! matched. Recording is first-write-wins, so the assertion is that *some* //! location is reported for a hit that is nested. -use exav_core::{scan_seekable_located, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, scan_seekable_located}; use std::io::Cursor; fn eicar() -> &'static [u8] { diff --git a/crates/exav-core/tests/suites/matryoshka.rs b/crates/exav-core/tests/suites/matryoshka.rs index c1598bf1..f8c32ec5 100644 --- a/crates/exav-core/tests/suites/matryoshka.rs +++ b/crates/exav-core/tests/suites/matryoshka.rs @@ -40,7 +40,7 @@ //! scanner that gives up quietly on a deeply nested archive has been evaded //! by nothing more than a shell loop. -use exav_core::{analyze, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze}; /// Recursion levels needed to reach the payload. One less than this and exav /// cannot get there; the fixture sits exactly at this depth so the boundary is diff --git a/crates/exav-core/tests/suites/mod.rs b/crates/exav-core/tests/suites/mod.rs index 26f69243..e7784864 100644 --- a/crates/exav-core/tests/suites/mod.rs +++ b/crates/exav-core/tests/suites/mod.rs @@ -27,6 +27,9 @@ mod cut_short_members; mod embedded_carve; #[cfg(feature = "all-formats")] mod encrypted_archives; +mod exec_offsets; +#[cfg(feature = "all-formats")] +mod format_registry; #[cfg(feature = "image-hash")] mod fuzzy_img_formats; #[cfg(feature = "all-formats")] @@ -41,6 +44,7 @@ mod match_location; mod matryoshka; #[cfg(feature = "all-formats")] mod multi_volume; +mod nesting; #[cfg(feature = "all-formats")] mod one_scan; #[cfg(feature = "all-formats")] @@ -49,14 +53,22 @@ mod ooxml_container; mod oversize_container; mod salvage; #[cfg(feature = "all-formats")] +mod second_structure; +#[cfg(feature = "all-formats")] mod sevenz_ppmd; mod size_caps; +#[cfg(feature = "all-formats")] +mod split_sets; mod tar_size_terminator; mod target_flash; mod target_text; #[cfg(feature = "all-formats")] mod tdb_attributes; mod untyped_container_dispatch; +#[cfg(feature = "visible-text")] +mod visible_text; +#[cfg(feature = "visible-text-office")] +mod visible_text_office; #[cfg(feature = "yara")] mod yara_dotnet; #[cfg(feature = "all-formats")] diff --git a/crates/exav-core/tests/suites/multi_volume.rs b/crates/exav-core/tests/suites/multi_volume.rs index b57913ee..c1dc5cb7 100644 --- a/crates/exav-core/tests/suites/multi_volume.rs +++ b/crates/exav-core/tests/suites/multi_volume.rs @@ -11,7 +11,7 @@ //! rejoined: those bytes were withheld from the scan, and dropping them there //! would be a silent clean — the exact failure this scanner exists to prevent. -use exav_core::{analyze, ScanOptions, Scanner, Verdict}; +use exav_core::{ScanOptions, Scanner, Verdict, analyze}; fn eicar() -> &'static [u8] { exav_core::unpack::eicar() @@ -415,3 +415,83 @@ fn ordinary_names_cost_nothing_and_return_nothing() { ); assert!(v.is_empty(), "no sets, no verdicts"); } + +/// `analyze_volume_sets` over `parts` with `opts`, the `unreadable` ones failing to read. +fn sets_with( + parts: &[(String, Vec)], + unreadable: &[&str], + opts: &ScanOptions, +) -> Vec { + let names: Vec = parts.iter().map(|(n, _)| n.clone()).collect(); + exav_core::analyze_volume_sets(&Scanner::builtin(), &names, opts, |want| { + if unreadable.contains(&want) { + return Err(std::io::Error::other("permission denied")); + } + parts + .iter() + .find(|(n, _)| n == want) + .map(|(_, d)| d.clone()) + .ok_or_else(|| std::io::Error::other("no such file")) + }) +} + +#[test] +fn a_set_none_of_whose_parts_fits_the_budget_is_not_clean_part_by_part() { + // Every part is read and turned away, so the collector holds nothing of the + // set and has no entry to report. Each part then scanned alone as an + // ordinary file, and the archive was never looked at. + let parts = vec![ + ("big.7z.001".to_string(), vec![b'A'; 5000]), + ("big.7z.002".to_string(), vec![b'B'; 5000]), + ]; + let mut opts = ScanOptions::default(); + opts.deep_analysis_max = 4096; + let v = sets_with(&parts, &[], &opts); + assert_eq!(v.len(), 2, "a verdict for each part: {v:?}"); + for e in &v { + assert!( + matches!(e.report.verdict, Verdict::Unscannable { .. }), + "{}: got {:?}", + e.name, + e.report.verdict + ); + } +} + +#[test] +fn a_set_is_known_by_the_same_name_whatever_the_case_of_its_parts() { + // The collector groups parts without regard to case. The unreadable last + // part was recorded under its own case, so the set joined under the first + // part's case missed it and its readable prefix scanned clean. + let mut parts = set_with_payload_in_the_tail("truncated"); + parts[0].0 = parts[0].0.to_uppercase(); + let last = parts[2].0.clone(); + let v = sets(&parts, &[last.as_str()]); + assert!(!v.is_empty()); + for e in &v { + assert!( + !matches!(e.report.verdict, Verdict::Clean), + "{}: a set that lost a volume was reported CLEAN: {:?}", + e.name, + e.report.verdict + ); + } +} + +#[test] +fn the_one_readable_part_of_a_set_with_an_unreadable_part_is_not_a_lone_file() { + // `big.bin.001` alone looks like an ordinary numbered file, which is left + // to the caller's own scan. With `.002` present and unreadable it is half of + // an archive, and that scan says nothing about the other half. + let parts = vec![ + ("big.bin.001".to_string(), vec![b'A'; 100]), + ("big.bin.002".to_string(), eicar().to_vec()), + ]; + let v = sets(&parts, &["big.bin.002"]); + assert_eq!(v.len(), 1, "{v:?}"); + assert!( + matches!(v[0].report.verdict, Verdict::Unscannable { .. }), + "{:?}", + v[0].report.verdict + ); +} diff --git a/crates/exav-core/tests/suites/nesting.rs b/crates/exav-core/tests/suites/nesting.rs new file mode 100644 index 00000000..4f1478e5 --- /dev/null +++ b/crates/exav-core/tests/suites/nesting.rs @@ -0,0 +1,504 @@ +//! Signature text nested or chained as long as the file is. +//! +//! A database comes from a feed and is read with the stack of whatever thread +//! loads it. A signature that nests groups or chains operators deeper than the +//! parser expects must be refused or read, not recursed into until the stack +//! ends, which ends the process. Each database kind that has such a syntax is +//! loaded and scanned against here on a small stack with a signature as deep +//! as 200 KB of text makes it. + +use exav_core::loader::Builder; +use exav_core::{ScanOptions, analyze}; +use std::time::{Duration, Instant}; + +const STACK: usize = 512 * 1024; +const N: usize = 50_000; + +/// Loads `text` as the database `name` and scans a few bytes with it, on a +/// thread with a [`STACK`] stack. What went wrong, if anything. +fn load_small(name: &'static str, text: String) -> Option { + let started = Instant::now(); + let r = std::thread::Builder::new() + .stack_size(STACK) + .spawn(move || { + let mut loader = Builder::new(); + loader.add_named_bytes(name, text.as_bytes(), false); + if let Ok(db) = loader.build() { + let _ = analyze( + &db, + b"MZ\x90\x00 a short file with abc and 0123", + &ScanOptions::default(), + ); + } + }) + .unwrap() + .join(); + match r { + Err(_) => Some(format!("{name}: overflowed its stack or panicked")), + Ok(()) if started.elapsed() > Duration::from_secs(30) => Some(format!( + "{name}: took {:.1} s", + started.elapsed().as_secs_f64() + )), + Ok(()) => None, + } +} + +fn assert_all(findings: Vec>) { + let findings: Vec = findings.into_iter().flatten().collect(); + assert!(findings.is_empty(), "\n{}", findings.join("\n")); +} + +/// The harness loads what it is given: a plain signature of each kind found +/// in the bytes it scans, so the cases below are read and not skipped. +#[test] +fn the_databases_here_are_loaded_and_matched() { + let data = b"MZ\x90\x00 a short file with abc and 0123"; + for (name, text) in [ + ("t.ndb", "Canary.N:0:*:616263"), + ("t.ldb", "Canary.L;Target:0;0&1;616263;30313233"), + ] { + let mut loader = Builder::new(); + loader.add_named_bytes(name, text.as_bytes(), false); + let db = loader.build().unwrap(); + let report = analyze(&db, data, &ScanOptions::default()); + assert!( + matches!(report.verdict, exav_core::Verdict::Infected { .. }), + "{name}: {:?}", + report.verdict + ); + } +} + +#[test] +fn body_signatures() { + let a = "aabbcc"; + let cases: Vec<(&str, String)> = vec![ + ( + "parentheses", + format!("T:0:*:{}{a}{}", "(".repeat(N), ")".repeat(N)), + ), + ("alternatives", format!("T:0:*:({})", vec![a; N].join("|"))), + ( + "nested alternatives", + format!("T:0:*:{}{a}{}", "(aa|".repeat(N), ")".repeat(N)), + ), + ("not", format!("T:0:*:{}({a}|dd)", "!".repeat(N))), + ("jumps", format!("T:0:*:{}", "aa{1-2}".repeat(N))), + ("stars", format!("T:0:*:{}", "aa*".repeat(N))), + ("wildcards", format!("T:0:*:{}", "??".repeat(N))), + ("nibbles", format!("T:0:*:{}", "a?".repeat(N))), + ("anchored jumps", format!("T:0:*:{}", "aa[1-2]".repeat(N))), + ("offsets", format!("T:0:{}EP+0:{a}", "EP+0,".repeat(N))), + ("sections", format!("T:0:{}SE1:{a}", "SL+0,".repeat(N))), + ("a long name", format!("{}:0:*:{a}", "T".repeat(N * 4))), + ( + "many signatures", + (0..N * 2) + .map(|i| format!("T{i}:0:*:{a}{i:04x}\n")) + .collect::(), + ), + ]; + assert_all( + cases + .into_iter() + .map(|(n, t)| load_small("t.ndb", t).map(|e| format!("{n}: {e}"))) + .collect(), + ); +} + +#[test] +fn logical_signatures() { + let sub = "6162;6364"; + let flat = |op: &str, n: usize| vec!["0"; n].join(op); + let cases: Vec<(&str, String)> = vec![ + ( + "parentheses", + format!( + "T;Engine:51-255,Target:0;{}0{};{sub}", + "(".repeat(N), + ")".repeat(N) + ), + ), + ("and chain", format!("T;Target:0;{};{sub}", flat("&", N))), + ("or chain", format!("T;Target:0;{};{sub}", flat("|", N))), + ( + "mixed chain", + format!("T;Target:0;{};{sub}", vec!["0&1|"; N].concat() + "0"), + ), + ("negation", format!("T;Target:0;{}0;{sub}", "!".repeat(N))), + ( + "counts", + format!("T;Target:0;{}0>1;{sub}", "(".repeat(N / 100)), + ), + ( + "many subsignatures", + format!("T;Target:0;0;{}", vec!["6162"; N].join(";")), + ), + ("a high index", format!("T;Target:0;{};{sub}", N * 1000)), + ( + "fuzzy-hash chain", + format!( + "T;Target:0;0;{}", + "fuzzy_img#00000000000000000#0;".repeat(N / 100) + ), + ), + ( + "pcre groups", + format!("T;Target:0;0;0/{}a{}/", "(".repeat(N), ")".repeat(N)), + ), + ( + "pcre alternations", + format!("T;Target:0;0;0/{}/", "a|".repeat(N)), + ), + ( + "pcre quantifiers", + format!("T;Target:0;0;0/{}/", "a*".repeat(N)), + ), + ( + "pcre classes", + format!("T;Target:0;0;0/{}/", "[a-z]".repeat(N)), + ), + ( + "pcre lookaheads", + format!("T;Target:0;0;0/{}a{}/", "(?=".repeat(N), ")".repeat(N)), + ), + ( + "pcre backrefs", + format!("T;Target:0;0;0/(a){}/", "\\1".repeat(N)), + ), + ("pcre flags", format!("T;Target:0;0;0/a/{}", "i".repeat(N))), + ( + "macros", + format!("T;Target:0;0;6162${{1-2}}{}$", "1$".repeat(N)), + ), + ]; + assert_all( + cases + .into_iter() + .map(|(n, t)| load_small("t.ldb", t).map(|e| format!("{n}: {e}"))) + .collect(), + ); +} + +#[test] +fn phishing_and_regex_databases() { + let cases: Vec<(&'static str, &str, String)> = vec![ + ( + "pdb groups", + "t.pdb", + format!("R:.+{}a{}:.+", "(".repeat(N), ")".repeat(N)), + ), + ( + "pdb alternations", + "t.pdb", + format!("R:.+({}):.+", "a|".repeat(N)), + ), + ( + "pdb many", + "t.pdb", + (0..N).map(|i| format!("R:.+x{i}.com:.+\n")).collect(), + ), + ( + "wdb groups", + "t.wdb", + format!("X:.+{}a{}:.+", "(".repeat(N), ")".repeat(N)), + ), + ( + "wdb mixed", + "t.wdb", + format!("M:{}:{}", "(a|".repeat(N), ")".repeat(N)), + ), + ("gdb", "t.gdb", format!("H:{}", "a.".repeat(N))), + ("crb", "t.crb", format!("T;{}", "(".repeat(N))), + ( + "ftm", + "t.ftm", + format!("0:0:{}:Name:CL_TYPE_X:CL_TYPE_ANY", "(".repeat(N)), + ), + ( + "cdb", + "t.cdb", + format!("N:CL_TYPE_ANY:{}:*:*:*:*:*:*", "9".repeat(N)), + ), + ( + "idb", + "t.idb", + format!("IconName:GroupName:{}", "0".repeat(N * 4)), + ), + ("hdb", "t.hdb", format!("{}:1:N", "a".repeat(N * 4))), + ("hsb", "t.hsb", format!("{}:1:N:73", "a".repeat(N * 4))), + ("fp", "t.fp", format!("{}:1:N", "b".repeat(N * 4))), + ("mdb", "t.mdb", format!("1:{}:N", "c".repeat(N * 4))), + ("cfg", "t.cfg", "(".repeat(N * 4)), + ("ign", "t.ign2", "N:".repeat(N)), + ( + "pwdb", + "t.pwdb", + format!("T;Engine:81-255;0;{}", "x".repeat(N * 4)), + ), + ]; + assert_all( + cases + .into_iter() + .map(|(n, file, t)| load_small(file, t).map(|e| format!("{n}: {e}"))) + .collect(), + ); +} + +#[cfg(feature = "yara")] +#[test] +fn yara_sources() { + use exav_core::yara::Compiler; + let rule = |def: &str, cond: &str| { + format!("import \"pe\"\nrule r {{ strings: $a = \"x\" $b = {def} condition: {cond} }}") + }; + let sources: Vec<(&str, String)> = vec![ + ( + "regex groups", + rule(&format!("/{}a{}/", "(".repeat(N), ")".repeat(N)), "$b"), + ), + ( + "regex alternations", + rule(&format!("/{}/", "a|".repeat(N)), "$b"), + ), + ( + "regex repeats", + rule(&format!("/{}/", "a{1,2}".repeat(N)), "$b"), + ), + ( + "hex groups", + rule( + &format!("{{ {}41{} }}", "( 41 | ".repeat(N), " )".repeat(N)), + "$b", + ), + ), + ( + "hex jumps", + rule(&format!("{{ 41 {} 42 }}", "[1-2] 41 ".repeat(N)), "$b"), + ), + ( + "hex alternatives", + rule(&format!("{{ ( {} ) }}", vec!["41"; N].join(" | ")), "$b"), + ), + ( + "condition groups", + rule("\"y\"", &format!("{}true{}", "(".repeat(N), ")".repeat(N))), + ), + ( + "for loops", + rule( + "\"y\"", + &format!("{}true", "for any i in (1..2) : ( ".repeat(N)), + ), + ), + ( + "with", + rule("\"y\"", &format!("{}true", "with a = 1 : ( ".repeat(N))), + ), + ( + "of sets", + rule( + "\"y\"", + &format!("any of ({})", vec!["$a"; N * 2].join(",")), + ), + ), + ( + "many rules", + (0..N) + .map(|i| format!("rule r{i} {{ condition: true }}\n")) + .collect(), + ), + ( + "rule chain", + (0..N) + .map(|i| format!("rule r{i} {{ condition: r{} }}\n", i.saturating_sub(1))) + .collect::() + + "rule r0 { condition: true }", + ), + ("imports", "import \"pe\"\n".repeat(N)), + ( + "tags", + format!("rule r : {} {{ condition: true }}", vec!["t"; N].join(" ")), + ), + ( + "meta", + format!("rule r {{ meta: {} condition: true }}", "a = 1 ".repeat(N)), + ), + ]; + let findings: Vec> = sources + .into_iter() + .map(|(name, src)| { + let started = Instant::now(); + let r = std::thread::Builder::new() + .stack_size(STACK) + .spawn(move || { + let mut c = Compiler::new(); + let rejected = c.add_source_lenient(&src); + if rejected.is_empty() { + let rules = c.build(); + let _ = exav_core::yara::Scanner::new(&rules).scan(b"xxxx"); + } + }) + .unwrap() + .join(); + match r { + Err(_) => Some(format!("yara {name}: overflowed its stack or panicked")), + Ok(()) if started.elapsed() > Duration::from_secs(30) => Some(format!( + "yara {name}: took {:.1} s", + started.elapsed().as_secs_f64() + )), + Ok(()) => None, + } + }) + .collect(); + assert_all(findings); +} + +/// A stored ZIP of one member, `data`. +#[cfg(feature = "all-formats")] +fn stored_zip(data: &[u8]) -> Vec { + use std::io::Write; + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + z.start_file("a.bin", o).unwrap(); + z.write_all(data).unwrap(); + z.finish().unwrap().into_inner() +} + +/// The payload, deflated so that the raw scan of the outer layers cannot see +/// it: it is found only by walking every layer. +#[cfg(feature = "all-formats")] +fn deflated_eicar() -> Vec { + use std::io::Write; + let mut data = exav_core::unpack::eicar().to_vec(); + data.extend(std::iter::repeat_n(b'A', 8192)); + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(&data).unwrap(); + e.finish().unwrap() +} + +/// Containers nested to the depth the user allows (`--max-unpack-depth` takes +/// up to 255) are walked to the payload on a 1 MiB stack. At the test profile's +/// opt-level this holds without the frame fixes in the ZIP and XZ walkers; the +/// frames of a release build are what `scripts/stack-frames.py` audits. +#[cfg(feature = "all-formats")] +#[test] +fn zips_in_zips_to_the_allowed_depth_are_walked_on_a_small_stack() { + let mut layers = deflated_eicar(); + for _ in 0..60 { + layers = stored_zip(&layers); + } + let found = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let db = exav_core::Scanner::builtin(); + let mut opts = ScanOptions::default(); + opts.limits.max_recursion = 255; + matches!( + analyze(&db, &layers, &opts).verdict, + exav_core::Verdict::Infected { .. } + ) + }) + .unwrap() + .join() + .expect("no stack overflow"); + assert!(found, "the payload at the bottom is found"); +} + +/// Script, markup and text with nesting as deep as 400 KB of it makes it, scanned +/// with every content analysis on, on a small stack: the normalizers, the +/// phishing reader, the structured-data counters. +#[test] +fn script_and_markup_nested_as_deep_as_the_file_is_long() { + let n = 100_000; + let rep = |s: &str| s.repeat(n); + let html = |inner: String| format!("{inner}"); + let script = |inner: String| format!(""); + let cases: Vec<(&str, String)> = vec![ + ( + "js parentheses", + script(format!("{}1{}", rep("("), rep(")"))), + ), + ("js brackets", script(format!("{}1{}", rep("["), rep("]")))), + ("js braces", script(format!("{}1{}", rep("{"), rep("}")))), + ( + "js functions", + script(format!("{}1{}", rep("function f(){"), rep("}"))), + ), + ("js ternaries", script(format!("x={}1", rep("a?b:")))), + ("js signs", script(format!("x={}1", rep("-")))), + ("js negations", script(format!("x={}1", rep("!")))), + ( + "js concatenations", + script(format!("x=\"a\"{}", rep("+\"b\""))), + ), + ("js eval", script(format!("{}1{}", rep("eval("), rep(")")))), + ( + "js unescape", + script(format!("{}\"%41\"{}", rep("unescape("), rep(")"))), + ), + ( + "js atob", + script(format!("{}\"QQ==\"{}", rep("atob("), rep(")"))), + ), + ( + "js from char code", + script(format!("{}65{}", rep("String.fromCharCode("), rep(")"))), + ), + ("js arrays of arrays", script(format!("x={}", rep("[1,")))), + ( + "js objects", + script(format!("x={}1{}", rep("{a:"), rep("}"))), + ), + ("js ifs", script(format!("{}x=1", rep("if(a)")))), + ("js comments", script(rep("/*"))), + ("js template strings", script(format!("x={}", rep("`${")))), + ("js regexes", script(format!("x={}", rep("/(/")))), + ( + "js escapes", + script(format!("x=\"{}\"", rep("\\x41\\u0042"))), + ), + ("html divs", html(rep("
"))), + ("html tables", html(rep("
"))), + ( + "html anchors", + html(rep("http://b.example.com")), + ), + ("html comments", html(rep("", " "); + let mut rest = t.as_str(); + while let Some(open) = rest.find('{') { + let Some(close) = rest[open..].find('}').map(|c| open + c) else { + break; + }; + let decls = declarations(&rest[open + 1..close]); + for sel in rest[..open].split(',') { + let sel = sel.trim().to_ascii_lowercase(); + if !sel.is_empty() && !sel.starts_with('@') && self.rules.len() < 1 << 16 { + self.index + .entry(sel.clone()) + .or_default() + .push(self.rules.len()); + self.rules.push((sel, decls.clone())); + } + } + rest = &rest[close + 1..]; + } + } + + /// Declarations for an element of `tag` and `classes`: the element's + /// rules, then its classes', each in source order. A class naming + /// `mso-style-parent` gets that style's declarations under its own. + pub fn lookup(&self, tag: &str, classes: &[&str], out: &mut Vec<(String, String)>) { + for &i in self.matching(tag) { + if !self.hand_out(i, out) { + return; + } + } + for cls in classes { + let cls = cls.to_ascii_lowercase(); + let dot = format!(".{cls}"); + let tagged = format!("{tag}.{cls}"); + let mut hits: Vec = self + .matching(&dot) + .iter() + .chain(self.matching(&tagged)) + .copied() + .collect(); + hits.sort_unstable(); + for i in hits { + let decls = &self.rules[i].1; + if let Some(parent) = decls + .iter() + .find(|d| d.0 == "mso-style-parent") + .map(|d| d.1.trim().to_ascii_lowercase()) + && parent != cls + { + for &j in self.matching(&format!(".{parent}")) { + if !self.hand_out(j, out) { + return; + } + } + } + if !self.hand_out(i, out) { + return; + } + } + } + } + + /// The declarations of every rule, for a reader that wants to know what + /// the sheet says anywhere. + pub fn declarations(&self) -> impl Iterator { + self.rules.iter().flat_map(|r| r.1.iter()) + } + + fn matching(&self, selector: &str) -> &[usize] { + if self.work.get() > MAX_HANDED_OUT { + return &[]; + } + self.index.get(selector).map_or(&[], Vec::as_slice) + } + + /// Adds the declarations of rule `i` to `out`, unless the sheet has handed + /// out its share. + fn hand_out(&self, i: usize, out: &mut Vec<(String, String)>) -> bool { + let decls = &self.rules[i].1; + let used = self.work.get().saturating_add(decls.len() as u64 + 1); + self.work.set(used); + if used > MAX_HANDED_OUT { + return false; + } + out.extend(decls.iter().cloned()); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tables_are_sorted() { + for t in [NAMED, SYSTEM] { + assert!(t.array_windows().all(|[a, b]| a.0 < b.0)); + } + } + + #[test] + fn colors() { + assert_eq!(color("#FF8000"), Some(Some(0xFF8000))); + assert_eq!(color("#f80"), Some(Some(0xFF8800))); + assert_eq!(color("WindowText"), Some(Some(0))); + assert_eq!(color("Silver"), Some(Some(0xC0C0C0))); + assert_eq!(color("rgb(255, 0, 50%)"), Some(Some(0xFF0080))); + assert_eq!(color("Automatic"), Some(None)); + assert_eq!(color("nonsense"), None); + } + + fn declarations_of(sheet: &Sheet, tag: &str, classes: &[&str]) -> Vec<(String, String)> { + let mut out = vec![]; + sheet.lookup(tag, classes, &mut out); + out + } + + #[test] + fn rules_are_found_in_source_order_with_a_parent_style_first() { + let mut s = Sheet::default(); + s.add("td.x{a:1} .x{b:2; mso-style-parent:base} td{c:3} .base{d:4} .x{e:5} p.x{f:6}"); + let names = |tag, classes: &[&str]| { + declarations_of(&s, tag, classes) + .into_iter() + .map(|d| d.0) + .collect::>() + .join(" ") + }; + assert_eq!(names("td", &["x"]), "c a d b mso-style-parent e"); + assert_eq!(names("p", &["x"]), "d b mso-style-parent e f"); + assert_eq!(names("span", &["base", "X"]), "d d b mso-style-parent e"); + } + + /// What lookups return in all is bounded, so that a long style sheet over + /// many elements is not a cost of one times the other. + #[test] + fn the_declarations_a_sheet_returns_are_bounded() { + let mut s = Sheet::default(); + let big: String = (0..256).map(|i| format!("p{i}:v;")).collect(); + s.add(&format!(".a{{{big}}}")); + let total: usize = (0..100_000) + .map(|_| declarations_of(&s, "p", &["a"]).len()) + .sum(); + assert!(total > 1 << 23, "{total}"); + assert!(total <= (1 << 24) + 512, "{total}"); + } + + #[test] + fn lists_and_lengths() { + assert_eq!( + declarations("mso-number-format:\"0\\.00;\\(0\\)\"; color:red ;;bad"), + [ + ( + "mso-number-format".to_string(), + "\"0\\.00;\\(0\\)\"".to_string() + ), + ("color".to_string(), "red".to_string()) + ] + ); + assert_eq!(length_pt("20px", "pt"), Some(15.0)); + assert_eq!(length_pt("12", "pt"), Some(12.0)); + } +} diff --git a/crates/exav-render/src/formats/office/common/escher.rs b/crates/exav-render/src/formats/office/common/escher.rs new file mode 100644 index 00000000..5d1904b2 --- /dev/null +++ b/crates/exav-render/src/formats/office/common/escher.rs @@ -0,0 +1,633 @@ +//! OfficeArt records ([MS-ODRAW] 2.2): an 8-byte header (version and +//! instance, type, length) and a body; containers hold records. Shape +//! property tables, the shape tree of a drawing and the picture store. + +use super::bytes::Cur; +use std::io::Read; + +pub const DGG_CONTAINER: u16 = 0xF000; +pub const BSTORE_CONTAINER: u16 = 0xF001; +pub const DG_CONTAINER: u16 = 0xF002; +pub const SPGR_CONTAINER: u16 = 0xF003; +pub const SP_CONTAINER: u16 = 0xF004; +pub const BSE: u16 = 0xF007; +pub const FSPGR: u16 = 0xF009; +pub const FSP: u16 = 0xF00A; +pub const FOPT: u16 = 0xF00B; +pub const CLIENT_TEXTBOX: u16 = 0xF00D; +pub const CHILD_ANCHOR: u16 = 0xF00F; +pub const CLIENT_ANCHOR: u16 = 0xF010; +pub const CLIENT_DATA: u16 = 0xF011; +pub const SECONDARY_FOPT: u16 = 0xF121; +pub const TERTIARY_FOPT: u16 = 0xF122; + +pub const F_GROUP: u32 = 0x1; +pub const F_CHILD: u32 = 0x2; +pub const F_PATRIARCH: u32 = 0x4; +pub const F_DELETED: u32 = 0x8; +pub const F_OLE: u32 = 0x10; +pub const F_HAVE_MASTER: u32 = 0x20; +pub const F_FLIP_H: u32 = 0x40; +pub const F_FLIP_V: u32 = 0x80; +pub const F_CONNECTOR: u32 = 0x100; +pub const F_HAVE_ANCHOR: u32 = 0x200; +pub const F_BACKGROUND: u32 = 0x400; +pub const F_HAVE_SPT: u32 = 0x800; + +#[derive(Clone, Copy, Debug)] +pub struct Rec<'a> { + pub ver: u8, + pub inst: u16, + pub typ: u16, + pub data: &'a [u8], + /// Offset of the header in the slice it was read from. + pub at: usize, +} + +impl<'a> Rec<'a> { + pub fn children(&self) -> Children<'a> { + Children::new(self.data) + } + + pub fn child(&self, typ: u16) -> Option> { + self.children().find(|r| r.typ == typ) + } + + pub fn cur(&self) -> Cur<'a> { + Cur::new(self.data) + } +} + +/// The record at `at` in `d`; a length past the end is cut to what is there. +pub fn read_at(d: &[u8], at: usize) -> Option> { + let mut c = Cur::at(d, at); + if c.left() < 8 { + return None; + } + let vi = c.u16(); + let typ = c.u16(); + let len = c.u32() as usize; + let start = at + 8; + let end = start.saturating_add(len).min(d.len()); + Some(Rec { + ver: (vi & 0xF) as u8, + inst: vi >> 4, + typ, + data: &d[start..end], + at, + }) +} + +/// The records one after another in a container's body. +pub struct Children<'a> { + d: &'a [u8], + p: usize, + n: usize, +} + +impl<'a> Children<'a> { + pub fn new(d: &'a [u8]) -> Self { + Children { d, p: 0, n: 0 } + } +} + +const MAX_CHILDREN: usize = 1 << 18; + +impl<'a> Iterator for Children<'a> { + type Item = Rec<'a>; + + fn next(&mut self) -> Option> { + if self.n >= MAX_CHILDREN { + return None; + } + let r = read_at(self.d, self.p)?; + self.p = self.p + 8 + r.data.len(); + self.n += 1; + Some(r) + } +} + +/// One entry of a property table: id (without its flags), value, and for a +/// complex property its data. +#[derive(Clone, Debug)] +pub struct Prop<'a> { + pub id: u16, + pub value: u32, + pub complex: Option<&'a [u8]>, +} + +#[derive(Clone, Debug, Default)] +pub struct Props<'a>(pub Vec>); + +impl<'a> Props<'a> { + pub fn get(&self, id: u16) -> Option { + self.0.iter().rev().find(|p| p.id == id).map(|p| p.value) + } + + pub fn int(&self, id: u16) -> Option { + self.get(id).map(|v| v as i32) + } + + pub fn complex(&self, id: u16) -> Option<&'a [u8]> { + self.0 + .iter() + .rev() + .find(|p| p.id == id) + .and_then(|p| p.complex) + } + + /// A boolean of a set (its property id `set`): bit `bit` (0 for the last + /// of the set's 16 values) when its "use" bit (bit + 16) is set. + pub fn flag(&self, set: u16, bit: u32) -> Option { + let v = self.get(set)?; + (v & (1 << (bit + 16)) != 0).then_some(v & (1 << bit) != 0) + } + + /// `o`'s properties over these. + pub fn overlay(&mut self, o: &Props<'a>) { + for p in &o.0 { + if let Some(q) = self.0.iter_mut().find(|q| q.id == p.id) { + // Boolean sets merge bit by bit: each "use" bit of `o` wins. + if p.id & 0x3F == 0x3F && p.complex.is_none() { + let used = p.value >> 16; + q.value = (q.value & !(used | used << 16)) | (p.value & (used | used << 16)); + } else { + *q = p.clone(); + } + } else { + self.0.push(p.clone()); + } + } + } +} + +/// Reads a property table: `n` entries, then the complex data in order. +pub fn read_props(d: &[u8], n: u16) -> Props<'_> { + let mut c = Cur::new(d); + let mut entries = vec![]; + for _ in 0..n.min(1024) { + let opid = c.u16(); + let value = c.u32(); + if c.short { + break; + } + entries.push((opid, value)); + } + let mut p = c.p; + let mut out = Vec::with_capacity(entries.len()); + for (opid, value) in entries { + let id = opid & 0x3FFF; + if opid & 0x8000 != 0 { + let len = value as usize; + let end = p.saturating_add(len).min(d.len()); + out.push(Prop { + id, + value, + complex: Some(&d[p.min(end)..end]), + }); + p = end; + } else { + out.push(Prop { + id, + value, + complex: None, + }); + } + } + Props(out) +} + +/// The elements of an IMsoArray ([MS-ODRAW] 2.2.51). Element size 0xFFF0 +/// means 4-byte elements holding two 16-bit halves. +pub fn array(d: &[u8]) -> Vec<&[u8]> { + let mut c = Cur::new(d); + let n = c.u16() as usize; + let _alloc = c.u16(); + let mut size = c.u16() as usize; + if size == 0xFFF0 { + size = 4; + } + if size == 0 || c.short { + return vec![]; + } + let body = &d[6.min(d.len())..]; + body.chunks_exact(size).take(n.min(1 << 20)).collect() +} + +#[derive(Clone, Debug, Default)] +pub struct OShape<'a> { + pub spt: u16, + pub spid: u32, + pub flags: u32, + pub props: Props<'a>, + /// Left, top, right, bottom of a ClientAnchor. + pub anchor: Option<[i32; 4]>, + /// Left, top, right, bottom in the group's coordinates. + pub child: Option<[i32; 4]>, + /// A group's own coordinate space. + pub group: Option<[i32; 4]>, + pub client_data: Option>, + pub textbox: Option>, + pub children: Vec>, +} + +impl OShape<'_> { + pub fn is_group(&self) -> bool { + self.flags & F_GROUP != 0 + } +} + +pub fn sp_container<'a>(r: &Rec<'a>) -> OShape<'a> { + let mut s = OShape::default(); + for k in r.children() { + let mut c = k.cur(); + match k.typ { + FSP => { + s.spt = k.inst; + s.spid = c.u32(); + s.flags = c.u32(); + } + FSPGR => s.group = Some([c.i32(), c.i32(), c.i32(), c.i32()]), + CHILD_ANCHOR => s.child = Some([c.i32(), c.i32(), c.i32(), c.i32()]), + CLIENT_ANCHOR => { + s.anchor = if k.data.len() >= 16 { + let (t, l, r, b) = (c.i32(), c.i32(), c.i32(), c.i32()); + Some([l, t, r, b]) + } else if k.data.len() >= 8 { + let (t, l, r, b) = (c.i16(), c.i16(), c.i16(), c.i16()); + Some([l.into(), t.into(), r.into(), b.into()]) + } else { + None + }; + } + FOPT | SECONDARY_FOPT | TERTIARY_FOPT => { + let p = read_props(k.data, k.inst); + s.props.overlay(&p); + } + CLIENT_DATA => s.client_data = Some(k), + CLIENT_TEXTBOX => s.textbox = Some(k), + _ => {} + } + } + s +} + +pub fn group<'a>(r: &Rec<'a>, depth: u32) -> OShape<'a> { + let mut kids = r.children(); + let mut g = match kids.next() { + Some(k) if k.typ == SP_CONTAINER => sp_container(&k), + _ => OShape { + flags: F_GROUP, + ..OShape::default() + }, + }; + g.flags |= F_GROUP; + for k in kids { + match k.typ { + SP_CONTAINER => g.children.push(sp_container(&k)), + SPGR_CONTAINER if depth < 32 => g.children.push(group(&k, depth + 1)), + _ => {} + } + if g.children.len() >= 1 << 16 { + break; + } + } + g +} + +/// A drawing's shapes (the top group's members) and background shape. +pub struct Drawing<'a> { + pub shapes: Vec>, + pub background: Option>, +} + +/// Reads an OfficeArtDgContainer. +pub fn drawing<'a>(dg: &Rec<'a>) -> Drawing<'a> { + let mut out = Drawing { + shapes: vec![], + background: None, + }; + for k in dg.children() { + match k.typ { + SPGR_CONTAINER => { + let g = group(&k, 0); + out.shapes.extend(g.children); + } + SP_CONTAINER => { + let s = sp_container(&k); + if s.flags & F_BACKGROUND != 0 { + out.background = Some(s); + } + } + _ => {} + } + } + out +} + +pub struct Blip { + pub ext: &'static str, + pub data: Vec, +} + +/// Metafiles past this size inflated are dropped. +const MAX_METAFILE: u64 = 64 << 20; + +/// The picture records of the store (OfficeArtDggContainer's BStore), in +/// order: a shape's `pib` is 1-based in it. A BSE whose picture is not in +/// its record is read from `delay` (the stream the format keeps them in) at +/// the BSE's offset. +pub fn blip_records<'a>(dgg: &Rec<'a>, delay: &'a [u8]) -> Vec>> { + let mut out = vec![]; + let Some(store) = dgg.child(BSTORE_CONTAINER) else { + return out; + }; + for bse in store.children() { + if out.len() >= 1 << 16 { + break; + } + if bse.typ != BSE { + // A blip itself, where a store lists them directly. + out.push(matches!(bse.typ, 0xF01A..=0xF02A).then_some(bse)); + continue; + } + let d = bse.data; + let size = Cur::at(d, 20).u32() as usize; + let at = Cur::at(d, 28).u32() as usize; + let name_len = d.get(33).copied().unwrap_or(0) as usize; + let inline = d.get(36 + name_len..).filter(|r| r.len() >= 8); + let rec = match inline { + Some(rest) => read_at(rest, 0), + None if size > 0 => read_at(delay, at), + None => None, + }; + out.push(rec); + } + out +} + +/// A picture record's image: its bytes, with metafiles inflated. +pub fn blip(r: &Rec) -> Option { + // An instance with its low bit set has a second 16-byte id. + let two = |base: u16| if r.inst == base + 1 { 32 } else { 16 }; + let (ext, skip, meta) = match r.typ { + 0xF01A => ("emf", two(0x3D4), true), + 0xF01B => ("wmf", two(0x216), true), + 0xF01C => ("pict", two(0x542), true), + 0xF01D | 0xF02A => ( + "jpeg", + if r.inst == 0x46B || r.inst == 0x6E3 { + 33 + } else { + 17 + }, + false, + ), + 0xF01E => ("png", two(0x6E0) + 1, false), + 0xF01F => ("bmp", two(0x7A8) + 1, false), + 0xF029 => ("tiff", two(0x6E4) + 1, false), + _ => return None, + }; + let d = r.data; + if !meta { + let data = d.get(skip..)?.to_vec(); + if ext == "bmp" { + return Some(Blip { + ext, + data: super::image::dib_to_bmp(&data), + }); + } + return Some(Blip { ext, data }); + } + let hdr = d.get(skip..skip + 34)?; + let body = d.get(skip + 34..)?; + let data = if hdr[32] == 0 { + let mut out = Vec::new(); + flate2::read::ZlibDecoder::new(body) + .take(MAX_METAFILE) + .read_to_end(&mut out) + .ok()?; + out + } else { + body.to_vec() + }; + let data = if ext == "wmf" && !data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) { + placeable_wmf(&data, hdr) + } else { + data + }; + Some(Blip { ext, data }) +} + +/// A WMF without its placeable header gets one, from the blip's bounds. +fn placeable_wmf(data: &[u8], hdr: &[u8]) -> Vec { + let mut c = Cur::at(hdr, 4); + let mut h = vec![0xD7, 0xCD, 0xC6, 0x9A, 0, 0]; + for _ in 0..4 { + h.extend_from_slice(&(c.i32() as i16).to_le_bytes()); + } + h.extend_from_slice(&1440u16.to_le_bytes()); + h.extend_from_slice(&[0, 0, 0, 0]); + let mut sum = 0u16; + for k in 0..10 { + sum ^= u16::from_le_bytes([h[2 * k], h[2 * k + 1]]); + } + h.extend_from_slice(&sum.to_le_bytes()); + h.extend_from_slice(data); + h +} + +/// Records written for tests. +#[cfg(any(test, feature = "office-test-util"))] +pub mod build { + pub fn rec(ver: u8, inst: u16, typ: u16, body: &[u8]) -> Vec { + let mut v = Vec::with_capacity(body.len() + 8); + v.extend_from_slice(&(u16::from(ver & 0xF) | inst << 4).to_le_bytes()); + v.extend_from_slice(&typ.to_le_bytes()); + v.extend_from_slice(&(body.len() as u32).to_le_bytes()); + v.extend_from_slice(body); + v + } + + pub fn container(typ: u16, inst: u16, children: &[Vec]) -> Vec { + rec(0xF, inst, typ, &children.concat()) + } +} + +#[cfg(test)] +mod tests { + use super::build::*; + use super::*; + + fn fopte(id: u16, v: u32) -> Vec { + let mut b = id.to_le_bytes().to_vec(); + b.extend_from_slice(&v.to_le_bytes()); + b + } + + #[test] + fn children_and_truncation() { + let c = container( + 0x03E8, + 0, + &[rec(0, 1, 0x03E9, &[1, 2]), rec(0, 0, 0x0FA0, b"hi")], + ); + let r = read_at(&c, 0).unwrap(); + let kids: Vec<_> = r + .children() + .map(|k| (k.typ, k.inst, k.data.to_vec())) + .collect(); + assert_eq!( + kids, + vec![(0x03E9, 1, vec![1, 2]), (0x0FA0, 0, b"hi".to_vec())] + ); + let r = read_at(&c[..c.len() - 1], 0).unwrap(); + assert_eq!(r.children().last().unwrap().data, b"h"); + assert!(read_at(&c, c.len() - 4).is_none()); + } + + #[test] + fn property_tables_with_complex_data() { + let mut body = fopte(0x0181, 0x00FF_0000); + body.extend(fopte(0x8145, 6 + 8)); + body.extend(fopte(0x01BF, 0x0010_0010)); + body.extend(fopte(0xC380, 4)); + body.extend([2, 0, 2, 0, 4, 0, 1, 0, 2, 0, 3, 0, 4, 0]); + body.extend(b"N\0a\0"); + let p = read_props(&body, 4); + assert_eq!(p.get(0x0181), Some(0x00FF_0000)); + let pts = array(p.complex(0x0145).unwrap()); + assert_eq!(pts, vec![&[1, 0, 2, 0][..], &[3, 0, 4, 0][..]]); + assert_eq!(p.complex(0x0380), Some(&b"N\0a\0"[..])); + assert_eq!(p.flag(0x01BF, 4), Some(true)); + assert_eq!(p.flag(0x01BF, 3), None); + // Element size 0xFFF0: four bytes each. + let packed = [1, 0, 1, 0, 0xF0, 0xFF, 9, 0, 8, 0]; + assert_eq!(array(&packed), vec![&[9, 0, 8, 0][..]]); + // No element size, or fewer bytes than the header. + assert!(array(&[1, 0, 1, 0, 0, 0]).is_empty()); + assert!(array(&[1]).is_empty()); + } + + #[test] + fn groups_and_anchors() { + let fsp = |spt: u16, flags: u32| { + let mut b = 1024u32.to_le_bytes().to_vec(); + b.extend_from_slice(&flags.to_le_bytes()); + rec(2, spt, FSP, &b) + }; + let anchor = rec(0, 0, CLIENT_ANCHOR, &[10, 0, 20, 0, 120, 0, 60, 0]); + let g = container( + SPGR_CONTAINER, + 0, + &[ + container(SP_CONTAINER, 0, &[fsp(0, 4), rec(1, 0, FSPGR, &[0; 16])]), + container(SP_CONTAINER, 0, &[fsp(3, 0), anchor]), + ], + ); + let top = group(&read_at(&g, 0).unwrap(), 0); + assert!(top.is_group()); + assert_eq!(top.children.len(), 1); + let dg = container(DG_CONTAINER, 0, &[g]); + let d = drawing(&read_at(&dg, 0).unwrap()); + assert_eq!(d.shapes.len(), 1); + assert_eq!(d.shapes[0].spt, 3); + assert_eq!(d.shapes[0].anchor, Some([20, 10, 120, 60])); + } + + #[test] + fn boolean_sets_merge_bit_by_bit() { + // Bits 4 (used, set) of the first; of the second, bit 3 (used, set) + // and bit 4 (used, clear). + let (da, db) = (fopte(0x01BF, 0x0010_0010), fopte(0x01BF, 0x0008_0008)); + let mut a = read_props(&da, 1); + a.overlay(&read_props(&db, 1)); + assert_eq!(a.flag(0x01BF, 3), Some(true)); + // Bit 4 is not used by the second set: the first's stays. + assert_eq!(a.flag(0x01BF, 4), Some(true)); + // A used bit that is clear turns it off. + let dc = fopte(0x01BF, 0x0010_0000); + a.overlay(&read_props(&dc, 1)); + assert_eq!(a.flag(0x01BF, 4), Some(false)); + assert_eq!(a.flag(0x01BF, 3), Some(true)); + // Other properties are replaced. + let (da, db) = (fopte(0x0181, 1), fopte(0x0181, 2)); + let mut a = read_props(&da, 1); + a.overlay(&read_props(&db, 1)); + assert_eq!(a.get(0x0181), Some(2)); + } + + #[test] + fn client_anchors_come_in_two_sizes() { + let mut long = vec![]; + for v in [20i32, 10, 120, 60] { + long.extend_from_slice(&v.to_le_bytes()); + } + let c = container(SP_CONTAINER, 0, &[rec(0, 0, CLIENT_ANCHOR, &long)]); + let s = sp_container(&read_at(&c, 0).unwrap()); + // Top, left, right, bottom in the record; left, top, ... in the shape. + assert_eq!(s.anchor, Some([10, 20, 120, 60])); + let c = container(SP_CONTAINER, 0, &[rec(0, 0, CLIENT_ANCHOR, &[1, 0, 2, 0])]); + assert_eq!(sp_container(&read_at(&c, 0).unwrap()).anchor, None); + } + + #[test] + fn metafile_blips_are_inflated_and_wmf_gets_a_header() { + use std::io::Write; + let mut z = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default()); + z.write_all(b"META").unwrap(); + let packed = z.finish().unwrap(); + // Metafile header: size, bounds (0, 0, 1000, 500), ..., compression + // (0: deflate, 0xFE: none) at 32, filter. + let header = |compression: u8| { + let mut h = vec![0u8; 34]; + h[12..16].copy_from_slice(&1000i32.to_le_bytes()); + h[16..20].copy_from_slice(&500i32.to_le_bytes()); + h[32] = compression; + h[33] = 0xFE; + h + }; + let record = |typ: u16, inst: u16, uid: usize, compression: u8, body: &[u8]| { + let mut d = vec![0u8; uid]; + d.extend(header(compression)); + d.extend_from_slice(body); + rec(0, inst, typ, &d) + }; + // An EMF with one 16-byte id, deflated. + let b = blip(&read_at(&record(0xF01A, 0x3D4, 16, 0, &packed), 0).unwrap()).unwrap(); + assert_eq!((b.ext, b.data.as_slice()), ("emf", &b"META"[..])); + // The odd instance has two ids, 32 bytes; stored as it is. + let b = blip(&read_at(&record(0xF01A, 0x3D5, 32, 0xFE, b"META"), 0).unwrap()).unwrap(); + assert_eq!(b.data, b"META"); + // A WMF without the placeable header gets one with the bounds. + let b = blip(&read_at(&record(0xF01B, 0x216, 16, 0xFE, b"META"), 0).unwrap()).unwrap(); + assert_eq!(b.ext, "wmf"); + assert!(b.data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A, 0, 0])); + assert_eq!(&b.data[10..14], [0xE8, 3, 0xF4, 1]); + assert_eq!(&b.data[14..16], 1440u16.to_le_bytes()); + assert!(b.data.ends_with(b"META")); + // Not a picture record. + assert!(blip(&read_at(&rec(0, 0, 0xF000, &[0; 40]), 0).unwrap()).is_none()); + } + + #[test] + fn blips_from_the_store() { + // A PNG blip: uid, tag, then the image. + let mut png = vec![0u8; 16]; + png.push(0xFF); + png.extend_from_slice(b"\x89PNG\r\n\x1a\n"); + let blip_rec = rec(0, 0x6E0, 0xF01E, &png); + // A BSE with the blip inline. + let mut bse = vec![0u8; 36]; + bse[1] = 6; + bse.extend_from_slice(&blip_rec); + let store = container(BSTORE_CONTAINER, 1, &[rec(2, 6, BSE, &bse)]); + let dgg = container(DGG_CONTAINER, 0, &[store]); + let r = read_at(&dgg, 0).unwrap(); + let blips = blip_records(&r, &[]); + assert_eq!(blips.len(), 1); + let b = blip(&blips[0].unwrap()).unwrap(); + assert_eq!(b.ext, "png"); + assert!(b.data.starts_with(b"\x89PNG")); + } +} diff --git a/crates/exav-render/src/formats/office/common/image.rs b/crates/exav-render/src/formats/office/common/image.rs new file mode 100644 index 00000000..ab2203fb --- /dev/null +++ b/crates/exav-render/src/formats/office/common/image.rs @@ -0,0 +1,284 @@ +//! Pictures by their first bytes: the format, the size in pixels, and the +//! natural size in EMU where the file says it (resolution of a bitmap, +//! frame of a metafile). + +use super::bytes::{Cur, u16_at, u32_at}; + +/// The extension of a picture's format: png, jpeg, gif, bmp, tiff, emf, +/// wmf or svg. +pub fn ext(d: &[u8]) -> Option<&'static str> { + Some(match d { + [0x89, b'P', b'N', b'G', ..] => "png", + [0xFF, 0xD8, ..] => "jpeg", + [b'G', b'I', b'F', ..] => "gif", + [b'B', b'M', ..] => "bmp", + [b'I', b'I', 42, 0, ..] | [b'M', b'M', 0, 42, ..] => "tiff", + [1, 0, 0, 0, _, _, _, _, ..] if d.len() > 44 && &d[40..44] == b" EMF" => "emf", + [0xD7, 0xCD, 0xC6, 0x9A, ..] | [1, 0, 9, 0, ..] | [2, 0, 9, 0, ..] => "wmf", + _ => { + let head = String::from_utf8_lossy(&d[..d.len().min(512)]); + if head.contains(" Option<(u32, u32)> { + match ext(d)? { + "png" if d.len() >= 24 => { + let w = be32(d, 16); + let h = be32(d, 20); + Some((w, h)) + } + "gif" if d.len() >= 10 => Some((u32::from(u16_at(d, 6)), u32::from(u16_at(d, 8)))), + "bmp" if d.len() >= 26 => { + let hdr = u32_at(d, 14); + if hdr == 12 { + Some((u32::from(u16_at(d, 18)), u32::from(u16_at(d, 20)))) + } else { + let w = u32_at(d, 18) as i32; + let h = u32_at(d, 22) as i32; + Some((w.unsigned_abs(), h.unsigned_abs())) + } + } + "jpeg" => jpeg_size(d).map(|(w, h, _)| (w, h)), + _ => None, + } +} + +fn be32(d: &[u8], p: usize) -> u32 { + match d.get(p..p + 4) { + Some(&[a, b, c, e]) => u32::from_be_bytes([a, b, c, e]), + _ => 0, + } +} + +fn be16(d: &[u8], p: usize) -> u16 { + match d.get(p..p + 2) { + Some(&[a, b]) => u16::from_be_bytes([a, b]), + _ => 0, + } +} + +/// Dots per inch, horizontally and vertically. +type Dpi = (f64, f64); + +/// Width, height and the JFIF density of a JPEG. +fn jpeg_size(d: &[u8]) -> Option<(u32, u32, Option)> { + let mut p = 2usize; + let mut dpi = None; + let mut n = 0; + while p + 4 <= d.len() && n < 4096 { + n += 1; + if d[p] != 0xFF { + p += 1; + continue; + } + let m = d[p + 1]; + if m == 0xFF { + p += 1; + continue; + } + if (0xD0..=0xD9).contains(&m) || m == 0x01 { + p += 2; + continue; + } + let len = usize::from(be16(d, p + 2)); + if m == 0xE0 && d.get(p + 4..p + 9) == Some(b"JFIF\0") { + let unit = d.get(p + 11).copied().unwrap_or(0); + let (x, y) = (f64::from(be16(d, p + 12)), f64::from(be16(d, p + 14))); + if x > 0.0 && y > 0.0 { + dpi = match unit { + 1 => Some((x, y)), + 2 => Some((x * 2.54, y * 2.54)), + _ => None, + }; + } + } + if (0xC0..=0xCF).contains(&m) && !matches!(m, 0xC4 | 0xC8 | 0xCC) { + let h = u32::from(be16(d, p + 5)); + let w = u32::from(be16(d, p + 7)); + return Some((w, h, dpi)); + } + if len < 2 { + return None; + } + p += 2 + len; + } + None +} + +/// The resolution of a PNG (pHYs chunk), dots per inch. +fn png_dpi(d: &[u8]) -> Option<(f64, f64)> { + let mut p = 8usize; + let mut n = 0; + while p + 12 <= d.len() && n < 256 { + n += 1; + let len = be32(d, p) as usize; + let kind = d.get(p + 4..p + 8)?; + if kind == b"pHYs" && len >= 9 && d.get(p + 16) == Some(&1) { + // Dots per metre are whole numbers: 2835 is 72 dpi, not 72.009. + let snap = |v: f64| { + if (v - v.round()).abs() < 0.05 { + v.round() + } else { + v + } + }; + let x = snap(f64::from(be32(d, p + 8)) * 0.0254); + let y = snap(f64::from(be32(d, p + 12)) * 0.0254); + return (x > 0.0 && y > 0.0).then_some((x, y)); + } + if kind == b"IDAT" { + break; + } + p = p.checked_add(12 + len)?; + } + None +} + +/// The picture's natural size in EMU, when it can be told. +pub fn natural_emu(d: &[u8]) -> Option<(i64, i64)> { + const EMU_IN: f64 = 914_400.0; + match ext(d)? { + "emf" => { + // Header: bounds (device), frame in 0.01 mm at 24..40. + let mut c = Cur::at(d, 24); + let (l, t, r, b) = (c.i32(), c.i32(), c.i32(), c.i32()); + let (w, h) = (i64::from(r) - i64::from(l), i64::from(b) - i64::from(t)); + (w > 0 && h > 0).then_some((w * 360, h * 360)) + } + "wmf" if d.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) => { + let mut c = Cur::at(d, 6); + let (l, t, r, b) = (c.i16(), c.i16(), c.i16(), c.i16()); + let inch = f64::from(c.u16()).max(1.0); + let (w, h) = (f64::from(r) - f64::from(l), f64::from(b) - f64::from(t)); + (w > 0.0 && h > 0.0).then(|| ((w / inch * EMU_IN) as i64, (h / inch * EMU_IN) as i64)) + } + "png" => { + let (w, h) = pixel_size(d)?; + let (dx, dy) = png_dpi(d).unwrap_or((96.0, 96.0)); + Some(( + (f64::from(w) / dx * EMU_IN) as i64, + (f64::from(h) / dy * EMU_IN) as i64, + )) + } + "jpeg" => { + let (w, h, dpi) = jpeg_size(d)?; + let (dx, dy) = dpi.unwrap_or((96.0, 96.0)); + Some(( + (f64::from(w) / dx * EMU_IN) as i64, + (f64::from(h) / dy * EMU_IN) as i64, + )) + } + "gif" | "bmp" => { + let (w, h) = pixel_size(d)?; + Some((i64::from(w) * 9525, i64::from(h) * 9525)) + } + _ => None, + } +} + +/// A device-independent bitmap (BITMAPINFO and pixels) with the file header +/// it lacks. +pub fn dib_to_bmp(dib: &[u8]) -> Vec { + let hsize = u32_at(dib, 0) as usize; + let bits = u32::from(u16_at(dib, 14)); + let used = u32_at(dib, 32); + let colors = if bits <= 8 && bits > 0 { + if used > 0 { used.min(256) } else { 1 << bits } + } else { + used.min(256) + }; + let off = 14 + hsize.min(1 << 16) + colors as usize * 4; + let mut out = Vec::with_capacity(dib.len() + 14); + out.extend_from_slice(b"BM"); + out.extend_from_slice(&((dib.len() + 14) as u32).to_le_bytes()); + out.extend_from_slice(&[0, 0, 0, 0]); + out.extend_from_slice(&(off as u32).to_le_bytes()); + out.extend_from_slice(dib); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png(w: u32, h: u32, phys: Option<(u32, u32)>) -> Vec { + let mut v = b"\x89PNG\r\n\x1a\n".to_vec(); + v.extend_from_slice(&13u32.to_be_bytes()); + v.extend_from_slice(b"IHDR"); + v.extend_from_slice(&w.to_be_bytes()); + v.extend_from_slice(&h.to_be_bytes()); + v.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]); + if let Some((x, y)) = phys { + v.extend_from_slice(&9u32.to_be_bytes()); + v.extend_from_slice(b"pHYs"); + v.extend_from_slice(&x.to_be_bytes()); + v.extend_from_slice(&y.to_be_bytes()); + v.push(1); + v.extend_from_slice(&[0; 4]); + } + v.extend_from_slice(&[0, 0, 0, 0]); + v.extend_from_slice(b"IDAT"); + v + } + + #[test] + fn formats_and_sizes() { + assert_eq!(ext(b"\x89PNG\r\n"), Some("png")); + assert_eq!(ext(&[0xFF, 0xD8, 0xFF]), Some("jpeg")); + assert_eq!(ext(b""), Some("svg")); + assert_eq!(ext(b"VCLMTF"), None); + let p = png(100, 50, None); + assert_eq!(pixel_size(&p), Some((100, 50))); + assert_eq!(natural_emu(&p), Some((952_500, 476_250))); + // 2835 dots per metre is 72 dpi. + let p = png(72, 36, Some((2835, 2835))); + assert_eq!(natural_emu(&p), Some((914_400, 457_200))); + } + + #[test] + fn jpeg_and_metafile_sizes() { + let mut j = vec![0xFF, 0xD8]; + j.extend_from_slice(&[0xFF, 0xE0, 0, 16]); + j.extend_from_slice(b"JFIF\0\x01\x01\x01"); + j.extend_from_slice(&[0, 96, 0, 96, 0, 0]); + j.extend_from_slice(&[0xFF, 0xC0, 0, 11, 8, 0, 20, 0, 40, 1, 1, 0x11, 0]); + assert_eq!(pixel_size(&j), Some((40, 20))); + assert_eq!(natural_emu(&j), Some((40 * 9525, 20 * 9525))); + // Placeable WMF: 1440 units per inch, 1440 x 720. + let mut w = vec![0xD7, 0xCD, 0xC6, 0x9A, 0, 0]; + for v in [0i16, 0, 1440, 720] { + w.extend_from_slice(&v.to_le_bytes()); + } + w.extend_from_slice(&1440u16.to_le_bytes()); + assert_eq!(natural_emu(&w), Some((914_400, 457_200))); + // An EMF frame of 100 x 50 mm. + let mut e = vec![0u8; 88]; + e[0] = 1; + e[40..44].copy_from_slice(b" EMF"); + e[24..40].copy_from_slice( + &[0i32, 0, 10_000, 5_000] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + assert_eq!(natural_emu(&e), Some((3_600_000, 1_800_000))); + } + + #[test] + fn dib_gets_a_file_header() { + let mut dib = vec![0u8; 40]; + dib[0] = 40; + dib[14] = 8; + let b = dib_to_bmp(&dib); + assert_eq!(&b[..2], b"BM"); + // 14 + 40 + 256 palette entries. + assert_eq!(u32_at(&b, 10), 14 + 40 + 1024); + } +} diff --git a/crates/exav-render/src/formats/office/common/iwa/mod.rs b/crates/exav-render/src/formats/office/common/iwa/mod.rs new file mode 100644 index 00000000..8901cf6d --- /dev/null +++ b/crates/exav-render/src/formats/office/common/iwa/mod.rs @@ -0,0 +1,103 @@ +//! IWA ("iWork archive") streams: chunks of Snappy blocks, each a 1-byte +//! type (0) and a 24-bit length; uncompressed, a run of archives, each a +//! varint length, a TSP.ArchiveInfo and the payloads its MessageInfos +//! count. Objects are indexed by their identifier. + +pub mod proto; +pub mod snappy; + +use proto::{Msg, varint}; +use std::collections::HashMap; + +/// The objects of a document's IWA streams. +#[derive(Default)] +pub struct Store { + files: Vec>, + /// identifier: (message type, file, start, length). + objects: HashMap, +} + +impl Store { + /// Adds a stream; `budget` is the bytes it may still decompress to. + /// Returns the bytes used. + pub fn add(&mut self, data: &[u8], budget: usize) -> usize { + let mut out = Vec::new(); + let mut p = 0usize; + while p + 4 <= data.len() { + let kind = data[p]; + let len = usize::from(data[p + 1]) + | usize::from(data[p + 2]) << 8 + | usize::from(data[p + 3]) << 16; + p += 4; + let Some(chunk) = data.get(p..p + len) else { + break; + }; + p += len; + if kind != 0 { + continue; + } + let before = out.len(); + if snappy::decompress(chunk, &mut out, budget).is_none() { + // Some writers store a chunk uncompressed. + out.truncate(before); + if out.len() + chunk.len() > budget { + break; + } + out.extend_from_slice(chunk); + } + } + let used = out.len(); + let fi = self.files.len(); + let d = &out; + let mut p = 0usize; + while p < d.len() { + let Some(n) = varint(d, &mut p).and_then(|n| usize::try_from(n).ok()) else { + break; + }; + let Some(info) = d.get(p..p.saturating_add(n)) else { + break; + }; + p += n; + let info = Msg(info); + let id = info.uint(1).unwrap_or(0); + let mut first = true; + for mi in info.msgs(2).take(64) { + let kind = mi.uint(1).unwrap_or(0) as u32; + let len = mi.uint(3).unwrap_or(0) as usize; + if p.saturating_add(len) > d.len() { + p = d.len(); + break; + } + if first && id != 0 { + self.objects.entry(id).or_insert((kind, fi, p, len)); + } + first = false; + p += len; + } + } + self.files.push(out); + used + } + + /// An object: its message type and its first message. + pub fn get(&self, id: u64) -> Option<(u32, Msg<'_>)> { + let &(kind, f, start, len) = self.objects.get(&id)?; + Some((kind, Msg(self.files.get(f)?.get(start..start + len)?))) + } + + /// An object's message, when it is of type `kind`. + pub fn typed(&self, id: u64, kind: u32) -> Option> { + self.get(id).filter(|o| o.0 == kind).map(|o| o.1) + } + + pub fn ids_of(&self, kind: u32) -> Vec { + let mut v: Vec = self + .objects + .iter() + .filter(|o| o.1.0 == kind) + .map(|o| *o.0) + .collect(); + v.sort_unstable(); + v + } +} diff --git a/crates/exav-render/src/formats/office/common/iwa/proto.rs b/crates/exav-render/src/formats/office/common/iwa/proto.rs new file mode 100644 index 00000000..1c74d350 --- /dev/null +++ b/crates/exav-render/src/formats/office/common/iwa/proto.rs @@ -0,0 +1,252 @@ +//! A protocol buffers wire-format reader (the encoding documented at +//! protobuf.dev), schemaless: messages are read field by field, and the +//! field numbers of the messages used are written where they are used. + +#[derive(Clone, Copy, Debug)] +pub enum Val<'a> { + Varint(u64), + Fixed64(u64), + Bytes(&'a [u8]), + Fixed32(u32), +} + +/// One message's bytes. +#[derive(Clone, Copy, Debug, Default)] +pub struct Msg<'a>(pub &'a [u8]); + +pub fn varint(d: &[u8], p: &mut usize) -> Option { + let mut v: u64 = 0; + for shift in (0..64).step_by(7) { + let b = *d.get(*p)?; + *p += 1; + v |= u64::from(b & 0x7F) << shift; + if b & 0x80 == 0 { + return Some(v); + } + } + None +} + +impl<'a> Msg<'a> { + /// Every field, in order; stops at the first damaged one. + pub fn fields(self) -> Fields<'a> { + Fields { d: self.0, p: 0 } + } + + pub fn get(self, n: u32) -> Option> { + self.fields().find(|f| f.0 == n).map(|f| f.1) + } + + pub fn all(self, n: u32) -> impl Iterator> { + self.fields().filter(move |f| f.0 == n).map(|f| f.1) + } + + pub fn uint(self, n: u32) -> Option { + match self.get(n)? { + Val::Varint(v) | Val::Fixed64(v) => Some(v), + Val::Fixed32(v) => Some(u64::from(v)), + Val::Bytes(_) => None, + } + } + + pub fn int(self, n: u32) -> Option { + self.uint(n).map(|v| v as i64) + } + + /// A zigzag-encoded signed integer (sint32, sint64). + pub fn sint(self, n: u32) -> Option { + self.uint(n).map(|v| ((v >> 1) as i64) ^ -((v & 1) as i64)) + } + + pub fn boolean(self, n: u32) -> Option { + self.uint(n).map(|v| v != 0) + } + + pub fn double(self, n: u32) -> Option { + match self.get(n)? { + Val::Fixed64(v) => Some(f64::from_bits(v)), + _ => None, + } + } + + pub fn float(self, n: u32) -> Option { + match self.get(n)? { + Val::Fixed32(v) => Some(f32::from_bits(v)), + _ => None, + } + } + + pub fn bytes(self, n: u32) -> Option<&'a [u8]> { + match self.get(n)? { + Val::Bytes(b) => Some(b), + _ => None, + } + } + + pub fn string(self, n: u32) -> Option { + self.bytes(n) + .map(|b| String::from_utf8_lossy(b).into_owned()) + } + + pub fn msg(self, n: u32) -> Option> { + self.bytes(n).map(Msg) + } + + pub fn msgs(self, n: u32) -> impl Iterator> { + self.all(n).filter_map(|v| { + if let Val::Bytes(b) = v { + Some(Msg(b)) + } else { + None + } + }) + } + + /// A TSP.Reference field's identifier (field 1 of the reference). + pub fn reference(self, n: u32) -> Option { + self.msg(n)?.uint(1) + } + + pub fn references(self, n: u32) -> impl Iterator + 'a { + self.msgs(n).filter_map(|m| m.uint(1)) + } +} + +pub struct Fields<'a> { + d: &'a [u8], + p: usize, +} + +impl<'a> Iterator for Fields<'a> { + type Item = (u32, Val<'a>); + + fn next(&mut self) -> Option { + if self.p >= self.d.len() { + return None; + } + let key = varint(self.d, &mut self.p)?; + let n = u32::try_from(key >> 3).ok()?; + let v = match key & 7 { + 0 => Val::Varint(varint(self.d, &mut self.p)?), + 1 => { + let b = self.d.get(self.p..self.p + 8)?; + self.p += 8; + Val::Fixed64(u64::from_le_bytes(b.try_into().ok()?)) + } + 2 => { + let len = usize::try_from(varint(self.d, &mut self.p)?).ok()?; + let b = self.d.get(self.p..self.p.checked_add(len)?)?; + self.p += len; + Val::Bytes(b) + } + 5 => { + let b = self.d.get(self.p..self.p + 4)?; + self.p += 4; + Val::Fixed32(u32::from_le_bytes(b.try_into().ok()?)) + } + _ => { + // Groups (3, 4) and unknown types end the message. + self.p = self.d.len(); + return None; + } + }; + Some((n, v)) + } +} + +/// A protobuf writer, for tests that build documents. +#[cfg(any(test, feature = "office-test-util"))] +pub mod write { + #[derive(Default, Clone)] + pub struct W(pub Vec); + + fn varint(out: &mut Vec, mut v: u64) { + loop { + let b = (v & 0x7F) as u8; + v >>= 7; + if v == 0 { + out.push(b); + return; + } + out.push(b | 0x80); + } + } + + impl W { + pub fn uint(mut self, n: u32, v: u64) -> Self { + varint(&mut self.0, u64::from(n) << 3); + varint(&mut self.0, v); + self + } + pub fn sint(self, n: u32, v: i64) -> Self { + self.uint(n, ((v << 1) ^ (v >> 63)) as u64) + } + pub fn double(mut self, n: u32, v: f64) -> Self { + varint(&mut self.0, u64::from(n) << 3 | 1); + self.0.extend_from_slice(&v.to_le_bytes()); + self + } + pub fn float(mut self, n: u32, v: f32) -> Self { + varint(&mut self.0, u64::from(n) << 3 | 5); + self.0.extend_from_slice(&v.to_le_bytes()); + self + } + pub fn fixed32(mut self, n: u32, v: u32) -> Self { + varint(&mut self.0, u64::from(n) << 3 | 5); + self.0.extend_from_slice(&v.to_le_bytes()); + self + } + pub fn bytes(mut self, n: u32, b: &[u8]) -> Self { + varint(&mut self.0, u64::from(n) << 3 | 2); + varint(&mut self.0, b.len() as u64); + self.0.extend_from_slice(b); + self + } + pub fn msg(self, n: u32, m: W) -> Self { + self.bytes(n, &m.0) + } + pub fn reference(self, n: u32, id: u64) -> Self { + self.msg(n, W::default().uint(1, id)) + } + pub fn varint_len(v: u64) -> Vec { + let mut out = vec![]; + varint(&mut out, v); + out + } + } +} + +#[cfg(test)] +mod tests { + use super::write::W; + use super::*; + + #[test] + fn round_trip() { + let m = W::default() + .uint(1, 300) + .sint(2, -5) + .string_test(3, "hi") + .double(4, 1.5) + .msg(5, W::default().uint(1, 7)) + .0; + let msg = Msg(&m); + assert_eq!(msg.uint(1), Some(300)); + assert_eq!(msg.sint(2), Some(-5)); + assert_eq!(msg.string(3).as_deref(), Some("hi")); + assert_eq!(msg.double(4), Some(1.5)); + assert_eq!(msg.reference(5), Some(7)); + // Truncated: no panic, fields up to the damage. + assert!(Msg(&m[..m.len() - 1]).fields().count() < 5); + assert_eq!( + Msg(&[0x0A, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]).fields().count(), + 0 + ); + } + + impl W { + fn string_test(self, n: u32, s: &str) -> Self { + self.bytes(n, s.as_bytes()) + } + } +} diff --git a/crates/exav-render/src/formats/office/common/iwa/snappy.rs b/crates/exav-render/src/formats/office/common/iwa/snappy.rs new file mode 100644 index 00000000..a71aa358 --- /dev/null +++ b/crates/exav-render/src/formats/office/common/iwa/snappy.rs @@ -0,0 +1,149 @@ +//! Snappy decompression of one raw block (the format description in +//! Google's snappy repository, format_description.txt): a varint of the +//! uncompressed length, then literals and back references. IWA streams use +//! raw blocks inside chunks of their own, without the framing format's +//! stream identifier or checksums. + +/// Decompresses `src`, appending to `out`, refusing to grow `out` past +/// `limit` bytes. `None` when the block is damaged. +pub fn decompress(src: &[u8], out: &mut Vec, limit: usize) -> Option<()> { + let mut p = 0usize; + let mut len: u64 = 0; + let mut shift = 0; + loop { + let b = *src.get(p)?; + p += 1; + len |= u64::from(b & 0x7F) << shift; + if b & 0x80 == 0 { + break; + } + shift += 7; + if shift > 35 { + return None; + } + } + let base = out.len(); + let end = base.checked_add(usize::try_from(len).ok()?)?; + if end > limit { + return None; + } + out.reserve(end - base); + while p < src.len() { + let tag = src[p]; + p += 1; + match tag & 3 { + 0 => { + let mut n = usize::from(tag >> 2); + if n >= 60 { + let extra = n - 59; + let b = src.get(p..p + extra)?; + n = b + .iter() + .rev() + .fold(0usize, |a, &x| (a << 8) | usize::from(x)); + p += extra; + } + let n = n + 1; + let lit = src.get(p..p.checked_add(n)?)?; + if out.len() + n > end { + return None; + } + out.extend_from_slice(lit); + p += n; + } + kind => { + let (n, off) = match kind { + 1 => { + let b = *src.get(p)?; + p += 1; + ( + usize::from((tag >> 2) & 7) + 4, + usize::from(tag >> 5) << 8 | usize::from(b), + ) + } + 2 => { + let b = src.get(p..p + 2)?; + p += 2; + ( + usize::from(tag >> 2) + 1, + usize::from(u16::from_le_bytes([b[0], b[1]])), + ) + } + _ => { + let b = src.get(p..p + 4)?; + p += 4; + ( + usize::from(tag >> 2) + 1, + u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize, + ) + } + }; + if off == 0 || off > out.len() - base || out.len() + n > end { + return None; + } + let start = out.len() - off; + if off >= n { + out.extend_from_within(start..start + n); + } else { + // An overlapping copy repeats the last `off` bytes. + for i in 0..n { + let b = out[start + i]; + out.push(b); + } + } + } + } + } + (out.len() == end).then_some(()) +} + +/// Compresses `src` as literals only: a valid block, for tests. +#[cfg(any(test, feature = "office-test-util"))] +pub fn literal_block(src: &[u8]) -> Vec { + let mut out = vec![]; + let mut n = src.len() as u64; + loop { + let b = (n & 0x7F) as u8; + n >>= 7; + if n == 0 { + out.push(b); + break; + } + out.push(b | 0x80); + } + for chunk in src.chunks(60) { + out.push(((chunk.len() - 1) as u8) << 2); + out.extend_from_slice(chunk); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn literals_and_copies() { + // "abcabcabcd": a literal "abc", a copy of 6 at offset 3, a literal "d". + let block = [10, 2 << 2, b'a', b'b', b'c', 0b0000_1001, 3, 0, b'd']; + let mut out = vec![]; + decompress(&block, &mut out, 100).unwrap(); + assert_eq!(out, b"abcabcabcd"); + let mut out = vec![]; + let data = + b"hello hello hello, a longer literal of more than sixty bytes, to use the long form"; + decompress(&literal_block(data), &mut out, 1000).unwrap(); + assert_eq!(out, data); + } + + #[test] + fn damaged_blocks_are_refused() { + let mut out = vec![]; + // A copy before any output. + assert!(decompress(&[4, 0b0000_0001, 1], &mut out, 100).is_none()); + // A length past the limit. + assert!(decompress(&[0xFF, 0xFF, 0xFF, 0x7F], &mut out, 100).is_none()); + // Truncated literal. + assert!(decompress(&[5, 4 << 2, b'a'], &mut out, 100).is_none()); + } +} diff --git a/crates/exav-render/src/formats/office/common/mod.rs b/crates/exav-render/src/formats/office/common/mod.rs new file mode 100644 index 00000000..608bd2a1 --- /dev/null +++ b/crates/exav-render/src/formats/office/common/mod.rs @@ -0,0 +1,21 @@ +//! Readers shared by the office converters (`sheets`, `slides`, `docs`): +//! legacy code pages, OfficeArt records, ZIP packages, iWork archives +//! (Snappy, protocol buffers, the IWA object store), a bounded XML tree with +//! OpenDocument's prefixes, and a little-endian cursor that never panics. +//! Compound files are read by `exav_unpack::cfb`, which the scanner uses too. +//! +//! Each converter uses a subset: what a build leaves unused is not an error. + +#![allow(dead_code)] + +pub mod base64; +pub mod bytes; +pub use exav_unpack::charset as codepage; +pub mod css; +pub mod escher; +pub mod image; +pub mod iwa; +pub mod odf; +pub mod package; +pub mod xml; +pub mod xmlout; diff --git a/crates/exav-render/src/formats/office/common/odf.rs b/crates/exav-render/src/formats/office/common/odf.rs new file mode 100644 index 00000000..3ada900c --- /dev/null +++ b/crates/exav-render/src/formats/office/common/odf.rs @@ -0,0 +1,415 @@ +//! OpenDocument styles ([ODF 1.2] part 1, 16) as the text, spreadsheet and +//! drawing formats share them: named and automatic styles with their parent +//! chains, default styles, list styles, font faces and page layouts, and the +//! length, colour and percentage values of their properties. + +use super::xml::Node; +use std::collections::HashMap; + +/// A length in points: cm, mm, in, pt, pc, px (96 per inch), em and ex are +/// not lengths here. A bare number is taken as points. +pub fn pt(s: &str) -> Option { + let s = s.trim(); + let at = s + .find(|c: char| c.is_ascii_alphabetic() || c == '%') + .unwrap_or(s.len()); + let (num, unit) = s.split_at(at); + let v: f64 = num.trim().parse().ok()?; + if !v.is_finite() { + return None; + } + let p = match unit { + "pt" | "" => v, + "cm" => v * 72.0 / 2.54, + "mm" => v * 72.0 / 25.4, + "in" | "inch" => v * 72.0, + "pc" => v * 12.0, + "px" => v * 0.75, + _ => return None, + }; + Some(p.clamp(-1e9, 1e9)) +} + +/// A length in twips (1/20 point). +pub fn twips(s: &str) -> Option { + pt(s).map(|p| (p * 20.0).round() as i32) +} + +/// A length in EMU. +pub fn emu(s: &str) -> Option { + pt(s).map(|p| (p * 12_700.0).round() as i64) +} + +/// "#RRGGBB" (or "#RGB") as 0xRRGGBB. +pub fn color(s: &str) -> Option { + let h = s.trim().strip_prefix('#')?; + if h.len() == 3 { + let v = u32::from_str_radix(h, 16).ok()?; + let (r, g, b) = ((v >> 8) & 0xF, (v >> 4) & 0xF, v & 0xF); + return Some(((r * 17) << 16) | ((g * 17) << 8) | (b * 17)); + } + if h.len() != 6 { + return None; + } + u32::from_str_radix(h, 16).ok() +} + +/// "50%" to 0.5. +pub fn percent(s: &str) -> Option { + let v: f64 = s.trim().strip_suffix('%')?.trim().parse().ok()?; + v.is_finite().then_some(v / 100.0) +} + +/// Attributes as (qualified name, value). +pub type Attrs = Vec<(String, String)>; + +/// One style: its parent and every property of its property elements, keyed +/// by the kind of property element and the qualified name of the attribute: +/// `text/fo:color`, `paragraph/fo:margin-left`. +#[derive(Clone, Debug, Default)] +pub struct StyleDef { + pub parent: Option, + pub next: Option, + /// The attributes of the style element itself (style:display-name, + /// style:list-style-name, style:master-page-name, ...). + pub attrs: Vec<(String, String)>, + pub props: Vec<(String, String)>, + /// Elements inside property elements (tab stops, background images, + /// drop caps), as (property element, child name, attributes). + pub children: Vec<(String, String, Attrs)>, + pub automatic: bool, +} + +impl StyleDef { + pub fn attr(&self, k: &str) -> Option<&str> { + self.attrs.iter().find(|a| a.0 == k).map(|a| a.1.as_str()) + } +} + +/// Resolved properties: the last value of a key wins. +#[derive(Clone, Debug, Default)] +pub struct Props(pub Vec<(String, String)>); + +impl Props { + pub fn get(&self, k: &str) -> Option<&str> { + self.0.iter().rev().find(|p| p.0 == k).map(|p| p.1.as_str()) + } + + pub fn overlay(&mut self, o: &[(String, String)]) { + self.0.extend(o.iter().cloned()); + } + + pub fn pt(&self, k: &str) -> Option { + self.get(k).and_then(pt) + } + + pub fn twips(&self, k: &str) -> Option { + self.get(k).and_then(twips) + } + + pub fn color(&self, k: &str) -> Option { + self.get(k).and_then(color) + } + + pub fn is(&self, k: &str, v: &str) -> bool { + self.get(k) == Some(v) + } +} + +/// A list style's level. +#[derive(Clone, Debug, Default)] +pub struct ListLevel { + /// bullet, number, image or none. + pub kind: &'static str, + pub attrs: Vec<(String, String)>, + /// Properties of the level's list-level-properties and text-properties. + pub props: Props, + pub tabs: Vec<(String, String)>, +} + +impl ListLevel { + pub fn attr(&self, k: &str) -> Option<&str> { + self.attrs.iter().find(|a| a.0 == k).map(|a| a.1.as_str()) + } +} + +/// A list style's levels: (level 1 to 10, definition). +#[derive(Clone, Debug, Default)] +pub struct ListDef { + pub levels: Vec<(usize, ListLevel)>, +} + +/// The styles of one scope (a document's content, or its styles part). +#[derive(Default)] +pub struct Styles { + /// (family, name) to style; later definitions replace earlier ones. + pub styles: HashMap<(String, String), StyleDef>, + /// Style names in the order they were defined, per family. + pub order: Vec<(String, String)>, + pub defaults: HashMap>, + pub lists: Vec, + pub list_names: HashMap, + /// Font face name to family. + pub fonts: HashMap, + /// Page layouts' properties by name, with their header and footer style + /// properties under "header:" and "footer:" prefixes. + pub page_layouts: HashMap, +} + +fn attrs_of(n: &Node) -> Vec<(String, String)> { + n.attrs.clone() +} + +/// The attributes of a property element with its kind in front, so that +/// `fo:background-color` of text and of a paragraph stay apart: +/// `text/fo:background-color`, `paragraph/fo:background-color`. +fn prefixed(n: &Node) -> Vec<(String, String)> { + let kind = n + .name + .rsplit(':') + .next() + .unwrap_or("") + .trim_end_matches("-properties"); + n.attrs + .iter() + .map(|(k, v)| (format!("{kind}/{k}"), v.clone())) + .collect() +} + +impl Styles { + /// Reads the style elements under `n` (office:styles, + /// office:automatic-styles, office:font-face-decls). + pub fn add(&mut self, n: &Node, automatic: bool) { + for c in n.nodes() { + match c.name.as_str() { + "style:style" => { + let family = c.attr("style:family").unwrap_or("").to_string(); + let name = c.attr("style:name").unwrap_or("").to_string(); + let mut def = StyleDef { + parent: c.attr("style:parent-style-name").map(str::to_string), + next: c.attr("style:next-style-name").map(str::to_string), + attrs: attrs_of(c), + automatic, + ..StyleDef::default() + }; + for p in c.nodes() { + if p.name.ends_with("-properties") { + def.props.extend(prefixed(p)); + for g in p.nodes() { + def.children + .push((p.name.clone(), g.name.clone(), attrs_of(g))); + // Tab stops sit one level further. + for t in g.nodes() { + def.children.push(( + g.name.clone(), + t.name.clone(), + attrs_of(t), + )); + } + } + } + } + let key = (family, name); + if !self.styles.contains_key(&key) { + self.order.push(key.clone()); + } + self.styles.insert(key, def); + } + "style:page-layout" => { + let mut v = vec![]; + for p in c.nodes() { + let prefix = match p.name.as_str() { + "style:header-style" => "header:", + "style:footer-style" => "footer:", + _ => "", + }; + if prefix.is_empty() { + v.extend(attrs_of(p)); + for g in p.nodes() { + if g.name == "style:columns" { + v.push(( + "columns".into(), + g.attr("fo:column-count").unwrap_or("1").into(), + )); + v.push(( + "columns-gap".into(), + g.attr("fo:column-gap").unwrap_or("0").into(), + )); + } + } + } else { + for g in p.nodes() { + for (k, val) in &g.attrs { + v.push((format!("{prefix}{k}"), val.clone())); + } + } + } + } + if let Some(name) = c.attr("style:name") { + self.page_layouts.insert(name.to_string(), Props(v)); + } + } + "style:default-style" => { + let family = c.attr("style:family").unwrap_or("").to_string(); + let mut v = vec![]; + for p in c.nodes() { + if p.name.ends_with("-properties") { + v.extend(prefixed(p)); + } + } + self.defaults.insert(family, v); + } + "text:list-style" => { + let i = self.list(c); + if let Some(name) = c.attr("style:name") { + self.list_names.insert(name.to_string(), i); + } + } + "style:font-face" => { + if let (Some(name), Some(fam)) = + (c.attr("style:name"), c.attr("svg:font-family")) + { + let fam = fam.trim().trim_matches(['\'', '"']).to_string(); + self.fonts.insert(name.to_string(), fam); + } + } + _ => {} + } + } + } + + /// Reads a text:list-style element (also in a style's properties), + /// returning its index in `lists`. + pub fn list(&mut self, l: &Node) -> usize { + let mut def = ListDef::default(); + for lv in l.nodes() { + let level = lv + .attr("text:level") + .and_then(|v| v.parse::().ok()) + .unwrap_or(1) + .clamp(1, 10); + let mut x = ListLevel { + kind: match lv.name.as_str() { + "text:list-level-style-bullet" => "bullet", + "text:list-level-style-number" | "text:outline-level-style" => "number", + "text:list-level-style-image" => "image", + _ => "none", + }, + attrs: attrs_of(lv), + ..ListLevel::default() + }; + let mut p = vec![]; + for c in lv.nodes() { + p.extend(prefixed(c)); + for g in c.nodes() { + if g.name == "style:tab-stops" { + for t in g.nodes() { + x.tabs.extend(t.attrs.iter().cloned()); + } + } else if g.name == "style:list-level-label-alignment" { + p.extend( + g.attrs + .iter() + .map(|(k, v)| (format!("label-alignment/{k}"), v.clone())), + ); + } + } + } + x.props = Props(p); + def.levels.push((level, x)); + } + self.lists.push(def); + self.lists.len() - 1 + } + + pub fn get(&self, family: &str, name: &str) -> Option<&StyleDef> { + self.styles.get(&(family.to_string(), name.to_string())) + } + + /// The style's properties over its parents' (family default first). + pub fn resolve(&self, family: &str, name: Option<&str>) -> Props { + let mut out = Props::default(); + if let Some(d) = self.defaults.get(family) { + out.overlay(d); + } + let mut chain = vec![]; + let mut cur = name.map(str::to_string); + while let Some(n) = cur.take() { + if chain.len() > 32 { + break; + } + if let Some(s) = self.get(family, &n) { + chain.push(s); + cur = s.parent.clone(); + } + } + for s in chain.iter().rev() { + out.overlay(&s.props); + } + out + } + + /// The font family a font face name stands for. + pub fn font(&self, name: &str) -> String { + self.fonts + .get(name) + .cloned() + .unwrap_or_else(|| name.trim_matches(['\'', '"']).to_string()) + } +} + +/// The text of a node and its descendants. +pub fn text_of(n: &Node) -> String { + use super::xml::Child; + let mut s = String::new(); + for c in &n.children { + match c { + Child::Text(t) => s.push_str(t), + Child::Node(k) => s.push_str(&text_of(k)), + } + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lengths_and_colours() { + assert_eq!(twips("2.54cm"), Some(1440)); + assert_eq!(twips("1in"), Some(1440)); + assert_eq!(emu("10pt"), Some(127_000)); + assert_eq!(twips("12px"), Some(180)); + assert_eq!(twips("x"), None); + assert_eq!(twips("1em"), None); + assert_eq!(color("#3465a4"), Some(0x3465A4)); + assert_eq!(color("#fff"), Some(0xFFFFFF)); + assert_eq!(percent("50%"), Some(0.5)); + } + + #[test] + fn styles_resolve_through_parents_and_defaults() { + let x = br##""##; + let n = super::super::xml::parse(x, &mut super::super::xml::within(1 << 20)).unwrap(); + let mut s = Styles::default(); + s.add(&n, false); + let p = s.resolve("paragraph", Some("b")); + // The parent overrides the default, the child adds to the parent. + assert_eq!(p.get("paragraph/fo:text-align"), Some("center")); + assert_eq!(p.get("paragraph/fo:margin-right"), Some("2cm")); + assert_eq!(p.color("text/fo:color"), Some(0xFF0000)); + assert_eq!(p.get("paragraph/fo:margin-left"), Some("1cm")); + assert_eq!(s.order.len(), 3); + // A style's own value beats its parents' and the default. + let p = s.resolve("paragraph", Some("c")); + assert_eq!(p.get("paragraph/fo:text-align"), Some("end")); + assert_eq!(p.get("paragraph/fo:margin-right"), Some("2cm")); + // An unknown style resolves to the defaults. + assert_eq!( + s.resolve("paragraph", Some("missing")) + .get("paragraph/fo:text-align"), + Some("start") + ); + } +} diff --git a/crates/exav-render/src/formats/office/common/package.rs b/crates/exav-render/src/formats/office/common/package.rs new file mode 100644 index 00000000..e9b46cb9 --- /dev/null +++ b/crates/exav-render/src/formats/office/common/package.rs @@ -0,0 +1,254 @@ +//! ZIP packages (OpenDocument, OOXML, iWork) read and written within limits. + +use std::io::{Cursor, Read, Seek, SeekFrom, Write}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Why a package part was not read or written. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// Not a ZIP archive. + NotZip(String), + /// A member is encrypted. + Encrypted, + /// A member inflates past the bound, or the output grew past its limit. + TooLarge, + /// The archive could not be written. + Write(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotZip(e) => write!(f, "not a ZIP package: {e}"), + Error::Encrypted => f.write_str("encrypted package member"), + Error::TooLarge => f.write_str("package part too large"), + Error::Write(e) => write!(f, "writing the package: {e}"), + } + } +} + +impl std::error::Error for Error {} + +/// What a package is read from: its bytes, or a reader that fetches the +/// parts asked for. +pub trait Source: Read + Seek {} +impl Source for T {} + +/// Opens `reader` as a ZIP archive. The directory is first taken where the +/// end record says, which reads only the end of the file; the crate's own +/// search for it, which also finds an archive that something precedes, +/// reads the whole file from its start, and comes second. +pub(crate) fn open_zip( + mut reader: R, +) -> zip::result::ZipResult> { + let config = || zip::read::Config { + archive_offset: zip::read::ArchiveOffset::Known(0), + }; + if zip::ZipArchive::with_config(config(), &mut reader).is_ok() { + return zip::ZipArchive::with_config(config(), reader); + } + zip::ZipArchive::new(reader) +} + +pub struct Zip<'a> { + z: zip::ZipArchive>, +} + +impl<'a> Zip<'a> { + pub fn open(bytes: &'a [u8]) -> Result, Error> { + Zip::from_source(Box::new(Cursor::new(bytes))) + } + + pub fn from_source(source: Box) -> Result, Error> { + let z = open_zip(source).map_err(|e| Error::NotZip(e.to_string()))?; + Ok(Zip { z }) + } + + /// Member names, at most 65536 of them. + pub fn names(&self) -> Vec { + self.z + .file_names() + .take(1 << 16) + .filter_map(|s| s.ok().map(|s| s.to_string())) + .collect() + } + + /// The member's size when inflated, as its directory entry says. + pub fn size(&mut self, name: &str) -> Option { + self.z.by_name(name).ok().map(|f| f.size()) + } + + /// A member, `None` when absent or unreadable; past `max` bytes, an + /// error. + pub fn read(&mut self, name: &str, max: u64) -> Result>, Error> { + let f = match self.z.by_name(name) { + Ok(f) => f, + Err(zip::result::ZipError::UnsupportedArchive(m)) + if m == zip::result::ZipError::PASSWORD_REQUIRED => + { + return Err(Error::Encrypted); + } + Err(_) => return Ok(None), + }; + let mut v = Vec::new(); + if f.take(max + 1).read_to_end(&mut v).is_err() { + return Ok(None); + } + if v.len() as u64 > max { + return Err(Error::TooLarge); + } + Ok(Some(v)) + } +} + +/// A ZIP archive written to memory, refused past `limit` bytes. +pub struct PackageWriter { + // Declared first: dropped first, so that the limit is lifted before the + // writer below is finalized by its own drop. + _lift: Lift, + z: Option>, +} + +/// The output of a ZIP writer, refused past a limit. +pub(crate) struct Out { + buf: Cursor>, + limit: Arc, +} + +/// Lifts its [`Out`]'s limit when dropped. A ZIP writer dropped without being +/// finished is finalized by the `zip` crate, which says on stderr when it +/// cannot: and a writer that was refused (past its limit) is the usual one. Its +/// archive is wanted by no one, so it is let finish its last small write +/// quietly. +pub(crate) struct Lift(Arc); + +impl Lift { + /// Lets the writer finish: only its central directory and the last of its + /// compressed data are left to write, and what was written before is what + /// the limit was about. + pub(crate) fn lift(&self) { + self.0.store(u64::MAX, Ordering::Relaxed); + } +} + +impl Drop for Lift { + fn drop(&mut self) { + self.lift(); + } +} + +impl Out { + /// An output of up to `limit` bytes, with room for `capacity` made at + /// once. + pub(crate) fn new(capacity: usize, limit: u64) -> (Out, Lift) { + let limit = Arc::new(AtomicU64::new(limit)); + let out = Out { + buf: Cursor::new(Vec::with_capacity(capacity)), + limit: Arc::clone(&limit), + }; + (out, Lift(limit)) + } + + pub(crate) fn into_inner(self) -> Vec { + self.buf.into_inner() + } +} + +impl Write for Out { + fn write(&mut self, b: &[u8]) -> std::io::Result { + if self.buf.position() + b.len() as u64 > self.limit.load(Ordering::Relaxed) { + return Err(std::io::Error::other("output too large")); + } + self.buf.write(b) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Seek for Out { + fn seek(&mut self, p: SeekFrom) -> std::io::Result { + self.buf.seek(p) + } +} + +fn werr(e: impl std::fmt::Display) -> Error { + let s = e.to_string(); + if s.contains("too large") { + Error::TooLarge + } else { + Error::Write(s) + } +} + +impl PackageWriter { + pub fn new(limit: u64) -> PackageWriter { + let (out, lift) = Out::new(0, limit); + PackageWriter { + _lift: lift, + z: Some(zip::ZipWriter::new(out)), + } + } + + /// Adds a member, deflated or stored. + pub fn put(&mut self, name: &str, data: &[u8], compress: bool) -> Result<(), Error> { + let method = if compress { + zip::CompressionMethod::Deflated + } else { + zip::CompressionMethod::Stored + }; + let opt = zip::write::SimpleFileOptions::default() + .compression_method(method) + .compression_level(compress.then_some(6)) + .last_modified_time(zip::DateTime::default()); + let Some(z) = self.z.as_mut() else { + return Err(Error::Write("the package is finished".to_string())); + }; + z.start_file(name, opt).map_err(werr)?; + z.write_all(data).map_err(werr) + } + + /// The archive's bytes. + pub fn finish(mut self) -> Result, Error> { + let Some(z) = self.z.take() else { + return Err(Error::Write("the package is finished".to_string())); + }; + self._lift.lift(); + let out = z.finish().map_err(werr)?; + Ok(out.into_inner()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_and_bounds() { + let mut w = PackageWriter::new(1 << 20); + w.put("a.txt", b"hello", true).unwrap(); + w.put("b/c.bin", &[7u8; 5000], false).unwrap(); + let bytes = w.finish().unwrap(); + let mut z = Zip::open(&bytes).unwrap(); + assert_eq!(z.names().len(), 2); + assert_eq!(z.read("a.txt", 100).unwrap().unwrap(), b"hello"); + assert_eq!(z.read("missing", 100).unwrap(), None); + assert_eq!(z.read("b/c.bin", 100), Err(Error::TooLarge)); + assert!(matches!(Zip::open(b"nope"), Err(Error::NotZip(_)))); + // Bit 0 of the general purpose flags marks a member as encrypted. + let mut e = bytes.clone(); + for i in 0..e.len().saturating_sub(10) { + if e[i..].starts_with(b"PK\x03\x04") { + e[i + 6] |= 1; + } else if e[i..].starts_with(b"PK\x01\x02") { + e[i + 8] |= 1; + } + } + let mut z = Zip::open(&e).unwrap(); + assert_eq!(z.read("a.txt", 100), Err(Error::Encrypted)); + let mut small = PackageWriter::new(100); + assert_eq!(small.put("x", &[1u8; 5000], false), Err(Error::TooLarge)); + } +} diff --git a/crates/exav-render/src/formats/office/common/xml.rs b/crates/exav-render/src/formats/office/common/xml.rs new file mode 100644 index 00000000..32ce14aa --- /dev/null +++ b/crates/exav-render/src/formats/office/common/xml.rs @@ -0,0 +1,475 @@ +//! A small element tree over quick-xml, with element and attribute names +//! given the usual ODF prefixes whatever prefixes a file declares. Bounded: +//! nesting depth is limited, and the memory of the tree is told to the caller +//! as it grows. + +use quick_xml::events::Event; +use std::fmt; + +/// Why a document did not parse. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// Not well-formed, or nested past [`MAX_DEPTH`]. + Malformed(String), + /// More elements than allowed: what was counted. + TooLarge(&'static str), + /// No element at all. + NotFormat(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Malformed(why) | Error::NotFormat(why) => f.write_str(why), + Error::TooLarge(what) => write!(f, "too large: {what}"), + } + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; +use quick_xml::NsReader; +use quick_xml::name::ResolveResult; + +#[derive(Debug, Default)] +pub struct Node { + pub name: String, + pub attrs: Vec<(String, String)>, + pub children: Vec, +} + +#[derive(Debug)] +pub enum Child { + Node(Node), + Text(String), +} + +const NS: &[(&str, &str)] = &[ + ("urn:oasis:names:tc:opendocument:xmlns:office:1.0", "office"), + ("urn:oasis:names:tc:opendocument:xmlns:style:1.0", "style"), + ("urn:oasis:names:tc:opendocument:xmlns:text:1.0", "text"), + ("urn:oasis:names:tc:opendocument:xmlns:table:1.0", "table"), + ("urn:oasis:names:tc:opendocument:xmlns:drawing:1.0", "draw"), + ( + "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0", + "fo", + ), + ("http://www.w3.org/1999/xlink", "xlink"), + ("http://purl.org/dc/elements/1.1/", "dc"), + ("urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "meta"), + ( + "urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0", + "number", + ), + ( + "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0", + "svg", + ), + ("urn:oasis:names:tc:opendocument:xmlns:chart:1.0", "chart"), + ("urn:oasis:names:tc:opendocument:xmlns:of:1.2", "of"), + ( + "urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0", + "calcext", + ), + ( + "urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0", + "loext", + ), + ("http://openoffice.org/2009/table", "tableooo"), + ("http://openoffice.org/2004/office", "ooo"), + ("http://openoffice.org/2004/calc", "oooc"), + ("urn:oasis:names:tc:opendocument:xmlns:config:1.0", "config"), + ("urn:oasis:names:tc:opendocument:xmlns:form:1.0", "form"), + ("http://www.w3.org/1998/Math/MathML", "math"), + ("urn:oasis:names:tc:opendocument:xmlns:dr3d:1.0", "dr3d"), + ( + "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0", + "presentation", + ), + ( + "urn:oasis:names:tc:opendocument:xmlns:smil-compatible:1.0", + "smil", + ), + ( + "urn:oasis:names:tc:opendocument:xmlns:animation:1.0", + "anim", + ), + ("http://openoffice.org/2010/draw", "drawooo"), + ("http://openoffice.org/2009/office", "officeooo"), + // Apple's iWork '04 to '09 documents (Keynote 2 to 5). + ("http://developer.apple.com/namespaces/sf", "sf"), + ("http://developer.apple.com/namespaces/sfa", "sfa"), + ("http://developer.apple.com/namespaces/ls", "ls"), + ("http://developer.apple.com/namespaces/keynote2", "key"), + ("http://www.w3.org/2001/XMLSchema-instance", "xsi"), + // Early OpenDocument drafts used the W3C namespaces. + ("http://www.w3.org/2000/svg", "svg"), + ("http://www.w3.org/1999/XSL/Format", "fo"), + ("http://www.w3.org/2001/SMIL20", "smil"), + // Word 2003 XML (WordprocessingML 2003). + ("http://schemas.microsoft.com/office/word/2003/wordml", "w"), + ( + "http://schemas.microsoft.com/office/word/2003/auxHint", + "wx", + ), + ("urn:schemas-microsoft-com:office:office", "o"), + ("urn:schemas-microsoft-com:vml", "v"), + ("urn:schemas-microsoft-com:office:word", "w10"), + ("http://schemas.microsoft.com/aml/2001/core", "aml"), + // Excel 2003 XML Spreadsheet. + ("urn:schemas-microsoft-com:office:spreadsheet", "ss"), + ("urn:schemas-microsoft-com:office:excel", "x"), + ("http://www.w3.org/TR/REC-html40", "html"), + // Visio drawings (VSDX) and the Open Packaging Conventions. + ( + "http://schemas.microsoft.com/office/visio/2012/main", + "visio", + ), + ( + "http://schemas.microsoft.com/office/visio/2011/1/core", + "visio", + ), + ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "r", + ), + ( + "http://schemas.openxmlformats.org/package/2006/relationships", + "rels", + ), + ("http://schemas.openxmlformats.org/drawingml/2006/main", "a"), +]; + +fn prefix_of(r: &ResolveResult) -> String { + match r { + ResolveResult::Bound(ns) => { + let u = ns.as_ref(); + NS.iter() + .find(|e| e.0 == u) + .map(|e| e.1.to_string()) + .unwrap_or_else(|| format!("{{{u}}}")) + } + _ => String::new(), + } +} + +fn qualify(p: String, l: &str) -> String { + if p.is_empty() { + l.to_owned() + } else { + format!("{p}:{l}") + } +} + +/// Bounds of one document. +pub const MAX_DEPTH: usize = 256; + +/// What the tree takes for an element or a text in its parent's list: the +/// room of the list doubles as it grows. +const SLOT: u64 = 2 * size_of::() as u64; + +/// What the allocator takes for `len` bytes: a header, a 16-byte grain, and 32 +/// at least. +fn heap(len: usize) -> u64 { + if len == 0 { + 0 + } else { + ((len as u64 + 8 + 15) & !15).max(32) + } +} + +/// A `charge` for [`parse`] that refuses past `max` bytes in all. +pub fn within(max: u64) -> impl FnMut(u64) -> bool { + let mut left = max; + move |bytes| match left.checked_sub(bytes) { + Some(l) => { + left = l; + true + } + None => false, + } +} + +/// Parses a document. The memory of the tree is told to `charge` as it grows, +/// in bytes, and the parse fails once `charge` returns false. +pub fn parse(xml: &[u8], charge: &mut dyn FnMut(u64) -> bool) -> Result { + let mut spend = |bytes: u64| { + if charge(bytes) { + Ok(()) + } else { + Err(Error::TooLarge("memory")) + } + }; + let mut r = NsReader::from_reader(xml); + r.config_mut().check_end_names = false; + let mut stack: Vec = vec![Node { + name: "#root".into(), + ..Default::default() + }]; + let mut buf = Vec::new(); + loop { + let (res, ev) = r + .read_resolved_event_into(&mut buf) + .map_err(|e| Error::Malformed(format!("XML: {e}")))?; + match ev { + Event::Start(ref e) | Event::Empty(ref e) => { + let name = qualify(prefix_of(&res), e.local_name().as_ref()); + let mut cost = SLOT + heap(name.len()); + let mut attrs = Vec::new(); + for a in e.attributes().with_checks(false).flatten() { + let (ar, local) = r.resolver().resolve_attribute(a.key); + let key = qualify(prefix_of(&ar), local.as_ref()); + let v = a + .normalized_value(quick_xml::XmlVersion::Implicit1_0) + .map(|v| v.into_owned()) + .unwrap_or_else(|_| a.value.to_string()); + cost += heap(key.len()) + heap(v.len()); + attrs.push((key, v)); + } + cost += heap(attrs.capacity() * size_of::<(String, String)>()); + spend(cost)?; + let node = Node { + name, + attrs, + children: vec![], + }; + let empty = matches!(ev, Event::Empty(_)); + if empty { + if let Some(top) = stack.last_mut() { + top.children.push(Child::Node(node)); + } + } else if stack.len() < MAX_DEPTH { + stack.push(node); + } else { + return Err(Error::Malformed("XML nested too deep".into())); + } + } + Event::End(_) => { + if stack.len() > 1 + && let Some(n) = stack.pop() + && let Some(top) = stack.last_mut() + { + top.children.push(Child::Node(n)); + } + } + Event::Text(t) => { + let s = t.xml10_content().into_owned(); + spend(push_text(&mut stack, &s))?; + } + Event::CData(t) => { + spend(push_text(&mut stack, &t))?; + } + Event::GeneralRef(g) => { + let s = match g.resolve_char_ref() { + Ok(Some(c)) => c.to_string(), + _ => match &*g { + "amp" => "&".into(), + "lt" => "<".into(), + "gt" => ">".into(), + "quot" => "\"".into(), + "apos" => "'".into(), + _ => String::new(), + }, + }; + spend(push_text(&mut stack, &s))?; + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + while stack.len() > 1 { + if let Some(n) = stack.pop() + && let Some(top) = stack.last_mut() + { + top.children.push(Child::Node(n)); + } + } + let mut root = stack.pop().unwrap_or_default(); + root.children.retain(|c| matches!(c, Child::Node(_))); + match root.children.pop() { + Some(Child::Node(n)) => Ok(n), + _ => Err(Error::NotFormat("an empty XML document".into())), + } +} + +/// Adds `s` to the text of the open element; the bytes it takes in the tree. +fn push_text(stack: &mut [Node], s: &str) -> u64 { + let Some(n) = stack.last_mut() else { return 0 }; + if let Some(Child::Text(t)) = n.children.last_mut() { + t.push_str(s); + s.len() as u64 + } else { + n.children.push(Child::Text(s.to_string())); + SLOT + heap(s.len()) + } +} + +impl Node { + pub fn attr(&self, k: &str) -> Option<&str> { + self.attrs.iter().find(|a| a.0 == k).map(|a| a.1.as_str()) + } + + pub fn nodes(&self) -> impl Iterator { + self.children.iter().filter_map(|c| { + if let Child::Node(n) = c { + Some(n) + } else { + None + } + }) + } + + pub fn child(&self, name: &str) -> Option<&Node> { + self.nodes().find(|n| n.name == name) + } + + pub fn all<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.nodes().filter(move |n| n.name == name) + } + + /// The first descendant named `name`, depth first. + pub fn find(&self, name: &str) -> Option<&Node> { + let mut stack: Vec<&Node> = self.nodes().collect(); + stack.reverse(); + while let Some(n) = stack.pop() { + if n.name == name { + return Some(n); + } + let mut kids: Vec<&Node> = n.nodes().collect(); + kids.reverse(); + stack.extend(kids); + } + None + } + + /// The node as a document that `parse` reads back to the same names: the + /// known namespaces are declared on it. Elements and attributes of + /// other namespaces are left out. + pub fn to_xml(&self) -> String { + fn escape(s: &str, out: &mut String) { + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + c => out.push(c), + } + } + } + fn write(n: &Node, root: bool, out: &mut String) { + out.push('<'); + out.push_str(&n.name); + if root { + let mut seen: Vec<&str> = vec![]; + for (uri, prefix) in NS { + if !seen.contains(prefix) { + seen.push(prefix); + out.push_str(&format!(" xmlns:{prefix}=\"{uri}\"")); + } + } + } + for (k, v) in n.attrs.iter().filter(|a| !a.0.starts_with('{')) { + out.push_str(&format!(" {k}=\"")); + escape(v, out); + out.push('"'); + } + out.push('>'); + for c in &n.children { + match c { + Child::Node(k) if !k.name.starts_with('{') => write(k, false, out), + Child::Node(_) => {} + Child::Text(t) => escape(t, out), + } + } + out.push_str("'); + } + let mut out = String::new(); + write(self, true, &mut out); + out + } + + /// The text directly in this node. + pub fn own_text(&self) -> String { + self.children + .iter() + .filter_map(|c| { + if let Child::Text(t) = c { + Some(t.as_str()) + } else { + None + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefixes_are_normalized() { + let x = br#"xA<y>"#; + let d = parse(x, &mut within(1 << 20)).unwrap(); + assert_eq!(d.name, "office:document"); + let t = d.child("table:table").unwrap(); + assert_eq!(t.attr("table:name"), Some("S&1")); + assert_eq!(t.own_text(), "xA"); + // An early draft's W3C SVG namespace reads as svg. + let x = br#""#; + let d = parse(x, &mut within(1 << 20)).unwrap(); + assert_eq!(d.attr("svg:width"), Some("2cm")); + } + + #[test] + fn written_back_reads_the_same() { + let x = br#"a < b"#; + let d = parse(x, &mut within(1 << 20)).unwrap(); + let back = d.to_xml(); + assert!(!back.contains("skip"), "{back}"); + let e = parse(back.as_bytes(), &mut within(1 << 20)).unwrap(); + assert_eq!(e.name, "office:document"); + let t = e.child("table:table").unwrap(); + assert_eq!(t.attr("table:name"), Some("S&\"1\"")); + assert_eq!(t.own_text(), "a < b"); + assert!(t.child("table:row").is_some()); + } + + #[test] + fn bounded() { + let mut x = String::new(); + for _ in 0..300 { + x.push_str(""); + } + assert!(parse(x.as_bytes(), &mut within(1 << 20)).is_err()); + // Unclosed elements are closed at the end. + let d = parse(b"", &mut within(1 << 20)).unwrap(); + assert_eq!(d.nodes().count(), 1); + } + + #[test] + fn the_memory_of_the_tree_is_told_as_it_grows() { + // An element of four bytes of input takes a node, whatever its size. + let x = format!("{}", "".repeat(10_000)); + let mut told = 0; + parse(x.as_bytes(), &mut |n| { + told += n; + true + }) + .unwrap(); + assert!(told >= 10_000 * size_of::() as u64, "{told}"); + let refused = parse(x.as_bytes(), &mut within(told / 2)); + assert_eq!(refused.err(), Some(Error::TooLarge("memory"))); + // A text takes its slot and its bytes. + let x = format!("{}", "x".repeat(100_000)); + assert_eq!( + parse(x.as_bytes(), &mut within(99_999)).err(), + Some(Error::TooLarge("memory")) + ); + } +} diff --git a/crates/exav-render/src/formats/office/common/xmlout.rs b/crates/exav-render/src/formats/office/common/xmlout.rs new file mode 100644 index 00000000..298ba0b9 --- /dev/null +++ b/crates/exav-render/src/formats/office/common/xmlout.rs @@ -0,0 +1,43 @@ +//! Escaping for the XML parts a converter writes. + +/// Appends `s` escaped for XML text or an attribute value. Characters XML +/// 1.0 cannot hold are dropped. +pub fn esc_into(o: &mut String, s: &str) { + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\t' | '\n' | '\r' => o.push(c), + c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {} + c => o.push(c), + } + } +} + +pub fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + esc_into(&mut o, s); + o +} + +/// RGB as six hex digits. +pub fn hex(c: u32) -> String { + format!("{:06X}", c & 0xFF_FFFF) +} + +pub const XML_HEAD: &str = "\n"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escapes_and_drops_what_xml_cannot_hold() { + assert_eq!(esc("a Option { + // A direct RGB value has no flags in the top byte. + (v >> 24 == 0).then_some((v & 0xFF) << 16 | (v & 0xFF00) | (v >> 16) & 0xFF) +} + +fn fixed(v: u32) -> i32 { + // 16.16 fixed point to thousandths of a percent of the picture. + ((i64::from(v as i32) * 100_000) >> 16) as i32 +} + +/// The picture record inside a BSE, or a bare picture record. +fn blip_of<'a>(r: &Rec<'a>) -> Option> { + if r.typ == escher::BSE { + let d = r.data; + let name_len = d.get(33).copied().unwrap_or(0) as usize; + let rest = d.get(36 + name_len..).filter(|x| x.len() >= 8)?; + return read_at(rest, 0); + } + matches!(r.typ, 0xF01A..=0xF02A).then_some(*r) +} + +impl Reader<'_> { + fn add_blip(&mut self, b: escher::Blip) -> Result> { + let ext = match b.ext { + "png" | "jpeg" | "gif" | "bmp" | "tiff" | "emf" | "wmf" => b.ext, + _ => match image::ext(&b.data) { + Some(e) => e, + None => return Ok(None), + }, + }; + self.b.alloc(b.data.len() as u64)?; + Ok(Some(self.doc.add_media(ext, b.data))) + } + + /// A picture character's picture, from the data stream. + pub(crate) fn inline_picture(&mut self, loc: i32) -> Result> { + if self.word6 { + return self.word6_picture(loc); + } + let at = loc as usize; + if loc < 0 || at + 68 > self.data.len() { + return Ok(None); + } + let lcb = u32_at(&self.data, at) as usize; + let cb_header = usize::from(u16_at(&self.data, at + 4)); + let mm = u16_at(&self.data, at + 6); + let (goal_x, goal_y) = ( + i64::from(u16_at(&self.data, at + 28)), + i64::from(u16_at(&self.data, at + 30)), + ); + let (mx, my) = ( + i64::from(u16_at(&self.data, at + 32)), + i64::from(u16_at(&self.data, at + 34)), + ); + if cb_header < 68 || lcb < cb_header { + return Ok(None); + } + let end = at.saturating_add(lcb).min(self.data.len()); + let mut start = at + cb_header; + if mm == 0x66 { + start += 1 + usize::from(self.data.get(start).copied().unwrap_or(0)); + } + let Some(body) = self.data.get(start..end) else { + return Ok(None); + }; + // The shape container, then the pictures it uses. + let body = body.to_vec(); + let mut shape: Option> = None; + let mut blips = vec![]; + let mut pos = 0usize; + while let Some(r) = read_at(&body, pos) { + pos = pos + 8 + r.data.len(); + if r.typ == escher::SP_CONTAINER { + let s = escher::sp_container(&r); + shape = Some(s.props.0.iter().map(|p| (p.id, p.value)).collect()); + } else if let Some(bl) = blip_of(&r) { + blips.push(bl); + } + if blips.len() > 4 { + break; + } + } + let props = shape.unwrap_or_default(); + let get = |id: u16| props.iter().rev().find(|p| p.0 == id).map(|p| p.1); + let pib = get(0x0104).unwrap_or(1) as usize; + let rec = blips.get(pib.saturating_sub(1)).or(blips.first()).copied(); + let Some(rec) = rec else { return Ok(None) }; + let Some(blip) = escher::blip(&rec) else { + return Ok(None); + }; + let Some(media) = self.add_blip(blip)? else { + return Ok(None); + }; + let crop = Crop { + t: get(0x0100).map(fixed).unwrap_or(0), + b: get(0x0101).map(fixed).unwrap_or(0), + l: get(0x0102).map(fixed).unwrap_or(0), + r: get(0x0103).map(fixed).unwrap_or(0), + }; + let m = &self.doc.media[media]; + let natural = image::natural_emu(&m.data); + let (mut cx, mut cy) = (goal_x * mx / 1000 * TWIP_EMU, goal_y * my / 1000 * TWIP_EMU); + if cx <= 0 || cy <= 0 { + let (nx, ny) = natural.unwrap_or((914_400, 914_400)); + cx = nx; + cy = ny; + } + Ok(Some(Drawing { + anchor: None, + cx, + cy, + name: "Picture".into(), + descr: None, + kind: DrawingKind::Picture { media, crop }, + })) + } + + /// The picture a floating shape shows, and its text, by shape id. + pub(crate) fn floating_shape(&mut self, cp: u32, story: Story) -> Result> { + let hdr = story == Story::Header; + let base = if hdr { self.base_hdd() } else { 0 }; + let spa = { + let list = if hdr { &self.spa_hdr } else { &self.spa_main }; + list.iter().find(|s| s.cp + base == cp).cloned() + }; + let Some(spa) = spa else { return Ok(None) }; + let (off, len) = self.fib.at(idx::DGG_INFO); + let Some(area) = self + .tbl + .get(off..off.saturating_add(len).min(self.tbl.len())) + else { + return Ok(None); + }; + // The drawing layer is read from a copy: the text of text boxes needs + // the reader itself. + let area = area.to_vec(); + self.floating_inner(&area, &spa, hdr) + } + + fn floating_inner( + &mut self, + area: &[u8], + spa: &super::Spa, + hdr: bool, + ) -> Result> { + let Some(dgg) = read_at(area, 0) else { + return Ok(None); + }; + // The drawings follow the group record: a byte, then a container. + let mut pos = 8 + dgg.data.len(); + let mut drawing = None; + for _ in 0..2 { + let label = area.get(pos).copied(); + let Some(label) = label else { break }; + let Some(dg) = read_at(area, pos + 1) else { + break; + }; + pos += 1 + 8 + dg.data.len(); + if (label == 1) == hdr { + drawing = Some(escher::drawing(&dg)); + break; + } + } + let Some(d) = drawing else { return Ok(None) }; + fn find<'a, 'b>(v: &'b [escher::OShape<'a>], id: u32) -> Option<&'b escher::OShape<'a>> { + for s in v { + if s.spid == id { + return Some(s); + } + if let Some(x) = find(&s.children, id) { + return Some(x); + } + } + None + } + let Some(shape) = find(&d.shapes, spa.lid) else { + return Ok(None); + }; + if shape.is_group() { + return Ok(None); + } + let p = &shape.props; + // Placement. + let flags = spa.flags; + let bx = (flags >> 1) & 3; + let by = (flags >> 3) & 3; + let wr = (flags >> 5) & 0xF; + let below = flags & 0x4000 != 0; + let width = i64::from(spa.rect[2]) - i64::from(spa.rect[0]); + let height = i64::from(spa.rect[3]) - i64::from(spa.rect[1]); + let anchor = Anchor { + h_rel: match bx { + 0 => "margin", + 1 => "page", + _ => "column", + }, + v_rel: match by { + 0 => "margin", + 1 => "page", + _ => "paragraph", + }, + h_off: i64::from(spa.rect[0]) * TWIP_EMU, + v_off: i64::from(spa.rect[1]) * TWIP_EMU, + wrap: match wr { + 1 => Wrap::TopBottom, + 3 => Wrap::None, + 4 => Wrap::Tight, + 5 => Wrap::Through, + _ => Wrap::Square, + }, + behind: below, + z: spa.cp % 1_000_000, + dist: [ + p.get(0x0385).map(i64::from).unwrap_or(0), + p.get(0x0387).map(i64::from).unwrap_or(0), + p.get(0x0384).map(i64::from).unwrap_or(114_300), + p.get(0x0386).map(i64::from).unwrap_or(114_300), + ], + ..Anchor::default() + }; + let (cx, cy) = (width.max(0) * TWIP_EMU, height.max(0) * TWIP_EMU); + // A picture shape; its bitmap may be in the delay stream (the + // WordDocument stream). + if let Some(pib) = p.get(0x0104).filter(|v| *v > 0) { + let blip = escher::blip_records(&dgg, &self.wd) + .get(pib as usize - 1) + .copied() + .flatten() + .and_then(|r| escher::blip(&r)); + if let Some(blip) = blip + && let Some(media) = self.add_blip(blip)? + { + let crop = Crop { + t: p.get(0x0100).map(fixed).unwrap_or(0), + b: p.get(0x0101).map(fixed).unwrap_or(0), + l: p.get(0x0102).map(fixed).unwrap_or(0), + r: p.get(0x0103).map(fixed).unwrap_or(0), + }; + return Ok(Some(Drawing { + anchor: Some(anchor), + cx, + cy, + name: "Picture".into(), + descr: None, + kind: DrawingKind::Picture { media, crop }, + })); + } + } + // A text box or an autoshape. + let plc = if hdr { &self.txbx_hdr } else { &self.txbx_main }; + let base_tx = if hdr { + self.base_hdr_txbx() + } else { + self.base_txbx() + }; + let text_range = plc + .data + .iter() + .position(|d| u32_at(d, 14) == spa.lid && u16_at(d, 8) == 0) + .and_then(|k| { + Some(( + base_tx.checked_add(*plc.cps.get(k)?)?, + base_tx.checked_add(*plc.cps.get(k + 1)?)?, + )) + }); + let preset = match shape.spt { + 2 => "roundRect", + 3 => "ellipse", + 4 => "diamond", + 5 => "triangle", + 6 => "rtTriangle", + 7 => "parallelogram", + 8 => "trapezoid", + 9 => "hexagon", + 10 => "octagon", + 11 => "plus", + 12 => "star5", + 13 => "rightArrow", + 20 | 32 => "line", + _ => "rect", + }; + let mut sh = Shape { + preset, + ..Shape::default() + }; + let filled = p.flag(0x01BF, 4) != Some(false); + if filled { + let c = p.get(0x0181).and_then(color_of).unwrap_or(0xFFFFFF); + sh.fill = Fill::Solid(c); + } + if p.flag(0x01FF, 3) != Some(false) && (p.get(0x01C0).is_some() || shape.spt == 20) { + let c = p.get(0x01C0).and_then(color_of).unwrap_or(0); + sh.line = Some(Line { + color: Color::Rgb(c), + width: p.get(0x01CB).map(i64::from).unwrap_or(9525), + dash: "solid", + }); + } + sh.insets = [ + p.get(0x0081).map(i64::from).unwrap_or(91_440), + p.get(0x0082).map(i64::from).unwrap_or(45_720), + p.get(0x0083).map(i64::from).unwrap_or(91_440), + p.get(0x0084).map(i64::from).unwrap_or(45_720), + ]; + sh.v_anchor = match p.get(0x0087).unwrap_or(0) { + 1 | 4 | 5 | 8 | 9 => "ctr", + 2 | 3 | 6 | 7 => "b", + _ => "t", + }; + sh.flip_h = shape.flags & escher::F_FLIP_H != 0; + sh.flip_v = shape.flags & escher::F_FLIP_V != 0; + sh.rot = ((p.get(0x0004).unwrap_or(0) as i32 as i64 * 60_000) >> 16) as i32; + if let Some((a, b)) = text_range { + let b = b.min(self.text.len() as u32); + if b > a + 1 { + let mut blocks = self.story_blocks(a, b, Story::Textbox)?; + // The mark that ends the text box is not a paragraph of it. + if blocks.len() > 1 + && let Some(Block::Para(last)) = blocks.last() + && last.content.is_empty() + { + blocks.pop(); + } + sh.text = blocks; + } + } + Ok(Some(Drawing { + anchor: Some(anchor), + cx, + cy, + name: "Shape".into(), + descr: None, + kind: DrawingKind::Shape(sh), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn colours_and_fixed_point() { + assert_eq!(color_of(0x0000_00FF), Some(0xFF0000)); + assert_eq!(color_of(0x0100_0000), None); + assert_eq!(color_of(0x00FF_8000), Some(0x0080FF)); + // 0.25 of the picture. + assert_eq!(fixed(0x4000), 25_000); + } +} diff --git a/crates/exav-render/src/formats/office/docs/doc/fib.rs b/crates/exav-render/src/formats/office/docs/doc/fib.rs new file mode 100644 index 00000000..3500d1c1 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/doc/fib.rs @@ -0,0 +1,302 @@ +//! The file information block and the text it locates: the piece table +//! ([MS-DOC] 2.5, 2.9.38). + +use crate::formats::office::common::bytes::{Cur, u16_at, u32_at}; +use crate::formats::office::docs::error::{Error, Result}; + +pub struct Fib { + pub n_fib: u16, + pub which_table: bool, + pub encrypted: bool, + pub ccp_text: i64, + pub ccp_ftn: i64, + pub ccp_hdd: i64, + pub ccp_atn: i64, + pub ccp_edn: i64, + pub ccp_txbx: i64, + pub ccp_hdr_txbx: i64, + /// The (fc, lcb) pairs of FibRgFcLcb97, as many as the file has. + pub pairs: Vec<(u32, u32)>, +} + +/// A sum of character counts as a character position, saturating. +pub fn cp(sum: i64) -> u32 { + u32::try_from(sum).unwrap_or(u32::MAX) +} + +/// Indexes into FibRgFcLcb97's pairs. +pub mod idx { + pub const STSHF: usize = 1; + pub const PLCFFND_REF: usize = 2; + pub const PLCFFND_TXT: usize = 3; + pub const PLCFAND_REF: usize = 4; + pub const PLCFAND_TXT: usize = 5; + pub const PLCF_SED: usize = 6; + pub const PLCF_HDD: usize = 11; + pub const PLCF_BTE_CHPX: usize = 12; + pub const PLCF_BTE_PAPX: usize = 13; + pub const STTBF_FFN: usize = 15; + pub const STTBF_BKMK: usize = 21; + pub const PLCF_BKF: usize = 22; + pub const PLCF_BKL: usize = 23; + pub const DOP: usize = 31; + pub const CLX: usize = 33; + pub const GRP_XST_ATN_OWNERS: usize = 36; + pub const PLC_SPA_MOM: usize = 40; + pub const PLC_SPA_HDR: usize = 41; + pub const PLCF_ATN_BKF: usize = 42; + pub const PLCF_ATN_BKL: usize = 43; + pub const PLCFEND_REF: usize = 46; + pub const PLCFEND_TXT: usize = 47; + pub const DGG_INFO: usize = 50; + pub const PLCFTXBX_TXT: usize = 56; + pub const PLCF_HDR_TXBX_TXT: usize = 58; + pub const PLF_LST: usize = 73; + pub const PLF_LFO: usize = 74; +} + +impl Fib { + pub fn read(wd: &[u8]) -> Result { + if wd.len() < 154 { + return Err(Error::Malformed( + "the WordDocument stream is too short".into(), + )); + } + let w_ident = u16_at(wd, 0); + if w_ident != 0xA5EC { + return Err(Error::Unsupported(format!( + "Word document of an unknown kind (identifier {w_ident:#06x})" + ))); + } + let n_fib = u16_at(wd, 2); + let flags = u16_at(wd, 10); + let csw = usize::from(u16_at(wd, 32)); + let w_lw = 34 + csw * 2; + let cslw = usize::from(u16_at(wd, w_lw)); + let lw = w_lw + 2; + let w_fc = lw + cslw * 4; + let cb = usize::from(u16_at(wd, w_fc)); + let mut pairs = Vec::with_capacity(cb.min(512)); + for i in 0..cb.min(512) { + let p = w_fc + 2 + 8 * i; + if p + 8 > wd.len() { + break; + } + pairs.push((u32_at(wd, p), u32_at(wd, p + 4))); + } + let lwv = |i: usize| i64::from(u32_at(wd, lw + 4 * i) as i32); + if cslw < 15 { + return Err(Error::Malformed( + "the file information block is too short".into(), + )); + } + Ok(Fib { + n_fib, + which_table: flags & 0x0200 != 0, + encrypted: flags & 0x0100 != 0, + ccp_text: lwv(3).max(0), + ccp_ftn: lwv(4).max(0), + ccp_hdd: lwv(5).max(0), + ccp_atn: lwv(7).max(0), + ccp_edn: lwv(8).max(0), + ccp_txbx: lwv(9).max(0), + ccp_hdr_txbx: lwv(10).max(0), + pairs, + }) + } + + /// The (offset, size) of a structure in the table stream; (0, 0) when the + /// file has no such pair. + pub fn at(&self, i: usize) -> (usize, usize) { + self.pairs + .get(i) + .map(|p| (p.0 as usize, p.1 as usize)) + .unwrap_or((0, 0)) + } + + /// Total characters of all the parts of the document. + pub fn total_cp(&self) -> i64 { + let parts = self.ccp_text + + self.ccp_ftn + + self.ccp_hdd + + self.ccp_atn + + self.ccp_edn + + self.ccp_txbx + + self.ccp_hdr_txbx; + // One more paragraph mark closes the last non-empty part. + parts + i64::from(parts > self.ccp_text) + } +} + +/// One piece of text: characters `cp0..cp1`, at byte `fc` of the +/// WordDocument stream. +#[derive(Clone, Debug)] +pub struct Piece { + pub cp0: u32, + pub cp1: u32, + pub fc: u32, + pub compressed: bool, + pub prm: u16, +} + +impl Piece { + pub fn unit(&self) -> u32 { + if self.compressed { 1 } else { 2 } + } + + pub fn fc_of(&self, cp: u32) -> u32 { + self.fc + .saturating_add((cp - self.cp0).saturating_mul(self.unit())) + } +} + +pub struct Clx { + pub pieces: Vec, + /// The grpprls of Prm1 modifiers. + pub prcs: Vec>, +} + +pub fn read_clx(tbl: &[u8], off: usize, len: usize) -> Result { + let d = tbl + .get(off..off.saturating_add(len).min(tbl.len())) + .ok_or_else(|| Error::Malformed("the piece table is outside the table stream".into()))?; + let mut c = Cur::new(d); + let mut prcs = vec![]; + loop { + match c.u8() { + 1 => { + let n = usize::from(c.u16()); + prcs.push(c.bytes(n).to_vec()); + if prcs.len() > 1 << 15 { + return Err(Error::Malformed("too many property modifier groups".into())); + } + } + 2 => break, + _ => { + return Err(Error::Malformed( + "no piece table in the table stream".into(), + )); + } + } + if c.short { + return Err(Error::Malformed("the piece table is cut short".into())); + } + } + let lcb = c.u32() as usize; + let plc = c.bytes(lcb); + // n + 1 CPs of 4 bytes and n Pcd of 8 bytes. + let n = plc.len().saturating_sub(4) / 12; + let mut pieces = Vec::with_capacity(n); + for i in 0..n { + let cp0 = u32_at(plc, 4 * i); + let cp1 = u32_at(plc, 4 * (i + 1)); + let pcd = 4 * (n + 1) + 8 * i; + let fcc = u32_at(plc, pcd + 2); + let prm = u16_at(plc, pcd + 6); + let compressed = fcc & 0x4000_0000 != 0; + let fc = fcc & 0x3FFF_FFFF; + if cp1 < cp0 { + return Err(Error::Malformed("pieces out of order".into())); + } + pieces.push(Piece { + cp0, + cp1, + fc: if compressed { fc / 2 } else { fc }, + compressed, + prm, + }); + } + if pieces.is_empty() { + return Err(Error::Malformed("the document has no text".into())); + } + Ok(Clx { pieces, prcs }) +} + +const CP1252_HIGH: [u16; 32] = [ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, + 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, +]; + +/// The text of every piece as UTF-16 code units, indexed by CP. +pub fn read_text(wd: &[u8], pieces: &[Piece], total: usize) -> Vec { + read_text_in(wd, pieces, total, None) +} + +/// [`read_text`], the 8-bit characters in a code page of 256 entries rather +/// than Windows 1252 (a Word 6 or 95 file keeps the document's own). +pub fn read_text_in( + wd: &[u8], + pieces: &[Piece], + total: usize, + table: Option<&[u16; 256]>, +) -> Vec { + let mut out = vec![0x0Du16; total]; + for p in pieces { + let (a, b) = (p.cp0 as usize, (p.cp1 as usize).min(total)); + if a >= b { + continue; + } + let fc = p.fc as usize; + for (k, slot) in out[a..b].iter_mut().enumerate() { + *slot = if p.compressed { + match wd.get(fc + k) { + Some(&c) if table.is_some_and(|_| c >= 0x80) => { + table.map_or(u16::from(c), |t| t[usize::from(c)]) + } + Some(&c) if (0x80..0xA0).contains(&c) => CP1252_HIGH[usize::from(c - 0x80)], + Some(&c) => u16::from(c), + None => 0x0D, + } + } else { + match wd.get(fc + 2 * k..fc + 2 * k + 2) { + Some(&[x, y]) => u16::from_le_bytes([x, y]), + _ => 0x0D, + } + }; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn piece_table_with_both_encodings() { + // Clx: one piece of 3 compressed characters at byte 0x100, one of 2 + // UTF-16 characters at 0x200. + let mut plc = vec![]; + for cp in [0u32, 3, 5] { + plc.extend_from_slice(&cp.to_le_bytes()); + } + for (fc, compressed) in [(0x100u32, true), (0x200, false)] { + plc.extend_from_slice(&[0, 0]); + let v = if compressed { + (fc * 2) | 0x4000_0000 + } else { + fc + }; + plc.extend_from_slice(&v.to_le_bytes()); + plc.extend_from_slice(&[0, 0]); + } + let mut clx = vec![2u8]; + clx.extend_from_slice(&(plc.len() as u32).to_le_bytes()); + clx.extend_from_slice(&plc); + let c = read_clx(&clx, 0, clx.len()).unwrap(); + assert_eq!(c.pieces.len(), 2); + assert_eq!((c.pieces[0].fc, c.pieces[0].compressed), (0x100, true)); + let mut wd = vec![0u8; 0x300]; + wd[0x100..0x103].copy_from_slice(&[b'a', 0x93, b'c']); + wd[0x200..0x204].copy_from_slice(&[0x41, 0x26, 0x42, 0x00]); + let t = read_text(&wd, &c.pieces, 5); + assert_eq!(t, vec![0x61, 0x201C, 0x63, 0x2641, 0x42]); + } + + #[test] + fn a_piece_table_without_pieces_is_refused() { + assert!(read_clx(&[2, 4, 0, 0, 0, 0, 0, 0, 0], 0, 9).is_err()); + assert!(read_clx(&[7], 0, 1).is_err()); + } +} diff --git a/crates/exav-render/src/formats/office/docs/doc/lists.rs b/crates/exav-render/src/formats/office/docs/doc/lists.rs new file mode 100644 index 00000000..8a7a27d9 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/doc/lists.rs @@ -0,0 +1,370 @@ +//! Lists ([MS-DOC] 2.9.147 to 2.9.150, 2.9.200, 2.9.201): list definitions +//! and the list instances paragraphs refer to. + +use super::props::{ChpAux, Ctx, PapAux, apply_chp_list, apply_pap_list}; +use crate::formats::office::common::bytes::{Cur, u16_at, u32_at}; +use crate::formats::office::docs::model::*; + +/// The ST_NumberFormat of an MSONFC ([MS-OSHARED] 2.2.1.3). +pub fn number_format(nfc: u8) -> &'static str { + const NAMES: [&str; 0x3C] = [ + "decimal", + "upperRoman", + "lowerRoman", + "upperLetter", + "lowerLetter", + "ordinal", + "cardinalText", + "ordinalText", + "hex", + "chicago", + "ideographDigital", + "japaneseCounting", + "aiueo", + "iroha", + "decimalFullWidth", + "decimalHalfWidth", + "japaneseLegal", + "japaneseDigitalTenThousand", + "decimalEnclosedCircle", + "decimalFullWidth2", + "aiueoFullWidth", + "irohaFullWidth", + "decimalZero", + "bullet", + "ganada", + "chosung", + "decimalEnclosedFullstop", + "decimalEnclosedParen", + "decimalEnclosedCircleChinese", + "ideographEnclosedCircle", + "ideographTraditional", + "ideographZodiac", + "ideographZodiacTraditional", + "taiwaneseCounting", + "ideographLegalTraditional", + "taiwaneseCountingThousand", + "taiwaneseDigital", + "chineseCounting", + "chineseLegalSimplified", + "chineseCountingThousand", + "decimal", + "koreanDigital", + "koreanCounting", + "koreanLegal", + "koreanDigital2", + "hebrew1", + "arabicAlpha", + "hebrew2", + "arabicAbjad", + "hindiVowels", + "hindiConsonants", + "hindiNumbers", + "hindiCounting", + "thaiLetters", + "thaiNumbers", + "thaiCounting", + "vietnameseCounting", + "numberInDash", + "russianLower", + "russianUpper", + ]; + match nfc { + 0xFF => "none", + n => NAMES.get(usize::from(n)).copied().unwrap_or("decimal"), + } +} + +/// A bullet written in a symbol font, as Unicode text a font of any kind +/// can show. +fn bullet(ch: u16, font: Option<&str>) -> (String, bool) { + let low = ch & 0x0FFF; + let f = font.unwrap_or("").to_ascii_lowercase(); + let map = |c: u16| -> Option { + Some(match (f.as_str(), c) { + ("symbol", 0xB7) => '\u{2022}', + ("symbol", 0xA8) => '\u{2666}', + ("symbol", 0xD8) => '\u{21D2}', + ("symbol", 0xAE) => '\u{2192}', + ("wingdings", 0xA7) => '\u{25AA}', + ("wingdings", 0xA8) => '\u{25FB}', + ("wingdings", 0x76) => '\u{2756}', + ("wingdings", 0xD8) => '\u{27A2}', + ("wingdings", 0xFC) => '\u{2714}', + ("wingdings", 0x6E) => '\u{25A0}', + ("wingdings", 0x71) => '\u{2751}', + ("wingdings", 0x6C) => '\u{25CF}', + ("wingdings", 0xFB) => '\u{2717}', + ("wingdings", 0x9F) => '\u{2022}', + ("wingdings", 0xA1) => '\u{25CB}', + ("wingdings", 0x77) => '\u{2B27}', + _ => return None, + }) + }; + if (ch & 0xF000 != 0 || f == "symbol" || f == "wingdings") + && let Some(c) = map(low) + { + return (c.to_string(), true); + } + ( + char::from_u32(u32::from(if ch & 0xF000 != 0 { low } else { ch })) + .unwrap_or('\u{2022}') + .to_string(), + false, + ) +} + +fn read_lvl(d: &[u8], p: &mut usize, cx: &Ctx, style: Option) -> Option { + let b = d.get(*p..*p + 28)?; + let start = i64::from(u32_at(b, 0) as i32); + let nfc = b[4]; + let flags = b[5]; + let nums = &b[6..15]; + let follow = b[15]; + let cb_chpx = usize::from(b[24]); + let cb_papx = usize::from(b[25]); + let restart_lim = b[26]; + let mut c = Cur::at(d, *p + 28); + let papx = c.bytes(cb_papx); + let chpx = c.bytes(cb_chpx); + let cch = usize::from(c.u16()); + let xst = c.bytes(cch * 2); + if c.short { + return None; + } + *p = c.p; + let units: Vec = xst + .as_chunks::<2>() + .0 + .iter() + .map(|x| u16::from_le_bytes(*x)) + .collect(); + let mut rpr = RunProps::default(); + let mut caux = ChpAux::default(); + apply_chp_list(&mut rpr, &mut caux, chpx, cx); + let mut ppr = ParaProps::default(); + let mut paux = PapAux::default(); + apply_pap_list(&mut ppr, &mut paux, papx); + let fmt = number_format(nfc); + let text = if nfc == 0x17 { + let (t, mapped) = bullet( + units.first().copied().unwrap_or(0x2022), + rpr.fonts.ascii.as_deref().or(rpr.fonts.hansi.as_deref()), + ); + if mapped { + rpr.fonts = Fonts::default(); + } + t + } else { + let mut s = String::new(); + for (k, u) in units.iter().enumerate() { + let mark = (k + 1) as u8; + if nums.contains(&mark) && *u < 9 { + s.push_str(&format!("%{}", u + 1)); + } else if let Some(ch) = char::from_u32(u32::from(*u)) { + s.push(ch); + } + } + s + }; + Some(Level { + start, + fmt: if nfc == 0xFF { "none" } else { fmt }, + text, + jc: match flags & 3 { + 1 => Jc::Center, + 2 => Jc::Right, + _ => Jc::Left, + }, + suff: match follow { + 1 => "space", + 2 => "nothing", + _ => "tab", + }, + style, + ppr, + rpr, + legal: flags & 4 != 0, + // Restarts after the levels more significant than the limit. + restart: (flags & 8 != 0).then_some(u32::from(restart_lim)), + }) +} + +pub struct Lists { + pub numbering: Numbering, +} + +/// Reads the list definitions and instances. `style_id` names the style an +/// istd stands for. +pub fn read( + tbl: &[u8], + lst: (usize, usize), + lfo: (usize, usize), + cx: &Ctx, + style_id: &dyn Fn(u16) -> Option, +) -> Lists { + let mut out = Numbering::default(); + let mut lsids = vec![]; + if lst.1 >= 2 { + // The level definitions follow the list definitions, past the size + // the file's pair gives. + let d = tbl.get(lst.0..).unwrap_or(&[]); + let n = usize::from(u16_at(d, 0)).min(4096); + let mut p = 2 + 28 * n; + for i in 0..n { + let b = match d.get(2 + 28 * i..2 + 28 * i + 28) { + Some(b) => b, + None => break, + }; + let lsid = u32_at(b, 0) as i32; + let simple = b[26] & 1 != 0; + let styles: Vec = (0..9).map(|k| u16_at(b, 8 + 2 * k)).collect(); + let nlvl = if simple { 1 } else { 9 }; + let mut levels = vec![]; + for k in 0..nlvl { + let st = styles + .get(k) + .filter(|s| **s != 0x0FFF) + .and_then(|s| style_id(*s)); + match read_lvl(d, &mut p, cx, st) { + Some(l) => levels.push(l), + None => break, + } + } + lsids.push(lsid); + out.abstracts.push(AbstractNum { + id: i as u32, + multi_level: !simple, + levels, + }); + } + } + if lfo.1 >= 4 { + let d = tbl + .get(lfo.0..lfo.0.saturating_add(lfo.1).min(tbl.len())) + .unwrap_or(&[]); + let n = (u32_at(d, 0) as usize).min(8192); + let mut p = 4 + 16 * n; + for i in 0..n { + let Some(b) = d.get(4 + 16 * i..4 + 16 * i + 16) else { + break; + }; + let lsid = u32_at(b, 0) as i32; + let clfolvl = usize::from(b[12]); + let abstract_id = lsids + .iter() + .position(|l| *l == lsid) + .map(|x| x as u32) + .unwrap_or(0); + p += 4; + let mut overrides = vec![]; + for _ in 0..clfolvl { + if p + 8 > d.len() { + break; + } + let start = i64::from(u32_at(d, p) as i32); + let fl = u32_at(d, p + 4); + let lvl_no = fl & 0xF; + p += 8; + let has_start = fl & 0x10 != 0; + let has_fmt = fl & 0x20 != 0; + let def = if has_fmt { + read_lvl(d, &mut p, cx, None) + } else { + None + }; + let start_over = (has_start && !has_fmt).then_some(start); + overrides.push((lvl_no, start_over, def)); + } + out.nums.push(Num { + id: i as u32 + 1, + abstract_id, + overrides, + }); + } + } + Lists { numbering: out } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lvl(nfc: u8, text: &[u16], nums: &[u8], follow: u8) -> Vec { + let mut b = vec![0u8; 28]; + b[0] = 1; + b[4] = nfc; + b[6..6 + nums.len()].copy_from_slice(nums); + b[15] = follow; + let mut v = b; + v.extend_from_slice(&(text.len() as u16).to_le_bytes()); + for u in text { + v.extend_from_slice(&u.to_le_bytes()); + } + v + } + + #[test] + fn levels_become_number_text() { + let fonts = vec![]; + let base = RunProps::default(); + let cx = Ctx { + fonts: &fonts, + base: &base, + }; + let v = lvl(0, &[0, 0x2E, 1], &[1], 0); + let mut p = 0; + let l = read_lvl(&v, &mut p, &cx, None).unwrap(); + assert_eq!(l.text, "%1.\u{1}"); + let v = lvl(0, &[0, b'.' as u16], &[1], 1); + let mut p = 0; + let l = read_lvl(&v, &mut p, &cx, None).unwrap(); + assert_eq!( + (l.text.as_str(), l.fmt, l.suff), + ("%1.", "decimal", "space") + ); + let v = lvl(0x17, &[0xF0B7], &[], 0); + let mut p = 0; + let l = read_lvl(&v, &mut p, &cx, None).unwrap(); + assert_eq!(l.fmt, "bullet"); + assert_eq!(l.text, "\u{B7}"); + } + + #[test] + fn definitions_and_instances() { + let fonts = vec![]; + let base = RunProps::default(); + let cx = Ctx { + fonts: &fonts, + base: &base, + }; + // One simple list (lsid 7) with one level; one LFO using it with a start override. + let mut lst = 1u16.to_le_bytes().to_vec(); + let mut lstf = vec![0u8; 28]; + lstf[0] = 7; + lstf[26] = 1; + for k in 0..9 { + lstf[8 + 2 * k] = 0xFF; + lstf[9 + 2 * k] = 0x0F; + } + lst.extend_from_slice(&lstf); + lst.extend_from_slice(&lvl(4, &[0, b')' as u16], &[1], 0)); + let mut lfo = 1u32.to_le_bytes().to_vec(); + let mut e = vec![0u8; 16]; + e[0] = 7; + e[12] = 1; + lfo.extend_from_slice(&e); + lfo.extend_from_slice(&[0xFF; 4]); + lfo.extend_from_slice(&5u32.to_le_bytes()); + lfo.extend_from_slice(&0x10u32.to_le_bytes()); + let mut t = lst.clone(); + let lfo_at = t.len(); + t.extend_from_slice(&lfo); + let l = read(&t, (0, lst.len()), (lfo_at, lfo.len()), &cx, &|_| None); + assert_eq!(l.numbering.abstracts.len(), 1); + assert_eq!(l.numbering.abstracts[0].levels[0].fmt, "lowerLetter"); + assert_eq!(l.numbering.nums.len(), 1); + assert_eq!(l.numbering.nums[0].abstract_id, 0); + assert_eq!(l.numbering.nums[0].overrides[0].1, Some(5)); + } +} diff --git a/crates/exav-render/src/formats/office/docs/doc/mod.rs b/crates/exav-render/src/formats/office/docs/doc/mod.rs new file mode 100644 index 00000000..95d6502a --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/doc/mod.rs @@ -0,0 +1,573 @@ +//! Word 6 to 2003 binary documents ([MS-DOC]; Word 6 and 95 in `word6`) to the +//! document model. + +mod draw; +mod fib; +pub(crate) mod lists; +mod props; +mod sprm; +mod story; +mod styles; +mod tables; +mod word6; + +use crate::formats::office::common::bytes::{u16_at, u32_at}; +use crate::formats::office::docs::error::{Error, Result}; +use crate::formats::office::docs::limits::Budget; +use crate::formats::office::docs::model::*; +use exav_unpack::cfb::Compound; +use fib::{Fib, Piece, idx}; +use std::collections::HashMap; + +/// An FKP entry: the bytes `lo..hi` of the WordDocument stream and the +/// modifier list that applies to them. +#[derive(Clone, Copy)] +pub(crate) struct Run { + pub lo: u32, + pub hi: u32, + pub off: u32, + pub len: u16, +} + +/// A Plc: positions and the fixed-size data between them. +pub(crate) struct Plc { + pub cps: Vec, + pub data: Vec>, +} + +pub(crate) fn read_plc(tbl: &[u8], off: usize, len: usize, size: usize) -> Plc { + let d = tbl + .get(off..off.saturating_add(len).min(tbl.len())) + .unwrap_or(&[]); + let n = if size == 0 { + (d.len() / 4).saturating_sub(1) + } else { + d.len().saturating_sub(4) / (4 + size) + }; + let n = n.min(1 << 22); + let cps = (0..=n).map(|i| u32_at(d, 4 * i)).collect(); + let data = if size == 0 { + vec![] + } else { + (0..n) + .map(|i| { + let s = 4 * (n + 1) + size * i; + d.get(s..s + size).unwrap_or(&[]).to_vec() + }) + .collect() + }; + Plc { cps, data } +} + +/// A shape anchor ([MS-DOC] 2.9.253). +#[derive(Clone, Debug)] +pub(crate) struct Spa { + pub cp: u32, + pub lid: u32, + pub rect: [i32; 4], + pub flags: u16, +} + +pub(crate) struct SectionSpan { + pub cp1: u32, + pub grpprl: Vec, +} + +pub(crate) struct Reader<'a> { + pub b: &'a mut Budget, + pub doc: Document, + pub wd: Vec, + pub tbl: Vec, + pub data: Vec, + pub fib: Fib, + pub pieces: Vec, + pub prcs: Vec>, + pub text: Vec, + pub chpx: Vec, + pub papx: Vec, + pub fonts: Vec, + pub stsh: styles::Stsh, + pub style_ids: Vec>, + pub rpr_cache: HashMap, + pub ppr_cache: HashMap, + pub sections: Vec, + pub ftn_ref: Vec, + pub ftn_txt: Vec, + pub edn_ref: Vec, + pub edn_txt: Vec, + pub atn_ref: Vec<(u32, String, usize)>, + pub atn_txt: Vec, + pub owners: Vec, + /// (cp, is end, id) of bookmark and comment-range marks, by cp. + pub marks: Vec<(u32, u8, u32)>, + pub spa_main: Vec, + pub spa_hdr: Vec, + pub txbx_main: Plc, + pub txbx_hdr: Plc, + pub hdd: Plc, + pub bookmark_names: Vec, + pub depth: u32, + pub facing: bool, + pub rpr_delta: HashMap, + /// Sections not yet put on the paragraph that ends them. + pub sect_ready: Vec>, + /// A Word 6 or 95 file, whose pictures and tables sit in the main stream. + pub word6: bool, +} + +pub(crate) fn read(input: &[u8], budget: &mut Budget) -> Result { + let cf = Compound::open(input).ok_or_else(|| Error::NotFormat("not a compound file".into()))?; + let max = budget.limits.max_part as usize; + let wd = cf + .stream(&["WordDocument"]) + .ok_or_else(|| Error::NotFormat("no WordDocument stream".into()))?; + if wd.len() > max { + return Err(Error::TooLarge("the WordDocument stream")); + } + if matches!(u16_at(&wd, 0), 0xA5DB | 0xA5DC) { + return word6::read(wd, budget); + } + let fib = Fib::read(&wd)?; + // XOR obfuscation (fObfuscated) is only set together with fEncrypted. + if fib.encrypted { + return Err(Error::Encrypted); + } + if fib.n_fib < 0xC1 { + return Err(Error::Unsupported(format!( + "Word version {:#06x}", + fib.n_fib + ))); + } + let tbl = cf + .stream(&[if fib.which_table { "1Table" } else { "0Table" }]) + .ok_or_else(|| Error::Malformed("no table stream".into()))?; + if tbl.len() > max { + return Err(Error::TooLarge("the table stream")); + } + let data = cf.stream(&["Data"]).unwrap_or_default(); + budget.alloc((wd.len() + tbl.len() + data.len()) as u64)?; + let (clx_off, clx_len) = fib.at(idx::CLX); + let clx = fib::read_clx(&tbl, clx_off, clx_len)?; + let total = usize::try_from(fib.total_cp()).unwrap_or(usize::MAX); + let last = clx.pieces.iter().map(|p| p.cp1 as usize).max().unwrap_or(0); + let total = total + .min(last) + .max(fib.ccp_text.max(0) as usize) + .min(last.max(1)); + if total > 1 << 28 { + return Err(Error::TooLarge("characters")); + } + budget.alloc(2 * total as u64)?; + let text = fib::read_text(&wd, &clx.pieces, total); + let mut r = Reader::new(budget, wd, tbl, data, fib, clx, text); + r.load()?; + r.convert()?; + Ok(r.doc) +} + +impl<'a> Reader<'a> { + /// A reader of the streams and text, with nothing else read yet. + pub(crate) fn new( + b: &'a mut Budget, + wd: Vec, + tbl: Vec, + data: Vec, + fib: Fib, + clx: fib::Clx, + text: Vec, + ) -> Reader<'a> { + let empty = || Plc { + cps: vec![], + data: vec![], + }; + Reader { + b, + doc: Document::default(), + wd, + tbl, + data, + fib, + pieces: clx.pieces, + prcs: clx.prcs, + text, + chpx: vec![], + papx: vec![], + fonts: vec![], + stsh: styles::Stsh::default(), + style_ids: vec![], + rpr_cache: HashMap::new(), + ppr_cache: HashMap::new(), + sections: vec![], + ftn_ref: vec![], + ftn_txt: vec![], + edn_ref: vec![], + edn_txt: vec![], + atn_ref: vec![], + atn_txt: vec![], + owners: vec![], + marks: vec![], + spa_main: vec![], + spa_hdr: vec![], + txbx_main: empty(), + txbx_hdr: empty(), + hdd: empty(), + bookmark_names: vec![], + depth: 0, + facing: false, + rpr_delta: HashMap::new(), + sect_ready: vec![], + word6: false, + } + } +} + +fn utf16(b: &[u8]) -> String { + let u: Vec = b + .as_chunks::<2>() + .0 + .iter() + .map(|x| u16::from_le_bytes(*x)) + .collect(); + String::from_utf16_lossy(&u) +} + +impl Reader<'_> { + fn load(&mut self) -> Result<()> { + self.read_fonts(); + let (o, l) = self.fib.at(idx::STSHF); + self.stsh = styles::read(&self.tbl, o, l); + self.read_fkps(); + self.read_dop(); + self.read_sections(); + self.read_notes(); + self.read_bookmarks(); + self.read_shapes_table(); + let (o, l) = self.fib.at(idx::PLCF_HDD); + self.hdd = read_plc(&self.tbl, o, l, 0); + Ok(()) + } + + fn read_fonts(&mut self) { + let (o, l) = self.fib.at(idx::STTBF_FFN); + let Some(d) = self.tbl.get(o..o.saturating_add(l).min(self.tbl.len())) else { + return; + }; + if d.len() < 4 { + return; + } + // An STTB without the extended header: cData, cbExtra, then FFNs. + let n = usize::from(u16_at(d, 0)); + let mut p = 2usize; + let cb_extra = usize::from(u16_at(d, 2)); + let _ = cb_extra; + p += 2; + for _ in 0..n.min(4096) { + let Some(&cb) = d.get(p) else { break }; + let cb = usize::from(cb); + let ffn = d.get(p + 1..p + 1 + cb).unwrap_or(&[]); + // FFN: flags, weight (2), charset, alt name index, panose (10), + // font signature (24), then the name. + let name = ffn.get(39..).unwrap_or(&[]); + let end = name + .as_chunks::<2>() + .0 + .iter() + .position(|c| *c == [0, 0]) + .map(|k| k * 2) + .unwrap_or(name.len() & !1); + self.fonts.push(utf16(&name[..end])); + p += 1 + cb; + } + } + + /// The FKP pages of character and paragraph formatting, as sorted runs. + fn read_fkps(&mut self) { + for (which, para) in [(idx::PLCF_BTE_CHPX, false), (idx::PLCF_BTE_PAPX, true)] { + let (o, l) = self.fib.at(which); + let plc = read_plc(&self.tbl, o, l, 4); + let mut runs = vec![]; + for pn in plc.data.iter().map(|d| u32_at(d, 0) & 0x3F_FFFF) { + let base = pn as usize * 512; + let Some(page) = self.wd.get(base..base + 512) else { + continue; + }; + let n = usize::from(page[511]); + if n == 0 || 4 * (n + 1) + n > 511 { + continue; + } + for k in 0..n { + let lo = u32_at(page, 4 * k); + let hi = u32_at(page, 4 * (k + 1)); + // The modifier list's place in the page, if it has one. + let at = if para { + let bx = 4 * (n + 1) + 13 * k; + let o2 = usize::from(page.get(bx).copied().unwrap_or(0)) * 2; + if o2 == 0 || o2 >= 511 { + None + } else { + let cb = usize::from(page[o2]); + if cb == 0 { + let cb2 = usize::from(page.get(o2 + 1).copied().unwrap_or(0)); + Some((o2 + 2, cb2 * 2)) + } else { + Some((o2 + 1, cb * 2 - 1)) + } + } + } else { + let o2 = usize::from(page[4 * (n + 1) + k]) * 2; + if o2 == 0 || o2 >= 511 { + None + } else { + Some((o2 + 1, usize::from(page[o2]))) + } + }; + let (off, len) = match at { + // The list must lie in the page. + Some((o, l)) => (base + o, l.min(512 - o)), + None => (0, 0), + }; + if hi > lo { + runs.push(Run { + lo, + hi, + off: off as u32, + len: len as u16, + }); + } + } + } + runs.sort_by_key(|r| r.lo); + if para { + self.papx = runs; + } else { + self.chpx = runs; + } + } + } + + fn read_dop(&mut self) { + let (o, l) = self.fib.at(idx::DOP); + let Some(d) = self.tbl.get(o..o.saturating_add(l).min(self.tbl.len())) else { + return; + }; + if d.len() < 12 { + return; + } + self.facing = d[0] & 1 != 0; + self.doc.settings.even_and_odd_headers = self.facing; + let tab = i32::from(u16_at(d, 10)); + if tab > 0 { + self.doc.settings.default_tab_stop = Some(tab); + } + } + + fn read_sections(&mut self) { + let (o, l) = self.fib.at(idx::PLCF_SED); + let plc = read_plc(&self.tbl, o, l, 12); + for (i, d) in plc.data.iter().enumerate() { + let fc = u32_at(d, 2); + let grpprl = if fc == 0xFFFF_FFFF { + vec![] + } else { + let cb = usize::from(u16_at(&self.wd, fc as usize)); + self.wd + .get(fc as usize + 2..fc as usize + 2 + cb) + .unwrap_or(&[]) + .to_vec() + }; + self.sections.push(SectionSpan { + cp1: plc.cps[i + 1], + grpprl, + }); + } + if self.sections.is_empty() { + self.sections.push(SectionSpan { + cp1: self.fib.ccp_text as u32, + grpprl: vec![], + }); + } + } + + fn read_notes(&mut self) { + let f = &self.fib; + let base_ftn = fib::cp(f.ccp_text); + let base_atn = fib::cp(f.ccp_text + f.ccp_ftn + f.ccp_hdd); + let base_edn = fib::cp(f.ccp_text + f.ccp_ftn + f.ccp_hdd + f.ccp_atn); + let (o, l) = self.fib.at(idx::PLCFFND_REF); + self.ftn_ref = read_plc(&self.tbl, o, l, 2).cps; + self.ftn_ref.pop(); + let (o, l) = self.fib.at(idx::PLCFFND_TXT); + self.ftn_txt = read_plc(&self.tbl, o, l, 0) + .cps + .iter() + .map(|c| c.saturating_add(base_ftn)) + .collect(); + let (o, l) = self.fib.at(idx::PLCFEND_REF); + self.edn_ref = read_plc(&self.tbl, o, l, 2).cps; + self.edn_ref.pop(); + let (o, l) = self.fib.at(idx::PLCFEND_TXT); + self.edn_txt = read_plc(&self.tbl, o, l, 0) + .cps + .iter() + .map(|c| c.saturating_add(base_edn)) + .collect(); + // Comments: reference marks with initials and author index. + let (o, l) = self.fib.at(idx::GRP_XST_ATN_OWNERS); + if let Some(d) = self.tbl.get(o..o.saturating_add(l).min(self.tbl.len())) { + let mut p = 0usize; + // An STTB of authors: the extended header, then strings. + if u16_at(d, 0) == 0xFFFF { + let n = usize::from(u16_at(d, 2)); + p = 6; + for _ in 0..n.min(1024) { + let cch = usize::from(u16_at(d, p)); + let s = d.get(p + 2..p + 2 + cch * 2).unwrap_or(&[]); + self.owners.push(utf16(s)); + p += 2 + cch * 2; + } + } else { + while p + 2 <= d.len() && self.owners.len() < 1024 { + let cch = usize::from(u16_at(d, p)); + let s = d.get(p + 2..p + 2 + cch * 2).unwrap_or(&[]); + self.owners.push(utf16(s)); + p += 2 + cch * 2; + } + } + } + let (o, l) = self.fib.at(idx::PLCFAND_REF); + let plc = read_plc(&self.tbl, o, l, 30); + for (i, d) in plc.data.iter().enumerate() { + let cch = usize::from(u16_at(d, 0)).min(9); + let initials = utf16(d.get(2..2 + cch * 2).unwrap_or(&[])); + let ibst = usize::from(u16_at(d, 20)); + self.atn_ref.push((plc.cps[i], initials, ibst)); + } + let (o, l) = self.fib.at(idx::PLCFAND_TXT); + self.atn_txt = read_plc(&self.tbl, o, l, 0) + .cps + .iter() + .map(|c| c.saturating_add(base_atn)) + .collect(); + } + + fn read_bookmarks(&mut self) { + let (o, l) = self.fib.at(idx::STTBF_BKMK); + if let Some(d) = self.tbl.get(o..o.saturating_add(l).min(self.tbl.len())) + && u16_at(d, 0) == 0xFFFF + && d.len() >= 6 + { + let n = usize::from(u16_at(d, 2)); + let extra = usize::from(u16_at(d, 4)); + let mut p = 6usize; + for _ in 0..n.min(1 << 16) { + let cch = usize::from(u16_at(d, p)); + let s = d.get(p + 2..p + 2 + cch * 2).unwrap_or(&[]); + self.bookmark_names.push(utf16(s)); + p += 2 + cch * 2 + extra; + } + } + let (o, l) = self.fib.at(idx::PLCF_BKF); + let bkf = read_plc(&self.tbl, o, l, 6); + let (o, l) = self.fib.at(idx::PLCF_BKL); + let bkl = read_plc(&self.tbl, o, l, 0); + for (i, d) in bkf.data.iter().enumerate() { + let Some(name) = self.bookmark_names.get(i) else { + continue; + }; + if name.starts_with('_') && !name.starts_with("_Toc") && !name.starts_with("_Ref") { + continue; + } + let ibkl = u32_at(d, 0) as usize; + let (Some(&start), Some(&end)) = (bkf.cps.get(i), bkl.cps.get(ibkl)) else { + continue; + }; + let id = i as u32; + self.marks.push((start, 0, id)); + self.marks.push((end.max(start), 1, id)); + } + // Comment ranges. + let (o, l) = self.fib.at(idx::PLCF_ATN_BKF); + let bkf = read_plc(&self.tbl, o, l, 6); + let (o, l) = self.fib.at(idx::PLCF_ATN_BKL); + let bkl = read_plc(&self.tbl, o, l, 0); + for (i, d) in bkf.data.iter().enumerate() { + let ibkl = u32_at(d, 0) as usize; + let (Some(&start), Some(&end)) = (bkf.cps.get(i), bkl.cps.get(ibkl)) else { + continue; + }; + // The i-th range belongs to the comment with the same bookmark tag; + // the files pair them by index in practice. + let id = 1_000_000 + i as u32; + self.marks.push((start, 2, id)); + self.marks.push((end.max(start), 3, id)); + } + self.marks.sort_by_key(|m| (m.0, m.1)); + } + + fn read_shapes_table(&mut self) { + for (which, hdr) in [(idx::PLC_SPA_MOM, false), (idx::PLC_SPA_HDR, true)] { + let (o, l) = self.fib.at(which); + let plc = read_plc(&self.tbl, o, l, 26); + let mut v = vec![]; + for (i, d) in plc.data.iter().enumerate() { + let rect = [ + u32_at(d, 4) as i32, + u32_at(d, 8) as i32, + u32_at(d, 12) as i32, + u32_at(d, 16) as i32, + ]; + v.push(Spa { + cp: plc.cps[i], + lid: u32_at(d, 0), + rect, + flags: u16_at(d, 20), + }); + } + if hdr { + self.spa_hdr = v; + } else { + self.spa_main = v; + } + } + let (o, l) = self.fib.at(idx::PLCFTXBX_TXT); + self.txbx_main = read_plc(&self.tbl, o, l, 22); + let (o, l) = self.fib.at(idx::PLCF_HDR_TXBX_TXT); + self.txbx_hdr = read_plc(&self.tbl, o, l, 22); + } + + /// Base of each document part in CP space. + pub(crate) fn base_hdd(&self) -> u32 { + (self.fib.ccp_text + self.fib.ccp_ftn) as u32 + } + + pub(crate) fn base_txbx(&self) -> u32 { + (self.fib.ccp_text + + self.fib.ccp_ftn + + self.fib.ccp_hdd + + self.fib.ccp_atn + + self.fib.ccp_edn) as u32 + } + + pub(crate) fn base_hdr_txbx(&self) -> u32 { + self.base_txbx() + self.fib.ccp_txbx as u32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plc_splits_positions_and_data() { + let mut d = vec![]; + for cp in [0u32, 5, 9] { + d.extend_from_slice(&cp.to_le_bytes()); + } + d.extend_from_slice(&[1, 2, 3, 4]); + let p = read_plc(&d, 0, d.len(), 2); + assert_eq!(p.cps, vec![0, 5, 9]); + assert_eq!(p.data, vec![vec![1, 2], vec![3, 4]]); + let p = read_plc(&d[..12], 0, 12, 0); + assert_eq!(p.cps, vec![0, 5, 9]); + } +} diff --git a/crates/exav-render/src/formats/office/docs/doc/props.rs b/crates/exav-render/src/formats/office/docs/doc/props.rs new file mode 100644 index 00000000..9d66b285 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/doc/props.rs @@ -0,0 +1,870 @@ +//! Character, paragraph and section modifiers ([MS-DOC] 2.6) applied to the +//! model's property structs. + +use super::sprm::{Prl, parse}; +use crate::formats::office::common::bytes::Cur; +use crate::formats::office::docs::model::*; + +/// The 16 colours of an Ico ([MS-DOC] 2.9.119); 0 is automatic. +pub fn ico(i: u8) -> Color { + const P: [u32; 17] = [ + 0, 0x000000, 0x0000FF, 0x00FFFF, 0x00FF00, 0xFF00FF, 0xFF0000, 0xFFFF00, 0xFFFFFF, + 0x000080, 0x008080, 0x008000, 0x800080, 0x800000, 0x808000, 0x808080, 0xC0C0C0, + ]; + match i { + 1..=16 => Color::Rgb(P[usize::from(i)]), + _ => Color::Auto, + } +} + +/// A COLORREF: red, green, blue, then 0xFF when automatic. +pub fn colorref(v: u32) -> Color { + if v >> 24 == 0xFF { + return Color::Auto; + } + let (r, g, b) = (v & 0xFF, (v >> 8) & 0xFF, (v >> 16) & 0xFF); + Color::Rgb(r << 16 | g << 8 | b) +} + +fn highlight(i: u8) -> Option<&'static str> { + Some(match i { + 0 => "none", + 1 => "black", + 2 => "blue", + 3 => "cyan", + 4 => "green", + 5 => "magenta", + 6 => "red", + 7 => "yellow", + 8 => "white", + 9 => "darkBlue", + 10 => "darkCyan", + 11 => "darkGreen", + 12 => "darkMagenta", + 13 => "darkRed", + 14 => "darkYellow", + 15 => "darkGray", + 16 => "lightGray", + _ => return None, + }) +} + +fn underline(k: u8) -> Option<&'static str> { + Some(match k { + 0 => "none", + 1 => "single", + 2 => "words", + 3 => "double", + 4 => "dotted", + 6 => "thick", + 7 => "dash", + 9 => "dotDash", + 10 => "dotDotDash", + 11 => "wave", + 0x14 => "dottedHeavy", + 0x17 => "dashedHeavy", + 0x19 => "dashDotHeavy", + 0x1A => "dashDotDotHeavy", + 0x1B => "wavyHeavy", + 0x27 => "dashLongHeavy", + 0x2B => "wavyDouble", + _ => "single", + }) +} + +fn border_style(t: u8) -> Option<&'static str> { + Some(match t { + 0 => return None, + 1 | 5 => "single", + 3 => "double", + 6 => "dotted", + 7 => "dashed", + 8 => "dotDash", + 9 => "dotDotDash", + 10 => "triple", + 11 => "thinThickSmallGap", + 12 => "thickThinSmallGap", + 13 => "thinThickThinSmallGap", + 14 => "thinThickMediumGap", + 15 => "thickThinMediumGap", + 16 => "thinThickThinMediumGap", + 17 => "thinThickLargeGap", + 18 => "thickThinLargeGap", + 19 => "thinThickThinLargeGap", + 20 => "wave", + 21 => "doubleWave", + 22 => "dashSmallGap", + 23 => "dashDotStroked", + 24 => "threeDEmboss", + 25 => "threeDEngrave", + 26 => "outset", + 27 => "inset", + 0xFF => return None, + _ => "single", + }) +} + +/// A Brc80 (4 bytes). `None` for no border. +pub fn brc80(b: &[u8]) -> Option { + if b.len() < 4 || b[..4] == [0xFF; 4] { + return None; + } + let style = border_style(b[1])?; + let size = u32::from(b[0]).clamp(2, 96); + Some(Border { + style, + size, + space: u32::from(b[3] & 0x1F), + color: ico(b[2]), + shadow: b[3] & 0x20 != 0, + }) +} + +/// A Brc (8 bytes). `None` for no border. +pub fn brc(b: &[u8]) -> Option { + if b.len() < 8 || b[4..8] == [0xFF; 4] { + return None; + } + let style = border_style(b[5])?; + let w = u32::from(b[4]); + let size = if b[5] >= 0x40 { w * 8 } else { w }.clamp(2, 96); + let flags = u16::from_le_bytes([b[6], b[7]]); + Some(Border { + style, + size, + space: u32::from(flags & 0x1F), + color: colorref(u32::from_le_bytes([b[0], b[1], b[2], b[3]])), + shadow: flags & 0x20 != 0, + }) +} + +fn rgb_of(c: Color, auto: u32) -> u32 { + match c { + Color::Rgb(v) => v, + Color::Auto => auto, + } +} + +/// The share of the foreground colour in a shading pattern. +fn coverage(ipat: u16) -> f64 { + match ipat { + 0 => 0.0, + 1 => 1.0, + 2 => 0.05, + 3 => 0.10, + 4 => 0.20, + 5 => 0.25, + 6 => 0.30, + 7 => 0.40, + 8 => 0.50, + 9 => 0.60, + 10 => 0.70, + 11 => 0.75, + 12 => 0.80, + 13 => 0.90, + 14..=19 => 0.5, + 20..=25 => 0.25, + 0x23 => 0.025, + 0x24 => 0.075, + 0x25 => 0.125, + 0x26 => 0.15, + 0x27 => 0.175, + 0x28 => 0.225, + 0x29 => 0.275, + 0x2A => 0.325, + 0x2B => 0.35, + 0x2C => 0.375, + 0x2D => 0.425, + 0x2E => 0.45, + 0x2F => 0.475, + 0x30 => 0.525, + 0x31 => 0.55, + 0x32 => 0.575, + 0x33 => 0.625, + 0x34 => 0.65, + 0x35 => 0.675, + 0x36 => 0.725, + 0x37 => 0.775, + 0x38 => 0.825, + 0x39 => 0.85, + 0x3A => 0.875, + 0x3B => 0.925, + 0x3C => 0.95, + 0x3D => 0.975, + _ => 0.0, + } +} + +/// The flat colour a pattern of `fore` on `back` looks like; `None` for +/// no shading. +pub fn blend(fore: Color, back: Color, ipat: u16) -> Option { + if ipat == 0xFFFF { + return None; + } + let p = coverage(ipat); + if p == 0.0 && back == Color::Auto { + return None; + } + let (f, b) = (rgb_of(fore, 0x000000), rgb_of(back, 0xFFFFFF)); + let mix = |s: u32| { + let (x, y) = (f64::from((f >> s) & 0xFF), f64::from((b >> s) & 0xFF)); + (x * p + y * (1.0 - p)).round() as u32 + }; + Some(Shading::fill(Color::Rgb( + mix(16) << 16 | mix(8) << 8 | mix(0), + ))) +} + +/// A Shd (10 bytes). +pub fn shd(b: &[u8]) -> Option { + if b.len() < 10 { + return None; + } + let f = colorref(u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let k = colorref(u32::from_le_bytes([b[4], b[5], b[6], b[7]])); + blend(f, k, u16::from_le_bytes([b[8], b[9]])) +} + +/// A Shd80 (2 bytes). +pub fn shd80(v: u16) -> Option { + if v == 0xFFFF { + return None; + } + blend( + ico((v & 0x1F) as u8), + ico(((v >> 5) & 0x1F) as u8), + (v >> 10) & 0x3F, + ) +} + +/// What a character modifier list says beyond formatting. +#[derive(Clone, Debug, Default)] +pub struct ChpAux { + pub pic_loc: Option, + pub spec: bool, + pub data: bool, + pub ole2: bool, + pub obj: bool, + pub symbol: Option<(u16, u16)>, + /// A character style set by sprmCIstd. + pub istd: Option, + pub fld_vanish: bool, + pub deleted: bool, + pub inserted: bool, +} + +/// What a paragraph modifier list says beyond formatting. +#[derive(Clone, Debug, Default)] +pub struct PapAux { + pub istd: Option, + pub in_table: bool, + pub ttp: bool, + pub depth: Option, + pub inner_cell: bool, + pub inner_ttp: bool, + /// The list instance a paragraph is in, when the modifiers set one. + pub ilfo_set: Option, + pub ilvl: u8, + pub huge: Option, + pub frame: Option, + pub drop_cap: Option<(u8, u8)>, + pub tabs_cleared: Vec, +} + +/// Font names by ftc, and the style values toggles are relative to. +pub struct Ctx<'a> { + pub fonts: &'a [String], + /// The style-level properties of the text a toggle is relative to. + pub base: &'a RunProps, +} + +impl Ctx<'_> { + fn font(&self, i: u16) -> Option { + self.fonts.get(usize::from(i)).cloned() + } +} + +/// A ToggleOperand: 0 off, 1 on, 0x80 as the style, 0x81 as the style's +/// opposite. +fn toggle(v: u8, base: Option, cur: &mut Option) { + match v { + 0 => *cur = Some(false), + 1 => *cur = Some(true), + 0x80 => *cur = None, + 0x81 => *cur = Some(!base.unwrap_or(false)), + _ => {} + } +} + +fn bool8(v: u8) -> Option { + Some(v & 1 != 0) +} + +/// Applies a character modifier to `rp`. +pub fn apply_chp(rp: &mut RunProps, aux: &mut ChpAux, p: &Prl, cx: &Ctx) { + let b = cx.base; + match p.op { + 0x0800 => aux.deleted = p.u8() != 0, + 0x0801 => aux.inserted = p.u8() != 0, + 0x0802 => aux.fld_vanish = p.u8() & 1 != 0, + 0x6A03 => aux.pic_loc = Some(p.i32()), + 0x0806 => aux.data = p.u8() & 1 != 0, + 0x080A => aux.ole2 = p.u8() & 1 != 0, + 0x0855 => aux.spec = p.u8() & 1 != 0, + 0x0856 => aux.obj = p.u8() & 1 != 0, + 0x6A09 => { + let mut c = Cur::new(p.val); + let ftc = c.u16(); + aux.symbol = Some((ftc, c.u16())); + } + 0x4A30 => aux.istd = Some(p.u16()), + 0x2A33 => { + // sprmCPlain: the direct formatting is cleared. + let style = rp.style.take(); + *rp = RunProps::default(); + rp.style = style; + } + 0x0835 => toggle(p.u8(), b.bold, &mut rp.bold), + 0x0836 => toggle(p.u8(), b.italic, &mut rp.italic), + 0x0837 => toggle(p.u8(), b.strike, &mut rp.strike), + 0x0838 => toggle(p.u8(), b.outline, &mut rp.outline), + 0x0839 => toggle(p.u8(), b.shadow, &mut rp.shadow), + 0x083A => toggle(p.u8(), b.small_caps, &mut rp.small_caps), + 0x083B => toggle(p.u8(), b.caps, &mut rp.caps), + 0x083C => toggle(p.u8(), b.vanish, &mut rp.vanish), + 0x0854 => toggle(p.u8(), b.imprint, &mut rp.imprint), + 0x0858 => toggle(p.u8(), b.emboss, &mut rp.emboss), + 0x2A53 => toggle(p.u8(), b.dstrike, &mut rp.dstrike), + 0x085C => toggle(p.u8(), b.bold_cs, &mut rp.bold_cs), + 0x085D => toggle(p.u8(), b.italic_cs, &mut rp.italic_cs), + 0x085A => rp.rtl = bool8(p.u8()), + 0x0882 => rp.cs = bool8(p.u8()), + 0x2A3E => rp.underline = underline(p.u8()), + 0x8840 => rp.spacing = Some(i32::from(p.i16())), + 0x2A42 => rp.color = Some(ico(p.u8())), + 0x6870 => rp.color = Some(colorref(p.u32())), + 0x2A0C => rp.highlight = highlight(p.u8()), + 0x4A43 => rp.size = Some(u32::from(p.u16())), + 0x4A61 => rp.size_cs = Some(u32::from(p.u16())), + 0x4845 => rp.position = Some(i32::from(p.i16())), + 0x2A48 => { + rp.vert_align = Some(match p.u8() { + 1 => VertAlign::Superscript, + 2 => VertAlign::Subscript, + _ => VertAlign::Baseline, + }) + } + 0x484B => rp.kerning = Some(u32::from(p.u16())), + 0x4852 => rp.scale = Some(u32::from(p.u16()).clamp(1, 600)), + 0x4A4F => rp.fonts.ascii = cx.font(p.u16()), + 0x4A50 => rp.fonts.east_asia = cx.font(p.u16()), + 0x4A51 => rp.fonts.hansi = cx.font(p.u16()), + 0x4A5E => rp.fonts.cs = cx.font(p.u16()), + 0x6865 => rp.border = brc80(p.val), + 0xCA72 => rp.border = brc(p.var()), + 0x4866 => rp.shading = shd80(p.u16()), + 0xCA71 => rp.shading = shd(p.var()), + 0x6877 => rp.underline_color = Some(colorref(p.u32())), + 0x4873 | 0x4874 => {} + _ => {} + } +} + +/// Applies a list of character modifiers. +pub fn apply_chp_list(rp: &mut RunProps, aux: &mut ChpAux, grpprl: &[u8], cx: &Ctx) { + for p in parse(grpprl) { + if p.sgc() == super::sprm::SGC_CHAR { + apply_chp(rp, aux, &p, cx); + } + } +} + +fn tab_kind(tbd: u8) -> TabKind { + match tbd & 7 { + 1 => TabKind::Center, + 2 => TabKind::Right, + 3 => TabKind::Decimal, + 4 => TabKind::Bar, + _ => TabKind::Left, + } +} + +fn tab_leader(tbd: u8) -> TabLeader { + match (tbd >> 3) & 7 { + 1 => TabLeader::Dot, + 2 => TabLeader::Hyphen, + 3 => TabLeader::Underscore, + 4 => TabLeader::Heavy, + 5 => TabLeader::MiddleDot, + _ => TabLeader::None, + } +} + +/// sprmPChgTabs and sprmPChgTabsPapx: positions to clear, then to add. +fn change_tabs(pp: &mut ParaProps, aux: &mut PapAux, v: &[u8], close: bool) { + let mut c = Cur::new(v); + let del = usize::from(c.u8()); + let mut dels = vec![]; + for _ in 0..del.min(64) { + dels.push(i32::from(c.i16())); + } + if close { + c.skip(2 * del.min(64)); + } + let add = usize::from(c.u8()).min(64); + let mut poss = vec![]; + for _ in 0..add { + poss.push(i32::from(c.i16())); + } + let mut tabs = vec![]; + for pos in poss { + let tbd = c.u8(); + tabs.push(Tab { + pos, + kind: tab_kind(tbd), + leader: tab_leader(tbd), + }); + } + for d in dels { + if !tabs.iter().any(|t| (t.pos - d).abs() < 25) { + pp.tabs.retain(|t| (t.pos - d).abs() >= 25); + aux.tabs_cleared.push(d); + pp.tabs.push(Tab { + pos: d, + kind: TabKind::Clear, + leader: TabLeader::None, + }); + } + } + for t in tabs { + pp.tabs.retain(|x| x.pos != t.pos); + pp.tabs.push(t); + } + pp.tabs.sort_by_key(|t| t.pos); +} + +/// Table nesting beyond this is cut: one table builder is open per level. +const MAX_TABLE_DEPTH: u32 = 63; + +/// Applies a paragraph modifier to `pp`. +pub fn apply_pap(pp: &mut ParaProps, aux: &mut PapAux, p: &Prl) { + match p.op { + 0x4600 => aux.istd = Some(p.u16()), + 0x2403 | 0x2461 => { + pp.jc = Some(match p.u8() { + 1 => Jc::Center, + 2 => Jc::Right, + 3..=5 | 7 => Jc::Both, + 8 => Jc::Distribute, + _ => Jc::Left, + }) + } + 0x2405 => pp.keep_lines = bool8(p.u8()), + 0x2406 => pp.keep_next = bool8(p.u8()), + 0x2407 => pp.page_break_before = bool8(p.u8()), + 0x260A => aux.ilvl = p.u8(), + 0x460B => { + let v = i32::from(p.i16()); + // 0xF801 stands for no list. + aux.ilfo_set = Some(if v == -2047 { 0 } else { v }); + } + 0x240C => pp.suppress_line_numbers = bool8(p.u8()), + 0xC60D => change_tabs(pp, aux, p.var(), false), + 0xC615 => change_tabs(pp, aux, p.var(), true), + 0x840E | 0x845D => pp.ind_right = Some(i32::from(p.i16())), + 0x840F | 0x845E => pp.ind_left = Some(i32::from(p.i16())), + 0x8411 | 0x8460 => { + let v = i32::from(p.i16()); + if v >= 0 { + pp.ind_first_line = Some(v); + pp.ind_hanging = None; + } else { + pp.ind_first_line = None; + pp.ind_hanging = Some(-v); + } + } + 0x6412 => { + let mut c = Cur::new(p.val); + let dya = c.i16(); + let mult = c.u16(); + if mult == 1 { + pp.line = Some(i32::from(dya).max(0)); + pp.line_rule = Some(LineRule::Auto); + } else if dya < 0 { + pp.line = Some(-i32::from(dya)); + pp.line_rule = Some(LineRule::Exact); + } else { + pp.line = Some(i32::from(dya)); + pp.line_rule = Some(LineRule::AtLeast); + } + } + 0xA413 => pp.space_before = Some(i32::from(p.u16())), + 0xA414 => pp.space_after = Some(i32::from(p.u16())), + 0x245B => pp.before_auto = bool8(p.u8()), + 0x245C => pp.after_auto = bool8(p.u8()), + 0x2416 => aux.in_table = p.u8() & 1 != 0, + 0x2417 => aux.ttp = p.u8() & 1 != 0, + 0x6649 => aux.depth = Some(p.u32().min(MAX_TABLE_DEPTH)), + 0x664A => { + // sprmPDtap: a change of table depth. + let d = aux.depth.unwrap_or(0) as i64 + i64::from(p.i32()); + aux.depth = Some(d.clamp(0, i64::from(MAX_TABLE_DEPTH)) as u32); + } + 0x244B => aux.inner_cell = p.u8() & 1 != 0, + 0x244C => aux.inner_ttp = p.u8() & 1 != 0, + 0x6646 => aux.huge = Some(p.u32()), + 0x2431 => pp.widow_control = bool8(p.u8()), + 0x2441 => pp.bidi = bool8(p.u8()), + 0x246D => pp.contextual_spacing = bool8(p.u8()), + 0x2640 => pp.outline_level = Some(u32::from(p.u8().min(9))), + 0x6424..=0x6428 => { + let b = brc80(p.val); + match p.op { + 0x6424 => pp.borders.top = b, + 0x6425 => pp.borders.left = b, + 0x6426 => pp.borders.bottom = b, + 0x6427 => pp.borders.right = b, + _ => pp.borders.between = b, + } + } + 0xC64E..=0xC652 => { + let b = brc(p.var()); + match p.op { + 0xC64E => pp.borders.top = b, + 0xC64F => pp.borders.left = b, + 0xC650 => pp.borders.bottom = b, + 0xC651 => pp.borders.right = b, + _ => pp.borders.between = b, + } + } + 0x442D => pp.shading = shd80(p.u16()), + 0xC64D => pp.shading = shd(p.var()), + 0x442C => { + // sprmPDcs: drop cap kind in the low 3 bits, lines in the next 5. + let v = p.u16(); + aux.drop_cap = Some(((v & 7) as u8, ((v >> 3) & 0x1F) as u8)); + } + 0x261B => { + let v = p.u8(); + let f = aux.frame.get_or_insert_with(Frame::default); + f.v_anchor = Some(match (v >> 4) & 3 { + 0 => "margin", + 1 => "page", + _ => "text", + }); + f.h_anchor = Some(match (v >> 6) & 3 { + 0 => "text", + 1 => "margin", + _ => "page", + }); + } + 0x8418 => { + let v = p.i16(); + let f = aux.frame.get_or_insert_with(Frame::default); + match v { + 0 => f.x_align = Some("left"), + -4 => f.x_align = Some("center"), + -8 => f.x_align = Some("right"), + -12 => f.x_align = Some("inside"), + -16 => f.x_align = Some("outside"), + v => f.x = Some(i32::from(v) - 1), + } + } + 0x8419 => { + let v = p.i16(); + let f = aux.frame.get_or_insert_with(Frame::default); + match v { + 0 => {} + -4 => f.y_align = Some("top"), + -8 => f.y_align = Some("center"), + -12 => f.y_align = Some("bottom"), + -16 => f.y_align = Some("inside"), + -20 => f.y_align = Some("outside"), + v => f.y = Some(i32::from(v) - 1), + } + } + 0x841A => { + let v = p.u16(); + let f = aux.frame.get_or_insert_with(Frame::default); + if v > 0 { + f.width = Some(i32::from(v)); + } + } + 0x442B => { + let v = p.u16(); + let f = aux.frame.get_or_insert_with(Frame::default); + if v & 0x7FFF != 0 { + f.height = Some(i32::from(v & 0x7FFF)); + f.height_exact = v & 0x8000 != 0; + } + } + 0x2423 => { + let f = aux.frame.get_or_insert_with(Frame::default); + f.wrap = Some(match p.u8() { + 1 => "notBeside", + 2 => "around", + 3 => "none", + 4 => "tight", + 5 => "through", + _ => "auto", + }); + } + 0x842E => { + let v = i32::from(p.i16()); + aux.frame.get_or_insert_with(Frame::default).v_space = Some(v); + } + 0x842F => { + let v = i32::from(p.i16()); + aux.frame.get_or_insert_with(Frame::default).h_space = Some(v); + } + _ => {} + } +} + +pub fn apply_pap_list(pp: &mut ParaProps, aux: &mut PapAux, grpprl: &[u8]) { + for p in parse(grpprl) { + if p.sgc() == super::sprm::SGC_PARA { + apply_pap(pp, aux, &p); + } + } +} + +/// Applies the section modifiers of `grpprl` to `s`. +pub fn apply_sep(s: &mut Section, grpprl: &[u8]) { + let mut restart = false; + let mut start: Option = None; + let mut cols: Option = None; + let mut widths: Vec<(usize, i32, i32)> = vec![]; + for p in parse(grpprl) { + match p.op { + 0x3009 => { + s.kind = match p.u8() { + 0 => SectionType::Continuous, + 1 => SectionType::NextColumn, + 3 => SectionType::EvenPage, + 4 => SectionType::OddPage, + _ => SectionType::NextPage, + } + } + 0x300A => s.title_page = p.u8() & 1 != 0, + 0x500B => cols = Some(u32::from(p.u16()) + 1), + 0x900C => s.columns.space = i32::from(p.u16()), + 0x3019 => s.columns.separator = p.u8() & 1 != 0, + 0x300E => { + s.page_fmt = Some(match p.u8() { + 1 => "upperRoman", + 2 => "lowerRoman", + 3 => "upperLetter", + 4 => "lowerLetter", + _ => "decimal", + }) + } + 0x3011 => restart = p.u8() & 1 != 0, + 0x501C | 0x7044 => { + start = Some(if p.op == 0x501C { + u32::from(p.u16()) + } else { + p.u32() + }); + } + 0xB017 => s.header = i32::from(p.u16()), + 0xB018 => s.footer = i32::from(p.u16()), + 0x301A => { + s.v_align = match p.u8() { + 1 => Some("center"), + 2 => Some("both"), + 3 => Some("bottom"), + _ => None, + } + } + 0x301D => s.landscape = p.u8() == 2, + 0xB01F => s.width = i32::from(p.u16()), + 0xB020 => s.height = i32::from(p.u16()), + 0xB021 => s.left = i32::from(p.u16()), + 0xB022 => s.right = i32::from(p.u16()), + 0x9023 => s.top = i32::from(p.i16()), + 0x9024 => s.bottom = i32::from(p.i16()), + 0xB025 => s.gutter = i32::from(p.u16()), + 0x3228 => s.bidi = p.u8() & 1 != 0, + 0x322A => s.rtl_gutter = p.u8() & 1 != 0, + 0x702B => s.page_borders.top = brc80(p.val), + 0x702C => s.page_borders.left = brc80(p.val), + 0x702D => s.page_borders.bottom = brc80(p.val), + 0x702E => s.page_borders.right = brc80(p.val), + 0xD234 => s.page_borders.top = brc(p.var()), + 0xD235 => s.page_borders.left = brc(p.var()), + 0xD236 => s.page_borders.bottom = brc(p.var()), + 0xD237 => s.page_borders.right = brc(p.var()), + 0x5015 => { + let n = u32::from(p.u16()); + if n > 0 { + let ln = s.line_numbers.get_or_insert((1, n, 0)); + ln.1 = n; + } + } + 0x9016 => { + if let Some(ln) = s.line_numbers.as_mut() { + ln.2 = i32::from(p.u16()); + } + } + 0x501B => { + if let Some(ln) = s.line_numbers.as_mut() { + ln.0 = u32::from(p.u16()) + 1; + } + } + 0x5033 => { + s.text_direction = match p.u16() { + 1 => Some("tbRl"), + 3 => Some("btLr"), + _ => None, + } + } + _ => {} + } + // sprmSDxaColWidth and sprmSDxaColSpacing: (column, twips). + if matches!(p.op, 0xF203 | 0xF204) + && let [_, i, a, b, ..] = p.val + { + let v = i32::from(u16::from_le_bytes([*a, *b])); + let i = usize::from(*i); + let e = match widths.iter().position(|w| w.0 == i) { + Some(k) => k, + None => { + widths.push((i, 0, 0)); + widths.len() - 1 + } + }; + if p.op == 0xF203 { + widths[e].1 = v; + } else { + widths[e].2 = v; + } + } + } + if restart { + s.page_start = Some(start.unwrap_or(1)); + } + if let Some(n) = cols { + s.columns.num = n; + if !widths.is_empty() { + widths.sort_by_key(|w| w.0); + s.columns.widths = widths.iter().map(|w| (w.1, w.2)).collect(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cx() -> (Vec, RunProps) { + (vec!["Arial".into(), "Times".into()], RunProps::default()) + } + + #[test] + fn character_modifiers() { + let (fonts, base) = cx(); + let c = Ctx { + fonts: &fonts, + base: &base, + }; + let mut rp = RunProps::default(); + let mut aux = ChpAux::default(); + // bold on, 12 pt, font 1, red. + let g = [ + 0x35, 0x08, 1, 0x43, 0x4A, 24, 0, 0x4F, 0x4A, 1, 0, 0x70, 0x68, 0xFF, 0, 0, 0, + ]; + apply_chp_list(&mut rp, &mut aux, &g, &c); + assert_eq!(rp.bold, Some(true)); + assert_eq!(rp.size, Some(24)); + assert_eq!(rp.fonts.ascii.as_deref(), Some("Times")); + assert_eq!(rp.color, Some(Color::Rgb(0xFF0000))); + } + + #[test] + fn toggles_are_relative_to_the_style() { + let fonts = vec![]; + let base = RunProps { + bold: Some(true), + ..RunProps::default() + }; + let c = Ctx { + fonts: &fonts, + base: &base, + }; + let mut rp = RunProps::default(); + let mut aux = ChpAux::default(); + apply_chp_list(&mut rp, &mut aux, &[0x35, 0x08, 0x81], &c); + assert_eq!(rp.bold, Some(false)); + apply_chp_list(&mut rp, &mut aux, &[0x35, 0x08, 0x80], &c); + assert_eq!(rp.bold, None); + } + + #[test] + fn paragraph_modifiers() { + let mut pp = ParaProps::default(); + let mut aux = PapAux::default(); + // centered, left indent 720, first line -348 (hanging), exact 300. + let g = [ + 0x61, 0x24, 1, 0x5E, 0x84, 0xD0, 0x02, 0x60, 0x84, 0xA4, 0xFE, 0x12, 0x64, 0xD4, 0xFE, + 0, 0, + ]; + apply_pap_list(&mut pp, &mut aux, &g); + assert_eq!(pp.jc, Some(Jc::Center)); + assert_eq!(pp.ind_left, Some(720)); + assert_eq!(pp.ind_hanging, Some(348)); + assert_eq!(pp.line, Some(300)); + assert_eq!(pp.line_rule, Some(LineRule::Exact)); + } + + #[test] + fn tab_stops_add_and_clear() { + let mut pp = ParaProps::default(); + let mut aux = PapAux::default(); + // sprmPChgTabsPapx: no deletions, two tabs (1440 right with dot + // leader, then 720 left); the stops end up in order. + let g = [0x0D, 0xC6, 8, 0, 2, 0xA0, 0x05, 0xD0, 0x02, 0x0A, 0x00]; + apply_pap_list(&mut pp, &mut aux, &g); + assert_eq!(pp.tabs.len(), 2); + assert_eq!(pp.tabs[0].pos, 720); + assert_eq!(pp.tabs[1].kind, TabKind::Right); + assert_eq!(pp.tabs[1].leader, TabLeader::Dot); + } + + #[test] + fn colours_and_borders() { + assert_eq!(ico(6), Color::Rgb(0xFF0000)); + assert_eq!(colorref(0xFF00_0000), Color::Auto); + assert_eq!(colorref(0x00_33_22_11), Color::Rgb(0x11_22_33)); + let b = brc80(&[8, 1, 6, 0x32]).unwrap(); + assert_eq!( + (b.style, b.size, b.space, b.shadow), + ("single", 8, 18, true) + ); + assert!(brc80(&[0xFF; 4]).is_none()); + // Yellow behind no pattern; a solid pattern is its foreground. + let s = shd80(0x0008 | (7 << 5)).unwrap(); + assert_eq!(s.fill, Some(Color::Rgb(0xFFFF00))); + let s = shd80(0x0001 | (7 << 5) | (1 << 10)).unwrap(); + assert_eq!(s.fill, Some(Color::Rgb(0x000000))); + // A 50% pattern of black on white is mid grey. + let s = shd80(1 | (8 << 5) | (8 << 10)).unwrap(); + assert_eq!(s.fill, Some(Color::Rgb(0x808080))); + assert!(shd80(0xFFFF).is_none()); + } + + #[test] + fn section_modifiers() { + let mut s = Section::default(); + // page 11906 x 16838 landscape, left margin 1417, two columns, space 708. + let g = [ + 0x1F, 0xB0, 0x82, 0x2E, 0x20, 0xB0, 0xC6, 0x41, 0x1D, 0x30, 2, 0x21, 0xB0, 0x89, 0x05, + 0x0B, 0x50, 1, 0, 0x0C, 0x90, 0xC4, 0x02, + ]; + apply_sep(&mut s, &g); + assert_eq!((s.width, s.height), (11906, 16838)); + assert!(s.landscape); + assert_eq!(s.left, 1417); + assert_eq!((s.columns.num, s.columns.space), (2, 708)); + } +} diff --git a/crates/exav-render/src/formats/office/docs/doc/sprm.rs b/crates/exav-render/src/formats/office/docs/doc/sprm.rs new file mode 100644 index 00000000..3128423a --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/doc/sprm.rs @@ -0,0 +1,140 @@ +//! Property modifiers ([MS-DOC] 2.6): a 16-bit opcode and an operand whose +//! size the opcode's top bits give. + +/// One modifier: its opcode and operand bytes. +#[derive(Clone, Copy, Debug)] +pub struct Prl<'a> { + pub op: u16, + pub val: &'a [u8], +} + +pub const SGC_PARA: u16 = 1; +pub const SGC_CHAR: u16 = 2; +pub const SGC_TABLE: u16 = 5; + +impl<'a> Prl<'a> { + /// 1 paragraph, 2 character, 3 picture, 4 section, 5 table. + pub fn sgc(&self) -> u16 { + (self.op >> 10) & 7 + } + + pub fn u8(&self) -> u8 { + self.val.first().copied().unwrap_or(0) + } + + pub fn u16(&self) -> u16 { + match self.val { + [a, b, ..] => u16::from_le_bytes([*a, *b]), + [a] => u16::from(*a), + _ => 0, + } + } + + pub fn i16(&self) -> i16 { + self.u16() as i16 + } + + pub fn u32(&self) -> u32 { + match self.val { + [a, b, c, d, ..] => u32::from_le_bytes([*a, *b, *c, *d]), + _ => 0, + } + } + + pub fn i32(&self) -> i32 { + self.u32() as i32 + } + + /// The operand after a length prefix (one byte; two for sprmTDefTable). + pub fn var(&self) -> &'a [u8] { + let skip = if self.op == 0xD608 { 2 } else { 1 }; + self.val.get(skip..).unwrap_or(&[]) + } +} + +/// The modifiers of a grpprl. A truncated last one ends the list. +pub fn parse(g: &[u8]) -> Vec> { + let mut out = vec![]; + let mut p = 0usize; + while p + 2 <= g.len() && out.len() < 4096 { + let op = u16::from_le_bytes([g[p], g[p + 1]]); + p += 2; + let rest = &g[p..]; + let n = match op >> 13 { + 0 | 1 => 1, + 2 | 4 | 5 => 2, + 3 => 4, + 7 => 3, + _ => { + if op == 0xD608 { + match rest { + [a, b, ..] => usize::from(u16::from_le_bytes([*a, *b])) + 1, + _ => break, + } + } else if op == 0xC615 && rest.first() == Some(&255) { + // The size is that of the deleted and added tab lists. + let del = usize::from(rest.get(1).copied().unwrap_or(0)); + let add_at = 2 + 4 * del; + let add = usize::from(rest.get(add_at).copied().unwrap_or(0)); + add_at + 1 + 3 * add + } else { + match rest.first() { + Some(&c) => usize::from(c) + 1, + None => break, + } + } + } + }; + if n > rest.len() { + // Operand cut short: keep what is there for fixed sizes only. + break; + } + out.push(Prl { + op, + val: &rest[..n], + }); + p += n; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn operand_sizes_follow_the_opcode() { + // sprmCFBold on, sprmCHps 24, sprmCCv, sprmPChgTabsPapx with 3 bytes. + let g = [ + 0x35, 0x08, 1, 0x43, 0x4A, 24, 0, 0x70, 0x68, 1, 2, 3, 0, 0x0D, 0xC6, 3, 9, 9, 9, + ]; + let p = parse(&g); + assert_eq!(p.len(), 4); + assert_eq!((p[0].op, p[0].u8()), (0x0835, 1)); + assert_eq!(p[1].u16(), 24); + assert_eq!(p[2].val, &[1, 2, 3, 0]); + assert_eq!(p[3].var(), &[9, 9, 9]); + assert_eq!(p[1].sgc(), SGC_CHAR); + } + + #[test] + fn table_definition_has_a_two_byte_size() { + // cb = 5: four more bytes after the size field. + let g = [0x08, 0xD6, 5, 0, 1, 2, 3, 4, 0x35, 0x08, 0]; + let p = parse(&g); + assert_eq!(p.len(), 2); + assert_eq!(p[0].val.len(), 6); + assert_eq!(p[0].var(), &[1, 2, 3, 4]); + assert_eq!(p[1].op, 0x0835); + // The group of an opcode: table, section, paragraph. + assert_eq!(p[0].sgc(), SGC_TABLE); + assert_eq!(parse(&[0x09, 0x30, 1])[0].sgc(), 4); + assert_eq!(parse(&[0x03, 0x24, 1])[0].sgc(), SGC_PARA); + } + + #[test] + fn truncated_operands_end_the_list() { + assert_eq!(parse(&[0x43, 0x4A, 24]).len(), 0); + assert_eq!(parse(&[0x0D, 0xC6, 9, 1]).len(), 0); + } +} diff --git a/crates/exav-render/src/formats/office/docs/doc/story.rs b/crates/exav-render/src/formats/office/docs/doc/story.rs new file mode 100644 index 00000000..c2c49a9a --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/doc/story.rs @@ -0,0 +1,1238 @@ +//! The text of the document parts: paragraphs, runs, fields, tables and the +//! notes, comments, headers and footers around them. + +use super::fib::{self, idx}; +use super::props::*; +use super::sprm::parse; +use super::tables::{Tap, parse_tap}; +use super::{Reader, Run as FkpRun}; +use crate::formats::office::common::bytes::u16_at; +use crate::formats::office::docs::error::Result; +use crate::formats::office::docs::fields::parse_hyperlink; +use crate::formats::office::docs::limits::PARA_BYTES; +use crate::formats::office::docs::model::*; +use std::collections::HashSet; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Story { + Main, + Footnote, + Endnote, + Header, + Comment, + Textbox, +} + +/// The sprm that a Prm0 stands for. +fn prm0_opcode(isprm: u8) -> Option { + Some(match isprm { + 0x00 => 0x2879, + 0x04 => 0x2602, + 0x05 => 0x2461, + 0x07 => 0x2405, + 0x08 => 0x2406, + 0x09 => 0x2407, + 0x0C => 0x260A, + 0x0D => 0x2470, + 0x0E => 0x240C, + 0x0F => 0x2471, + 0x18 => 0x2416, + 0x19 => 0x2417, + 0x1D => 0x261B, + 0x25 => 0x2423, + 0x2C => 0x242A, + 0x32 => 0x2430, + 0x33 => 0x2431, + 0x35 => 0x2433, + 0x36 => 0x2434, + 0x37 => 0x2435, + 0x38 => 0x2436, + 0x39 => 0x2437, + 0x3A => 0x2438, + 0x41 => 0x0800, + 0x42 => 0x0801, + 0x43 => 0x0802, + 0x47 => 0x0806, + 0x4B => 0x080A, + 0x4D => 0x2A0C, + 0x4E => 0x0858, + 0x4F => 0x2859, + 0x50 => 0x0811, + 0x51 => 0x0818, + 0x53 => 0x2A33, + 0x55 => 0x0835, + 0x56 => 0x0836, + 0x57 => 0x0837, + 0x58 => 0x0838, + 0x59 => 0x0839, + 0x5A => 0x083A, + 0x5B => 0x083B, + 0x5C => 0x083C, + 0x5E => 0x2A3E, + 0x62 => 0x2A42, + 0x68 => 0x2A48, + 0x73 => 0x2A53, + 0x74 => 0x0854, + 0x75 => 0x0855, + 0x76 => 0x0856, + 0x78 => 0x2640, + 0x7B => 0x2A90, + 0x7C => 0x2A86, + 0x7E => 0x2443, + _ => return None, + }) +} + +struct Span { + rp: RunProps, + aux: ChpAux, +} + +struct Atom { + cp: u32, + ch: u16, + span: usize, +} + +/// A paragraph found in the text. +struct ParaRec { + depth: u32, + role: Role, + para: Option, + tap: Option, +} + +#[derive(PartialEq, Eq, Clone, Copy)] +enum Role { + Normal, + CellEnd, + RowEnd, +} + +struct TableBuilder { + rows: Vec<(Tap, Vec>)>, + cells: Vec>, + cur: Vec, +} + +/// Deleted and inserted text is drawn in this colour, whoever made the change. +const REVISION_COLOR: u32 = 0xC00000; + +/// The headers or footers `src` has that `dst` has not. +fn inherit(dst: &mut HeaderFooter, src: HeaderFooter, title_page: bool, facing: bool) { + dst.default = dst.default.take().or(src.default); + if title_page { + dst.first = dst.first.take().or(src.first); + } + if facing { + dst.even = dst.even.take().or(src.even); + } +} + +impl Reader<'_> { + pub(crate) fn convert(&mut self) -> Result<()> { + self.build_styles(); + self.build_lists(); + // Sections and their headers first: the body puts each on the + // paragraph that ends it. + let n = self.sections.len(); + for i in 0..n { + let mut s = self.section_props(i); + self.section_stories(i, &mut s)?; + self.sect_ready.push(Some(s)); + } + // A section with no text has no paragraph to carry its headers; the + // next section, which links to them when it has none, takes them. + let mut start = 0; + for i in 0..n.saturating_sub(1) { + let end = self.sections[i].cp1; + if end <= start + && let Some(src) = self.sect_ready[i].take() + && let Some(dst) = self.sect_ready[i + 1].as_mut() + { + inherit(&mut dst.headers, src.headers, dst.title_page, self.facing); + inherit(&mut dst.footers, src.footers, dst.title_page, self.facing); + } + start = end; + } + let ccp = self.fib.ccp_text as u32; + let ccp = ccp.min(self.text.len() as u32); + let body = self.story_blocks(0, ccp, Story::Main)?; + self.doc.body = body; + self.doc.section = self + .sect_ready + .last_mut() + .and_then(|s| s.take()) + .unwrap_or_default(); + self.read_note_stories()?; + self.apply_defaults(); + Ok(()) + } + + fn apply_defaults(&mut self) { + let f = |me: &Reader, i: i16| { + me.fonts + .get(usize::try_from(i).unwrap_or(usize::MAX)) + .cloned() + }; + let s = &self.stsh; + let fonts = Fonts { + ascii: f(self, s.ftc_ascii), + hansi: f(self, s.ftc_other), + east_asia: f(self, s.ftc_fe), + cs: s.ftc_bi.and_then(|b| f(self, b)), + }; + self.doc.defaults_run.fonts = fonts; + } + + // ---- styles ---- + + fn style_id_for(&self, istd: u16) -> Option { + self.style_ids.get(usize::from(istd)).cloned().flatten() + } + + fn build_styles(&mut self) { + let n = self.stsh.stds.len(); + let mut used: HashSet = HashSet::new(); + let mut ids = vec![None; n]; + for (i, s) in self.stsh.stds.iter().enumerate() { + let Some(s) = s else { continue }; + let base = match (i, s.sti) { + (_, 0) => "Normal".to_string(), + (_, 1..=9) => format!("Heading{}", s.sti), + (_, 65) => "DefaultParagraphFont".to_string(), + (_, 105) => "TableNormal".to_string(), + (_, 107) => "NoList".to_string(), + _ => { + let c: String = s + .name + .split(',') + .next() + .unwrap_or("") + .chars() + .filter(|c| c.is_alphanumeric()) + .collect(); + if c.is_empty() { + format!("Style{i}") + } else if c.chars().next().is_some_and(|c| c.is_ascii_digit()) { + format!("S{c}") + } else { + c + } + } + }; + let mut id = base.clone(); + let mut k = 2; + while !used.insert(id.clone()) { + id = format!("{base}{k}"); + k += 1; + } + ids[i] = Some(id); + } + self.style_ids = ids; + let mut out = vec![]; + for i in 0..n { + let Some(std) = self.stsh.stds[i].as_ref() else { + continue; + }; + let kind = match std.kind { + 1 => StyleKind::Paragraph, + 2 => StyleKind::Character, + 3 => StyleKind::Table, + 4 => StyleKind::Numbering, + _ => continue, + }; + let name = std.name.split(',').next().unwrap_or("").to_string(); + let name = match std.sti { + 0 => "Normal".to_string(), + 1..=9 => format!("heading {}", std.sti), + 65 => "Default Paragraph Font".to_string(), + 105 => "Normal Table".to_string(), + 107 => "No List".to_string(), + _ => name, + }; + let (std_base, std_next, std_link, std_sti) = (std.base, std.next, std.link, std.sti); + let id = self.style_ids[i].clone().unwrap_or_default(); + let mut st = Style::new(&id, &name, kind); + st.based_on = (std_base != 0x0FFF && usize::from(std_base) != i) + .then(|| self.style_id_for(std_base)) + .flatten(); + st.next = (usize::from(std_next) != i) + .then(|| self.style_id_for(std_next)) + .flatten(); + st.link = (std_link != 0) + .then(|| self.style_id_for(std_link)) + .flatten(); + st.default = matches!( + (std_sti, kind), + (0, StyleKind::Paragraph) + | (65, StyleKind::Character) + | (105, StyleKind::Table) + | (107, StyleKind::Numbering) + ); + st.q_format = std_sti <= 9; + let (_, delta) = self.style_rpr(i as u16, 0); + st.rpr = delta; + st.ppr = self.style_ppr(i as u16); + if kind == StyleKind::Table { + st.tbl = Some(TableProps::default()); + } + out.push(st); + } + self.doc.styles = out; + } + + /// The character properties of a style: all of them (own and inherited), + /// and the ones it sets itself. + fn style_rpr(&mut self, istd: u16, depth: u32) -> (RunProps, RunProps) { + if let Some(r) = self.rpr_cache.get(&istd) { + let d = self.rpr_delta.get(&istd).cloned().unwrap_or_default(); + return (r.clone(), d); + } + let Some(std) = self + .stsh + .stds + .get(usize::from(istd)) + .and_then(|s| s.as_ref()) + else { + return (RunProps::default(), RunProps::default()); + }; + let (base_istd, chpx, kind) = (std.base, std.chpx.clone(), std.kind); + let base = if base_istd != 0x0FFF && base_istd != istd && depth < 32 { + self.style_rpr(base_istd, depth + 1).0 + } else { + RunProps::default() + }; + let fonts = self.fonts.clone(); + let cx = Ctx { + fonts: &fonts, + base: &base, + }; + let mut delta = RunProps::default(); + let mut aux = ChpAux::default(); + apply_chp_list(&mut delta, &mut aux, &chpx, &cx); + let _ = kind; + let mut all = base; + all.overlay(&delta); + self.rpr_cache.insert(istd, all.clone()); + self.rpr_delta.insert(istd, delta.clone()); + (all, delta) + } + + /// The paragraph properties a style sets itself. + fn style_ppr(&mut self, istd: u16) -> ParaProps { + let Some(std) = self + .stsh + .stds + .get(usize::from(istd)) + .and_then(|s| s.as_ref()) + else { + return ParaProps::default(); + }; + let papx = std.papx.clone(); + let mut pp = ParaProps::default(); + let mut aux = PapAux::default(); + let g = papx.get(2..).unwrap_or(&[]); + apply_pap_list(&mut pp, &mut aux, g); + if let Some(l) = aux.ilfo_set { + if l > 0 { + pp.num_id = Some(l as u32); + pp.num_level = Some(u32::from(aux.ilvl)); + } else if l == 0 { + pp.num_id = Some(0); + } + } + self.ppr_cache.insert(istd, pp.clone()); + pp + } + + fn style_numbered(&self, istd: u16, depth: u32) -> bool { + let Some(pp) = self.ppr_cache.get(&istd) else { + return false; + }; + if pp.num_id.is_some_and(|n| n > 0) { + return true; + } + if pp.num_id == Some(0) || depth > 32 { + return false; + } + match self + .stsh + .stds + .get(usize::from(istd)) + .and_then(|s| s.as_ref()) + { + Some(s) if s.base != 0x0FFF && s.base != istd => self.style_numbered(s.base, depth + 1), + _ => false, + } + } + + fn build_lists(&mut self) { + let (lo, ll) = self.fib.at(idx::PLF_LST); + let (fo, fl) = self.fib.at(idx::PLF_LFO); + let base = RunProps::default(); + let fonts = self.fonts.clone(); + let ids = self.style_ids.clone(); + let cx = Ctx { + fonts: &fonts, + base: &base, + }; + let lists = super::lists::read(&self.tbl, (lo, ll), (fo, fl), &cx, &|i| { + ids.get(usize::from(i)).cloned().flatten() + }); + self.doc.numbering = lists.numbering; + } + + // ---- sections ---- + + fn section_props(&mut self, i: usize) -> Section { + let mut s = Section { + left: 1800, + right: 1800, + ..Section::default() + }; + let g = self.sections[i].grpprl.clone(); + apply_sep(&mut s, &g); + s.top = s.top.abs(); + s.bottom = s.bottom.abs(); + s + } + + fn section_stories(&mut self, i: usize, s: &mut Section) -> Result<()> { + let base = self.base_hdd(); + for k in 0..6usize { + let n = 6 + 6 * i + k; + let (Some(&a), Some(&b)) = (self.hdd.cps.get(n), self.hdd.cps.get(n + 1)) else { + continue; + }; + if b <= a.saturating_add(1) { + continue; + } + let (Some(lo), Some(hi)) = (base.checked_add(a), base.checked_add(b - 1)) else { + continue; + }; + if hi as usize > self.text.len() { + continue; + } + let blocks = self.story_blocks(lo, hi, Story::Header)?; + if blocks.is_empty() { + continue; + } + match k { + 0 => s.headers.even = Some(blocks), + 1 => s.headers.default = Some(blocks), + 2 => s.footers.even = Some(blocks), + 3 => s.footers.default = Some(blocks), + 4 => s.headers.first = Some(blocks), + _ => s.footers.first = Some(blocks), + } + } + if !s.title_page { + s.headers.first = None; + s.footers.first = None; + } + if !self.facing { + s.headers.even = None; + s.footers.even = None; + } + Ok(()) + } + + // ---- notes and comments ---- + + fn read_note_stories(&mut self) -> Result<()> { + let limit_ftn = (self.fib.ccp_text + self.fib.ccp_ftn) as u32; + for i in 0..self.ftn_ref.len() { + let (Some(&a), Some(&b)) = (self.ftn_txt.get(i), self.ftn_txt.get(i + 1)) else { + break; + }; + let b = b.min(limit_ftn).min(self.text.len() as u32); + if a >= b { + continue; + } + let content = self.story_blocks(a, b, Story::Footnote)?; + self.doc.footnotes.push(Note { + id: i as u32 + 1, + content, + }); + } + let f = &self.fib; + let limit_edn = fib::cp(f.ccp_text + f.ccp_ftn + f.ccp_hdd + f.ccp_atn + f.ccp_edn); + let limit_atn = fib::cp(f.ccp_text + f.ccp_ftn + f.ccp_hdd + f.ccp_atn); + for i in 0..self.edn_ref.len() { + let (Some(&a), Some(&b)) = (self.edn_txt.get(i), self.edn_txt.get(i + 1)) else { + break; + }; + let b = b.min(limit_edn).min(self.text.len() as u32); + if a >= b { + continue; + } + let content = self.story_blocks(a, b, Story::Endnote)?; + self.doc.endnotes.push(Note { + id: i as u32 + 1, + content, + }); + } + for i in 0..self.atn_ref.len() { + let (Some(&a), Some(&b)) = (self.atn_txt.get(i), self.atn_txt.get(i + 1)) else { + break; + }; + let b = b.min(limit_atn).min(self.text.len() as u32); + if a >= b { + continue; + } + let content = self.story_blocks(a, b, Story::Comment)?; + let (_, initials, ibst) = self.atn_ref[i].clone(); + self.doc.comments.push(Comment { + id: i as u32, + author: self.owners.get(ibst).cloned().unwrap_or_default(), + initials, + date: None, + content, + }); + } + Ok(()) + } + + // ---- paragraphs ---- + + fn piece_index(&self, cp: u32) -> Option { + let i = self.pieces.partition_point(|p| p.cp1 <= cp); + (i < self.pieces.len() && self.pieces[i].cp0 <= cp).then_some(i) + } + + /// The byte offset in the WordDocument stream of text at `cp`. + fn fc_at(&self, cp: u32) -> Option { + let i = self.piece_index(cp)?; + Some(self.pieces[i].fc_of(cp)) + } + + fn lookup(runs: &[FkpRun], fc: u32) -> Option<&FkpRun> { + let i = runs.partition_point(|r| r.lo <= fc); + if i == 0 { + return None; + } + let r = &runs[i - 1]; + (fc < r.hi).then_some(r) + } + + /// The modifiers a piece's Prm adds, as a list. + fn prm_grpprl(&self, piece: usize) -> Vec { + let prm = self.pieces[piece].prm; + if prm == 0 { + return vec![]; + } + if prm & 1 != 0 { + return self + .prcs + .get(usize::from(prm >> 1)) + .cloned() + .unwrap_or_default(); + } + let isprm = ((prm >> 1) & 0x7F) as u8; + let val = (prm >> 8) as u8; + match prm0_opcode(isprm) { + Some(op) => vec![op as u8, (op >> 8) as u8, val], + None => vec![], + } + } + + /// Paragraph modifiers at `cp` (the paragraph's last character) and the + /// style they start with. + fn para_grpprl(&self, cp: u32) -> (u16, Vec) { + let Some(fc) = self.fc_at(cp) else { + return (0, vec![]); + }; + let mut istd = 0u16; + let mut g = vec![]; + if let Some(r) = Self::lookup(&self.papx, fc) + && r.len >= 2 + { + let b = &self.wd[r.off as usize..r.off as usize + usize::from(r.len)]; + istd = u16_at(b, 0); + g.extend_from_slice(&b[2..]); + } + if let Some(pi) = self.piece_index(cp) { + g.extend(self.prm_grpprl(pi)); + } + (istd, g) + } + + /// Runs of `a..b` with their character modifier lists. + fn char_runs(&self, a: u32, b: u32) -> Vec<(u32, u32, Vec)> { + let mut out = vec![]; + let mut cp = a; + while cp < b { + let Some(pi) = self.piece_index(cp) else { + break; + }; + let p = &self.pieces[pi]; + let end = b.min(p.cp1); + let prm = self.prm_grpprl(pi); + let mut c = cp; + while c < end { + let fc = p.fc_of(c); + let ri = self.chpx.partition_point(|r| r.hi <= fc); + let (run_end_fc, g) = match self.chpx.get(ri) { + Some(r) if r.lo <= fc => ( + r.hi, + self.wd[r.off as usize..r.off as usize + usize::from(r.len)].to_vec(), + ), + Some(r) => (r.lo, vec![]), + None => (u32::MAX, vec![]), + }; + let n = if run_end_fc == u32::MAX { + end - c + } else { + ((run_end_fc - fc) / p.unit()).max(1).min(end - c) + }; + let mut g = g; + g.extend_from_slice(&prm); + out.push((c, c + n, g)); + c += n; + } + cp = end; + } + out + } + + fn is_section_end(&self, cp_after: u32) -> bool { + self.section_ending_at(cp_after).is_some() + } + + /// The blocks of the text `lo..hi` of one part of the document. + pub(crate) fn story_blocks(&mut self, lo: u32, hi: u32, story: Story) -> Result> { + if self.depth > 8 { + return Ok(vec![]); + } + self.depth += 1; + let r = self.story_blocks_inner(lo, hi, story); + self.depth -= 1; + r + } + + fn story_blocks_inner(&mut self, lo: u32, hi: u32, story: Story) -> Result> { + let hi = hi.min(self.text.len() as u32); + let mut out: Vec = vec![]; + let mut stack: Vec = vec![]; + let mut p = lo; + let in_main = story == Story::Main; + while p < hi { + let mut e = p; + while e < hi { + let c = self.text[e as usize]; + if c == 0x0D || c == 0x07 || (c == 0x0C && in_main && self.is_section_end(e + 1)) { + break; + } + e += 1; + } + let end = e.min(hi - 1); + let mut rec = self.para_rec(p, end, story)?; + if in_main + && let Some(i) = self.section_ending_at(end + 1) + && let (Some(para), Some(s)) = (rec.para.as_mut(), self.sect_ready[i].take()) + { + para.section = Some(Box::new(s)); + } + let first_depth = rec.depth; + // Close tables deeper than this paragraph; open new ones. + while stack.len() as u32 > first_depth { + let Some(t) = stack.pop() else { break }; + let blocks = self.close_table(t); + match stack.last_mut() { + Some(parent) => parent.cur.extend(blocks), + None => out.extend(blocks), + } + } + while (stack.len() as u32) < first_depth { + stack.push(TableBuilder { + rows: vec![], + cells: vec![], + cur: vec![], + }); + } + match (rec.role, rec.para, rec.tap) { + (Role::RowEnd, _, tap) => { + if let Some(t) = stack.last_mut() { + let cells = std::mem::take(&mut t.cells); + t.cur.clear(); + t.rows.push((tap.unwrap_or_default(), cells)); + } + } + (role, Some(para), _) => { + let block = Block::Para(para); + match stack.last_mut() { + Some(t) => { + t.cur.push(block); + if role == Role::CellEnd { + self.b.cell()?; + let cell = std::mem::take(&mut t.cur); + t.cells.push(cell); + } + } + None => out.push(block), + } + } + _ => {} + } + p = end + 1; + } + while let Some(t) = stack.pop() { + let blocks = self.close_table(t); + match stack.last_mut() { + Some(parent) => parent.cur.extend(blocks), + None => out.extend(blocks), + } + } + Ok(out) + } + + /// The section (but the last) that ends before `cp_after`. + fn section_ending_at(&self, cp_after: u32) -> Option { + let n = self.sections.len(); + self.sections + .iter() + .take(n.saturating_sub(1)) + .position(|s| s.cp1 == cp_after) + } + + fn para_rec(&mut self, ps: u32, pe: u32, story: Story) -> Result { + let (istd_papx, g) = self.para_grpprl(pe); + let mut pp = ParaProps::default(); + let mut aux = PapAux::default(); + apply_pap_list(&mut pp, &mut aux, &g); + // A huge PAPX keeps its modifiers in the data stream. + if let Some(off) = aux.huge { + let o = off as usize; + let n = usize::from(u16_at(&self.data, o)); + if let Some(extra) = self.data.get(o + 2..o + 2 + n) { + let extra = extra.to_vec(); + apply_pap_list(&mut pp, &mut aux, &extra); + } + } + let istd = aux.istd.unwrap_or(istd_papx); + let mark = self.text[pe as usize]; + let depth = match aux.depth { + Some(d) => d, + None => u32::from(aux.in_table), + }; + let depth = if depth == 0 && (aux.in_table || aux.ttp) { + 1 + } else { + depth + }; + let ttp = (mark == 0x07 && aux.ttp) || aux.inner_ttp; + let cell_end = depth > 0 && !ttp && (mark == 0x07 || aux.inner_cell); + let role = if ttp { + Role::RowEnd + } else if cell_end { + Role::CellEnd + } else { + Role::Normal + }; + if role == Role::RowEnd { + let tap = parse_tap(&g); + return Ok(ParaRec { + depth, + role, + para: None, + tap: Some(tap), + }); + } + self.b + .paragraph((pe - ps) as usize * 2 + PARA_BYTES as usize / 4)?; + // Style and numbering. + if istd != 0 { + pp.style = self.style_id_for(istd); + } + match aux.ilfo_set { + Some(l) if l > 0 => { + pp.num_id = Some(l as u32); + pp.num_level = Some(u32::from(aux.ilvl)); + } + Some(l) if l < 0 && l > -0x7FE => { + pp.num_id = Some((-l) as u32); + pp.num_level = Some(u32::from(aux.ilvl)); + } + Some(0) => { + if self.style_numbered(istd, 0) { + pp.num_id = Some(0); + } + } + _ => { + if aux.ilvl != 0 && self.style_numbered(istd, 0) { + pp.num_level = Some(u32::from(aux.ilvl)); + } + } + } + if let Some((kind, lines)) = aux.drop_cap { + if kind != 0 { + let f = pp.frame.get_or_insert_with(Frame::default); + f.drop_cap = Some(if kind == 2 { "margin" } else { "drop" }); + f.lines = Some(u32::from(lines.max(1))); + f.wrap = Some("around"); + f.h_anchor = Some("text"); + f.v_anchor = Some("text"); + } + } else if let Some(f) = aux.frame + && (f.width.is_some() || f.x.is_some() || f.x_align.is_some() || f.y.is_some()) + { + pp.frame = Some(f); + } + // The paragraph mark's formatting. + let base = self.base_rpr(istd, None); + let mut mark_rp = RunProps::default(); + let mut mark_aux = ChpAux::default(); + { + let g = self.char_runs(pe, pe + 1); + let fonts = self.fonts.clone(); + let cx = Ctx { + fonts: &fonts, + base: &base, + }; + if let Some((_, _, gr)) = g.first() { + apply_chp_list(&mut mark_rp, &mut mark_aux, gr, &cx); + } + } + if let Some(c) = mark_aux.istd { + mark_rp.style = self.style_id_for(c); + } + // Content. + let content = if pe > ps { + self.inlines_of(ps, pe, istd, story)? + } else { + vec![] + }; + let para = Para { + props: pp, + mark: mark_rp, + content, + section: None, + }; + Ok(ParaRec { + depth, + role, + para: Some(para), + tap: None, + }) + } + + fn base_rpr(&mut self, para_istd: u16, char_istd: Option) -> RunProps { + let mut base = self.style_rpr(para_istd, 0).0; + if let Some(c) = char_istd { + let cr = self.style_rpr(c, 0).0; + base.overlay(&cr); + } + base + } + + // ---- runs ---- + + fn inlines_of( + &mut self, + ps: u32, + pe: u32, + para_istd: u16, + story: Story, + ) -> Result> { + let runs = self.char_runs(ps, pe); + let mut spans = vec![]; + let mut atoms: Vec = Vec::with_capacity((pe - ps) as usize); + let pbase = self.base_rpr(para_istd, None); + let fonts = self.fonts.clone(); + for (a, b, g) in &runs { + let mut rp = RunProps::default(); + let mut aux = ChpAux::default(); + // The character style first: its toggles are the base of the rest. + let istd = parse(g) + .iter() + .rev() + .find(|p| p.op == 0x4A30) + .map(|p| p.u16()); + let base = match istd { + Some(c) => self.base_rpr(para_istd, Some(c)), + None => pbase.clone(), + }; + let cx = Ctx { + fonts: &fonts, + base: &base, + }; + apply_chp_list(&mut rp, &mut aux, g, &cx); + // Tracked changes, as an editor shows them. + if aux.deleted || aux.inserted { + rp.color = Some(Color::Rgb(REVISION_COLOR)); + if aux.deleted { + rp.strike = Some(true); + } else { + rp.underline = Some("single"); + } + } + if let Some(c) = aux.istd { + rp.style = self.style_id_for(c); + } + spans.push(Span { rp, aux }); + let si = spans.len() - 1; + for cp in *a..*b { + atoms.push(Atom { + cp, + ch: self.text[cp as usize], + span: si, + }); + } + } + let mut out = vec![]; + let mut mark = self.marks.partition_point(|m| m.0 < ps); + self.emit(&atoms, &spans, story, &mut out, &mut mark, pe)?; + // Marks at the end of the paragraph. + self.flush_marks(pe, &mut mark, &mut out, true); + Ok(out) + } + + fn flush_marks(&mut self, upto: u32, mark: &mut usize, out: &mut Vec, inclusive: bool) { + while let Some(&(cp, kind, id)) = self.marks.get(*mark) { + if cp > upto || (cp == upto && !inclusive) { + break; + } + *mark += 1; + match kind { + 0 => { + if let Some(n) = self.bookmark_names.get(id as usize) { + out.push(Inline::BookmarkStart { + id, + name: n.clone(), + }); + } + } + 1 => out.push(Inline::BookmarkEnd(id)), + 2 => out.push(Inline::CommentStart(id - 1_000_000)), + _ => out.push(Inline::CommentEnd(id - 1_000_000)), + } + } + } + + fn emit( + &mut self, + atoms: &[Atom], + spans: &[Span], + story: Story, + out: &mut Vec, + mark: &mut usize, + pe: u32, + ) -> Result<()> { + let mut items: Vec = vec![]; + let mut text = String::new(); + let mut cur: Option = None; + macro_rules! flush { + () => { + if !text.is_empty() { + items.push(RunItem::Text(std::mem::take(&mut text))); + } + if !items.is_empty() { + if let Some(s) = cur { + out.push(Inline::Run(Run { + props: spans[s].rp.clone(), + items: std::mem::take(&mut items), + })); + } else { + items.clear(); + } + } + }; + } + let mut i = 0; + while i < atoms.len() { + let a = &atoms[i]; + if self.marks.get(*mark).is_some_and(|m| m.0 <= a.cp) { + flush!(); + self.flush_marks(a.cp, mark, out, true); + } + if cur != Some(a.span) { + flush!(); + cur = Some(a.span); + } + let span = &spans[a.span]; + match a.ch { + 0x13 => { + flush!(); + let (consumed, inl) = self.field(&atoms[i..], spans, story, pe)?; + out.extend(inl); + i += consumed.max(1); + cur = None; + continue; + } + 0x14 => { + flush!(); + out.push(Inline::FieldChar(FieldCharKind::Separate)); + } + 0x15 => { + flush!(); + out.push(Inline::FieldChar(FieldCharKind::End)); + } + 0x01 if span.aux.spec + && !span.aux.data + && let Some(loc) = span.aux.pic_loc => + { + if let Some(d) = self.inline_picture(loc)? { + items.push(RunItem::Drawing(Box::new(d))); + } + } + 0x02 if span.aux.spec || story == Story::Footnote || story == Story::Endnote => { + if let Ok(k) = self.ftn_ref.binary_search(&a.cp) { + items.push(RunItem::FootnoteRef(k as u32 + 1)); + } else if let Ok(k) = self.edn_ref.binary_search(&a.cp) { + items.push(RunItem::EndnoteRef(k as u32 + 1)); + } else if story == Story::Footnote { + items.push(RunItem::NoteMark(false)); + } else if story == Story::Endnote { + items.push(RunItem::NoteMark(true)); + } + } + 0x05 if span.aux.spec => { + if let Some(k) = self.atn_ref.iter().position(|r| r.0 == a.cp) { + items.push(RunItem::CommentRef(k as u32)); + } + } + 0x08 if span.aux.spec => { + if let Some(d) = self.floating_shape(a.cp, story)? { + items.push(RunItem::Drawing(Box::new(d))); + } + } + 0x28 if span.aux.spec + && let Some((ftc, ch)) = span.aux.symbol => + { + let font = self + .fonts + .get(usize::from(ftc)) + .cloned() + .unwrap_or_else(|| "Symbol".into()); + items.push(RunItem::Symbol { font, ch }); + } + 0x09 => { + if !text.is_empty() { + items.push(RunItem::Text(std::mem::take(&mut text))); + } + items.push(RunItem::Tab); + } + 0x0B | 0x0A => { + if !text.is_empty() { + items.push(RunItem::Text(std::mem::take(&mut text))); + } + items.push(RunItem::Break(BreakKind::Line)); + } + 0x0C => { + if !text.is_empty() { + items.push(RunItem::Text(std::mem::take(&mut text))); + } + items.push(RunItem::Break(BreakKind::Page)); + } + 0x0E => { + if !text.is_empty() { + items.push(RunItem::Text(std::mem::take(&mut text))); + } + items.push(RunItem::Break(BreakKind::Column)); + } + 0x1E => { + if !text.is_empty() { + items.push(RunItem::Text(std::mem::take(&mut text))); + } + items.push(RunItem::NoBreakHyphen); + } + 0x1F => { + if !text.is_empty() { + items.push(RunItem::Text(std::mem::take(&mut text))); + } + items.push(RunItem::SoftHyphen); + } + 0x00..=0x1F | 0xFFFE | 0xFFFF => {} + c => { + // A surrogate pair stays whole across atoms. + if (0xD800..0xDC00).contains(&c) { + if let Some(n) = atoms.get(i + 1) + && (0xDC00..0xE000).contains(&n.ch) + { + let ch = char::decode_utf16([c, n.ch]) + .next() + .and_then(|r| r.ok()) + .unwrap_or('\u{FFFD}'); + text.push(ch); + i += 2; + continue; + } + text.push('\u{FFFD}'); + } else if let Some(ch) = char::from_u32(u32::from(c)) { + text.push(ch); + } + } + } + i += 1; + } + flush!(); + Ok(()) + } + + /// A field starting at `atoms[0]` (a field begin mark). Returns the + /// atoms consumed and what to emit. + fn field( + &mut self, + atoms: &[Atom], + spans: &[Span], + story: Story, + pe: u32, + ) -> Result<(usize, Vec)> { + // The matching separator and end. + let mut depth = 0i32; + let mut sep = None; + let mut end = None; + for (k, a) in atoms.iter().enumerate() { + match a.ch { + 0x13 => depth += 1, + 0x14 if depth == 1 && sep.is_none() => sep = Some(k), + 0x15 => { + depth -= 1; + if depth == 0 { + end = Some(k); + break; + } + } + _ => {} + } + } + let props = spans[atoms[0].span].rp.clone(); + let instr_end = sep.or(end).unwrap_or(atoms.len()); + let mut instr = String::new(); + let mut d = 0; + for a in &atoms[1..instr_end] { + match a.ch { + 0x13 => d += 1, + 0x15 => d -= 1, + 0x14 => {} + c if d == 0 && c >= 0x20 => { + if let Some(ch) = char::from_u32(u32::from(c)) { + instr.push(ch); + } + } + _ => {} + } + } + let Some(end) = end else { + // The field ends in a later paragraph: its marks are kept as they + // are. + let mut v = vec![Inline::FieldChar(FieldCharKind::Begin)]; + v.push(Inline::InstrText(instr)); + if sep.is_some() { + v.push(Inline::FieldChar(FieldCharKind::Separate)); + } + return Ok((instr_end + usize::from(sep.is_some()), v)); + }; + let result_atoms = match sep { + Some(s) => &atoms[s + 1..end], + None => &atoms[end..end], + }; + let mut result = vec![]; + let mut m = usize::MAX; + self.emit(result_atoms, spans, story, &mut result, &mut m, pe)?; + let instr_trim = instr.trim(); + let word = instr_trim + .split_whitespace() + .next() + .unwrap_or("") + .to_ascii_uppercase(); + let inl = match word.as_str() { + "HYPERLINK" => { + let (target, anchor, tip) = parse_hyperlink(instr_trim); + vec![Inline::Hyperlink { + target, + anchor, + tooltip: tip, + content: result, + }] + } + "XE" | "TC" | "TA" | "RD" => vec![], + // A check box has no result text; its state is in the field's + // form field data. + "FORMCHECKBOX" => { + let aux = &spans[atoms[0].span].aux; + let checked = aux + .pic_loc + .filter(|_| aux.data) + .is_some_and(|at| self.checkbox_checked(at)); + vec![Inline::Run(Run { + props, + items: vec![RunItem::Text( + if checked { "\u{2612}" } else { "\u{2610}" }.into(), + )], + })] + } + _ => vec![Inline::Field { + instr, + props, + result, + }], + }; + Ok((end + 1, inl)) + } + + // ---- tables ---- + + /// Whether the check box whose FFData is at `at` of the data stream is + /// checked ([MS-DOC] 2.9.78, 2.9.79: a type of 1 and a state of 1). + fn checkbox_checked(&self, at: i32) -> bool { + let Some(d) = usize::try_from(at) + .ok() + .and_then(|a| self.data.get(a..a + 6)) + else { + return false; + }; + let bits = u16_at(d, 4); + bits & 3 == 1 && (bits >> 2) & 0x1F == 1 + } + + /// The blocks of a table whose end was reached: the table, if it has a + /// row, and the paragraphs of the cell that was left open (or of cells + /// whose row never ended) as the plain paragraphs they are. + fn close_table(&mut self, t: TableBuilder) -> Vec { + let TableBuilder { rows, cells, cur } = t; + let loose = cells.into_iter().flatten().chain(cur); + if rows.is_empty() { + return loose.collect(); + } + let table = super::tables::build(rows); + if table.rows.is_empty() { + return loose.collect(); + } + std::iter::once(Block::Table(table)).chain(loose).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prm0_maps_to_sprms() { + assert_eq!(prm0_opcode(0x55), Some(0x0835)); + assert_eq!(prm0_opcode(0x01), None); + } + + #[test] + fn a_section_takes_the_headers_it_lacks_that_it_shows() { + let story = || Some(vec![Block::Para(Para::default())]); + let src = HeaderFooter { + default: story(), + first: story(), + even: story(), + }; + // No title page and no facing pages: only the default one is taken. + let mut dst = HeaderFooter::default(); + inherit(&mut dst, src.clone(), false, false); + assert!(dst.default.is_some() && dst.first.is_none() && dst.even.is_none()); + // Both: all are, but not over what the section has of its own. + let mut dst = HeaderFooter { + first: Some(vec![]), + ..HeaderFooter::default() + }; + inherit(&mut dst, src, true, true); + assert!(dst.default.is_some() && dst.even.is_some()); + assert_eq!(dst.first.map(|b| b.len()), Some(0)); + } +} diff --git a/crates/exav-render/src/formats/office/docs/doc/styles.rs b/crates/exav-render/src/formats/office/docs/doc/styles.rs new file mode 100644 index 00000000..03babbd0 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/doc/styles.rs @@ -0,0 +1,184 @@ +//! The stylesheet ([MS-DOC] 2.9.271): style definitions as their raw +//! modifier lists. + +use crate::formats::office::common::bytes::{Cur, u16_at}; + +pub struct Std { + pub name: String, + pub sti: u16, + /// 1 paragraph, 2 character, 3 table, 4 numbering. + pub kind: u8, + pub base: u16, + pub next: u16, + pub link: u16, + pub papx: Vec, + pub chpx: Vec, +} + +#[derive(Default)] +pub struct Stsh { + pub stds: Vec>, + pub ftc_ascii: i16, + pub ftc_fe: i16, + pub ftc_other: i16, + pub ftc_bi: Option, +} + +pub fn read(tbl: &[u8], off: usize, len: usize) -> Stsh { + let mut out = Stsh::default(); + let Some(d) = tbl.get(off..off.saturating_add(len).min(tbl.len())) else { + return out; + }; + let mut c = Cur::new(d); + let cb_stshi = usize::from(c.u16()); + let stshi = c.bytes(cb_stshi); + if stshi.len() < 18 || c.short { + return out; + } + let cstd = usize::from(u16_at(stshi, 0)); + let cb_base = usize::from(u16_at(stshi, 2)); + out.ftc_ascii = u16_at(stshi, 12) as i16; + out.ftc_fe = u16_at(stshi, 14) as i16; + out.ftc_other = u16_at(stshi, 16) as i16; + if stshi.len() >= 20 { + out.ftc_bi = Some(u16_at(stshi, 18) as i16); + } + for _ in 0..cstd.min(4096) { + let cb = usize::from(c.u16()); + if c.short { + break; + } + let std = c.bytes(cb); + if cb % 2 == 1 { + c.skip(1); + } + if cb == 0 { + out.stds.push(None); + continue; + } + out.stds.push(parse_std(std, cb_base)); + } + out +} + +fn parse_std(d: &[u8], cb_base: usize) -> Option { + if cb_base < 10 || d.len() < cb_base + 2 { + return None; + } + let w0 = u16_at(d, 0); + let w1 = u16_at(d, 2); + let w2 = u16_at(d, 4); + let link = if cb_base >= 18 { + u16_at(d, 10) & 0x0FFF + } else { + 0 + }; + let sti = w0 & 0x0FFF; + let kind = (w1 & 0xF) as u8; + let base = w1 >> 4; + let cupx = usize::from(w2 & 0xF); + let next = w2 >> 4; + let mut c = Cur::at(d, cb_base); + let cch = usize::from(c.u16()); + let name_bytes = c.bytes(cch * 2); + c.skip(2); + let units: Vec = name_bytes + .as_chunks::<2>() + .0 + .iter() + .map(|x| u16::from_le_bytes(*x)) + .collect(); + let name = String::from_utf16_lossy(&units); + if c.p % 2 == 1 { + c.skip(1); + } + let mut upx = vec![]; + for _ in 0..cupx { + let n = usize::from(c.u16()); + if c.short { + break; + } + upx.push(c.bytes(n).to_vec()); + if n % 2 == 1 { + c.skip(1); + } + } + let take = + |v: &mut Vec>, i: usize| v.get_mut(i).map(std::mem::take).unwrap_or_default(); + let (mut papx, mut chpx) = (vec![], vec![]); + match kind { + 1 => { + papx = take(&mut upx, 0); + chpx = take(&mut upx, 1); + } + 2 => chpx = take(&mut upx, 0), + 3 => { + // The table properties (UPX 0) are not used. + papx = take(&mut upx, 1); + chpx = take(&mut upx, 2); + } + 4 => papx = take(&mut upx, 0), + _ => return None, + } + Some(Std { + name, + sti, + kind, + base, + next, + link, + papx, + chpx, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A stylesheet with one paragraph style "Body" based on style 0xFFF. + pub fn sample() -> Vec { + let mut stshi = vec![0u8; 18]; + stshi[0] = 1; + stshi[2] = 10; + let mut std = vec![0u8; 10]; + std[0] = 0xFE; + std[1] = 0x0F; + // stk 1, istdBase 0x0FFF. + std[2..4].copy_from_slice(&(1u16 | 0x0FFF << 4).to_le_bytes()); + std[4..6].copy_from_slice(&(2u16).to_le_bytes()); + let name: Vec = "Body" + .encode_utf16() + .flat_map(|u| u.to_le_bytes()) + .collect(); + std.extend_from_slice(&4u16.to_le_bytes()); + std.extend_from_slice(&name); + std.extend_from_slice(&[0, 0]); + // papx: istd 0 and sprmPJc centered (3 bytes: no padding needed beyond one). + let papx = [0u8, 0, 0x61, 0x24, 1]; + std.extend_from_slice(&(papx.len() as u16).to_le_bytes()); + std.extend_from_slice(&papx); + std.push(0); + let chpx = [0x35u8, 0x08, 1]; + std.extend_from_slice(&(chpx.len() as u16).to_le_bytes()); + std.extend_from_slice(&chpx); + std.push(0); + let mut d = (stshi.len() as u16).to_le_bytes().to_vec(); + d.extend_from_slice(&stshi); + d.extend_from_slice(&(std.len() as u16).to_le_bytes()); + d.extend_from_slice(&std); + d + } + + #[test] + fn style_definitions_are_split_into_their_modifier_lists() { + let d = sample(); + let s = read(&d, 0, d.len()); + assert_eq!(s.stds.len(), 1); + let std = s.stds[0].as_ref().unwrap(); + assert_eq!(std.name, "Body"); + assert_eq!((std.kind, std.base), (1, 0x0FFF)); + assert_eq!(std.papx, vec![0, 0, 0x61, 0x24, 1]); + assert_eq!(std.chpx, vec![0x35, 0x08, 1]); + } +} diff --git a/crates/exav-render/src/formats/office/docs/doc/tables.rs b/crates/exav-render/src/formats/office/docs/doc/tables.rs new file mode 100644 index 00000000..4d3340b3 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/doc/tables.rs @@ -0,0 +1,520 @@ +//! Table rows ([MS-DOC] 2.4.3): the modifiers of a row mark define its cells. + +use super::props::{brc, brc80, colorref, shd, shd80}; +use super::sprm::{Prl, parse}; +use crate::formats::office::common::bytes::{Cur, u16_at}; +use crate::formats::office::docs::grid::{self, CellIn, RowIn}; +use crate::formats::office::docs::model::*; + +#[derive(Clone, Debug, Default)] +pub struct Tc { + /// 0 none, 1 merged into the cell before, 2 or 3 first of a merged set. + pub merge_h: u8, + /// 0 none, 1 merged with the cell above, 3 first of a merged set. + pub merge_v: u8, + pub v_align: u8, + pub text_flow: u8, + pub fit_text: bool, + pub no_wrap: bool, + pub width: Option, + /// top, left, bottom, right: `None` inherits, `Some(None)` is no border. + pub borders: [Option>; 4], + pub shading: Option, + pub margins: Margins, +} + +#[derive(Clone, Debug, Default)] +pub struct Tap { + /// Positions of the cell edges, twips from the margin. + pub edges: Vec, + pub tcs: Vec, + pub borders: TableBorders, + pub height: Option, + pub header: bool, + pub cant_split: bool, + pub jc: Option, + pub gap_half: Option, + pub left: Option, + pub width: Option, + pub bidi: bool, + pub margins: Margins, + pub spacing: Option, + pub width_before: Option, + pub width_after: Option, + pub style: Option, +} + +fn fts(f: u8, w: u16) -> Option { + match f { + 0 => Some(Width::Auto), + 2 => Some(Width::Pct(i32::from(w))), + 3 => Some(Width::Twips(i32::from(w))), + _ => None, + } +} + +fn tc80(b: &[u8]) -> Tc { + let g = u16_at(b, 0); + let mut tc = Tc { + merge_h: (g & 3) as u8, + text_flow: ((g >> 2) & 7) as u8, + merge_v: ((g >> 5) & 3) as u8, + v_align: ((g >> 7) & 3) as u8, + fit_text: g & 0x1000 != 0, + no_wrap: g & 0x2000 != 0, + width: fts(((g >> 9) & 7) as u8, u16_at(b, 2)), + ..Tc::default() + }; + for i in 0..4 { + let v = b.get(4 + 4 * i..8 + 4 * i).unwrap_or(&[0; 4]); + tc.borders[i] = if v == [0, 0, 0, 0] { + None + } else if v == [0xFF; 4] { + Some(None) + } else { + Some(brc80(v)) + }; + } + tc +} + +fn itc(tap: &mut Tap, first: usize, lim: usize, f: impl Fn(&mut Tc)) { + for i in first..lim.min(tap.tcs.len()) { + f(&mut tap.tcs[i]); + } +} + +fn apply_brcs(tc: &mut Tc, which: u8, b: Option, nil: bool) { + let v = if nil { Some(None) } else { Some(b) }; + for (bit, k) in [(1u8, 0usize), (2, 1), (4, 2), (8, 3)] { + if which & bit != 0 { + tc.borders[k] = v.clone(); + } + } +} + +fn border_set(tap: &mut Tap, which: u8, b: Option) { + // Bits for the inside lines apply to the table. + if which & 0x10 != 0 { + tap.borders.inside_h = b.clone(); + } + if which & 0x20 != 0 { + tap.borders.inside_v = b; + } +} + +fn margins_of(grf: u8, w: i32, m: &mut Margins) { + if grf & 1 != 0 { + m.top = Some(w); + } + if grf & 2 != 0 { + m.left = Some(w); + } + if grf & 4 != 0 { + m.bottom = Some(w); + } + if grf & 8 != 0 { + m.right = Some(w); + } +} + +/// The table properties a row mark's modifiers describe. +pub fn parse_tap(grpprl: &[u8]) -> Tap { + let mut t = Tap::default(); + for p in parse(grpprl) { + if p.sgc() != super::sprm::SGC_TABLE { + continue; + } + apply(&mut t, &p); + } + t +} + +fn tbl_borders(t: &mut Tap, v: &[u8], size: usize, f: fn(&[u8]) -> Option) { + let get = |i: usize| v.get(i * size..(i + 1) * size).and_then(f); + t.borders.top = get(0); + t.borders.left = get(1); + t.borders.bottom = get(2); + t.borders.right = get(3); + t.borders.inside_h = get(4); + t.borders.inside_v = get(5); +} + +fn apply(t: &mut Tap, p: &Prl) { + match p.op { + 0xD608 => { + let v = p.var(); + let n = usize::from(v.first().copied().unwrap_or(0)).min(63); + t.edges = (0..=n) + .map(|i| i32::from(u16_at(v, 1 + 2 * i) as i16)) + .collect(); + let base = 1 + 2 * (n + 1); + t.tcs = (0..n) + .map(|i| { + let s = base + 20 * i; + match v.get(s..s + 20) { + Some(b) => tc80(b), + None => Tc::default(), + } + }) + .collect(); + } + 0xD605 => tbl_borders(t, p.var(), 4, brc80), + 0xD613 => tbl_borders(t, p.var(), 8, brc), + 0xD609 | 0xD612 | 0xD616 | 0xD60C | 0xD670 | 0xD671 | 0xD672 => { + let start = match p.op { + 0xD616 | 0xD671 => 22, + 0xD60C | 0xD672 => 44, + _ => 0, + }; + let v = p.var(); + let size = if p.op == 0xD609 { 2 } else { 10 }; + for (k, c) in v.chunks_exact(size).enumerate() { + let sh = if size == 2 { + shd80(u16_at(c, 0)) + } else { + shd(c) + }; + if let Some(tc) = t.tcs.get_mut(start + k) { + tc.shading = sh; + } + } + } + 0xD62D | 0xD62E => { + let v = p.var(); + if let [a, b, rest @ ..] = v { + let sh = shd(rest); + itc(t, usize::from(*a), usize::from(*b), |c| { + c.shading = sh.clone() + }); + } + } + 0xD62F => { + let v = p.var(); + if let [a, b, which, rest @ ..] = v { + let bd = brc(rest); + let nil = rest.len() >= 8 && rest[4..8] == [0xFF; 4]; + let (which, bd2) = (*which, bd.clone()); + itc(t, usize::from(*a), usize::from(*b), |c| { + apply_brcs(c, which, bd2.clone(), nil) + }); + border_set(t, which, bd); + } + } + 0xD620 => { + let v = p.var(); + if let [a, b, which, rest @ ..] = v { + let bd = brc80(rest); + let nil = rest.len() >= 4 && rest[..4] == [0xFF; 4]; + let (which, bd2) = (*which, bd.clone()); + itc(t, usize::from(*a), usize::from(*b), |c| { + apply_brcs(c, which, bd2.clone(), nil) + }); + border_set(t, which, bd); + } + } + 0xD62B => { + if let [i, f, ..] = p.var() { + let f = *f; + itc(t, usize::from(*i), usize::from(*i) + 1, |c| c.merge_v = f); + } + } + 0xD62C => { + if let [a, b, va, ..] = p.var() { + let va = *va; + itc(t, usize::from(*a), usize::from(*b), |c| c.v_align = va); + } + } + 0xD635 => { + if let [a, b, f, w0, w1, ..] = p.var() { + let w = fts(*f, u16::from_le_bytes([*w0, *w1])); + itc(t, usize::from(*a), usize::from(*b), |c| c.width = w); + } + } + 0xD639 => { + if let [a, b, ..] = p.var() { + itc(t, usize::from(*a), usize::from(*b), |c| c.no_wrap = true); + } + } + 0xD632 | 0xD634 => { + if let [a, b, grf, _f, w0, w1, ..] = p.var() { + let w = i32::from(u16::from_le_bytes([*w0, *w1])); + if p.op == 0xD634 { + margins_of(*grf, w, &mut t.margins); + } else { + let grf = *grf; + itc(t, usize::from(*a), usize::from(*b), |c| { + margins_of(grf, w, &mut c.margins) + }); + } + } + } + 0xD633 => { + if let [_, _, _, _, w0, w1, ..] = p.var() { + t.spacing = Some(i32::from(u16::from_le_bytes([*w0, *w1]))); + } + } + 0x9407 => { + let v = i32::from(p.i16()); + t.height = (v != 0).then_some(v); + } + 0x3404 => t.header = p.u8() & 1 != 0, + 0x3403 | 0x3466 => t.cant_split = p.u8() & 1 != 0, + 0x5400 | 0x548A => { + t.jc = Some(match p.u16() { + 1 => Jc::Center, + 2 => Jc::Right, + _ => Jc::Left, + }) + } + 0x9601 => t.left = Some(i32::from(p.i16())), + 0x9602 => t.gap_half = Some(i32::from(p.i16())), + 0xF614 => { + if let [f, w0, w1, ..] = p.val { + t.width = fts(*f, u16::from_le_bytes([*w0, *w1])); + } + } + 0xF617 => { + if let [f, w0, w1, ..] = p.val { + t.width_before = fts(*f, u16::from_le_bytes([*w0, *w1])); + } + } + 0xF618 => { + if let [f, w0, w1, ..] = p.val { + t.width_after = fts(*f, u16::from_le_bytes([*w0, *w1])); + } + } + 0x560B | 0x5664 => t.bidi = p.u16() & 1 != 0, + 0x563A => t.style = Some(p.u16()), + 0x7621 => { + // sprmTInsert: itcFirst, ctc, dxaCol. + let mut c = Cur::new(p.val); + let first = usize::from(c.u8()); + let n = usize::from(c.u8()); + let dxa = i32::from(c.u16()); + if first <= t.tcs.len() && n > 0 && t.tcs.len() + n <= 63 { + if t.edges.is_empty() { + t.edges.push(0); + } + for k in 0..n { + t.tcs.insert(first + k, Tc::default()); + } + // Shift the edges after the inserted cells. + let at = first.min(t.edges.len() - 1); + let base = t.edges[at]; + for e in t.edges.iter_mut().skip(at + 1) { + *e += dxa * n as i32; + } + for k in 0..n { + t.edges.insert(at + 1 + k, base + dxa * (k as i32 + 1)); + } + t.edges.truncate(t.tcs.len() + 1); + } + } + 0x5622 => { + if let [a, b, ..] = p.val { + let (a, b) = (usize::from(*a), usize::from(*b).min(t.tcs.len())); + if a < b && b - a < t.tcs.len() { + let w: i32 = + t.edges.get(b).copied().unwrap_or(0) - t.edges.get(a).copied().unwrap_or(0); + t.tcs.drain(a..b); + if a < t.edges.len() { + t.edges.drain(a + 1..(b + 1).min(t.edges.len())); + for e in t.edges.iter_mut().skip(a + 1) { + *e -= w; + } + } + } + } + } + 0x7623 => { + if let [a, b, w0, w1, ..] = p.val { + let w = i32::from(u16::from_le_bytes([*w0, *w1])); + let (a, b) = (usize::from(*a), usize::from(*b).min(t.tcs.len())); + for i in a..b { + if i + 1 < t.edges.len() { + let delta = (t.edges[i] + w) - t.edges[i + 1]; + for e in t.edges.iter_mut().skip(i + 1) { + *e += delta; + } + } + } + } + } + 0x5624 => { + if let [a, b, ..] = p.val { + let (a, b) = (usize::from(*a), usize::from(*b).min(t.tcs.len())); + for i in a..b { + t.tcs[i].merge_h = if i == a { 2 } else { 1 }; + } + } + } + 0x5625 => { + if let [a, b, ..] = p.val { + let (a, b) = (usize::from(*a), usize::from(*b).min(t.tcs.len())); + for i in a..b { + t.tcs[i].merge_h = 0; + } + } + } + 0xD60A | 0xD62A => {} + _ => { + let _ = colorref; + } + } +} + +/// A table from its rows: the cell edges of every row make up the grid; a +/// cell spans the grid columns between its edges. +pub fn build(rows: Vec<(Tap, Vec>)>) -> Table { + let mut props = TableProps::default(); + if let Some((t, _)) = rows.first() { + let gap = t.gap_half.unwrap_or(108); + props.cell_margins = Margins { + top: None, + left: Some(gap), + bottom: None, + right: Some(gap), + }; + props.borders = t.borders.clone(); + props.jc = t.jc; + props.bidi = t.bidi; + props.cell_spacing = t.spacing.filter(|s| *s > 0); + props.indent = Some(t.edges.first().copied().unwrap_or(0) + gap); + props.width = t.width.filter(|w| *w != Width::Auto); + } + let mut rows_in = vec![]; + for (tap, contents) in rows { + let mut row = RowIn { + edges: tap.edges.clone(), + props: RowProps::default(), + cells: vec![], + }; + row.props.height = tap.height.map(i32::abs); + row.props.height_exact = tap.height.is_some_and(|h| h < 0); + row.props.header = tap.header; + row.props.cant_split = tap.cant_split; + let n = tap.tcs.len().min(tap.edges.len().saturating_sub(1)); + let mut contents = contents.into_iter(); + for k in 0..n { + let tc = &tap.tcs[k]; + let content = contents.next().unwrap_or_default(); + let mut cp = CellProps { + v_merge: match tc.merge_v { + 3 => VMerge::Restart, + 1 => VMerge::Continue, + _ => VMerge::None, + }, + shading: tc.shading.clone(), + v_align: match tc.v_align { + 1 => Some(VAlign::Center), + 2 => Some(VAlign::Bottom), + _ => None, + }, + no_wrap: tc.no_wrap, + fit_text: tc.fit_text, + margins: tc.margins.clone(), + ..CellProps::default() + }; + cp.text_direction = match tc.text_flow { + 1 => Some("tbRl"), + 3 => Some("btLr"), + _ => None, + }; + for (slot, b) in [ + (&mut cp.borders.top, &tc.borders[0]), + (&mut cp.borders.left, &tc.borders[1]), + (&mut cp.borders.bottom, &tc.borders[2]), + (&mut cp.borders.right, &tc.borders[3]), + ] { + *slot = match b { + None => None, + Some(Some(b)) => Some(b.clone()), + Some(None) => Some(Border { + style: "nil", + size: 0, + space: 0, + color: Color::Auto, + shadow: false, + }), + }; + } + row.cells.push(CellIn { + props: cp, + content, + merged_left: tc.merge_h == 1, + }); + } + // Content past the cells goes into the last one. + let extra: Vec> = contents.collect(); + if let Some(last) = row.cells.last_mut() { + for e in extra { + last.content.extend(e); + } + } + rows_in.push(row); + } + grid::build(rows_in, props) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn def_table(edges: &[i16], tcs: &[[u8; 20]]) -> Vec { + let n = edges.len() - 1; + let mut body = vec![n as u8]; + for e in edges { + body.extend_from_slice(&e.to_le_bytes()); + } + for t in tcs { + body.extend_from_slice(t); + } + let mut g = vec![0x08, 0xD6]; + g.extend_from_slice(&((body.len() + 1) as u16).to_le_bytes()); + g.extend_from_slice(&body); + g + } + + #[test] + fn cell_edges_and_merges() { + let mut first = [0u8; 20]; + first[0] = 2; // first of a merged set + let mut second = [0u8; 20]; + second[0] = 1; + let g = def_table(&[0, 1000, 2000, 3000], &[first, second, [0; 20]]); + let t = parse_tap(&g); + assert_eq!(t.edges, vec![0, 1000, 2000, 3000]); + assert_eq!(t.tcs.len(), 3); + assert_eq!((t.tcs[0].merge_h, t.tcs[1].merge_h), (2, 1)); + } + + #[test] + fn shading_in_the_two_byte_form() { + let mut g = def_table(&[0, 1000, 2000], &[[0; 20], [0; 20]]); + // sprmTDefTableShd80: the first cell none, the second a yellow + // background (colour 8 is white over 7, yellow, with no pattern). + g.extend_from_slice(&[0x09, 0xD6, 4, 0xFF, 0xFF, 0x08 | 7 << 5, 0]); + let t = parse_tap(&g); + assert!(t.tcs[0].shading.is_none()); + let s = t.tcs[1].shading.as_ref().unwrap(); + assert_eq!(s.fill, Some(Color::Rgb(0xFFFF00))); + } + + #[test] + fn row_height_header_and_shading() { + let mut g = def_table(&[0, 1000, 2000], &[[0; 20], [0; 20]]); + // exact 300 (negative), header row, shading of the second cell. + g.extend_from_slice(&[0x07, 0x94, 0xD4, 0xFE, 0x04, 0x34, 1]); + g.extend_from_slice(&[0x12, 0xD6, 20]); + // The first cell has no shading (automatic colours, no pattern); the + // second a solid pattern of red. + g.extend_from_slice(&[0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]); + g.extend_from_slice(&[0xFF, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0, 1, 0]); + let t = parse_tap(&g); + assert_eq!(t.height, Some(-300)); + assert!(t.header); + assert!(t.tcs[0].shading.is_none()); + assert!(t.tcs[1].shading.is_some()); + } +} diff --git a/crates/exav-render/src/formats/office/docs/doc/word6.rs b/crates/exav-render/src/formats/office/docs/doc/word6.rs new file mode 100644 index 00000000..fc0c3318 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/doc/word6.rs @@ -0,0 +1,1378 @@ +//! Word 6 and Word 95 documents (nFib 101 and 104): the compound file of +//! Word 97, but with one `WordDocument` stream that holds the tables too, +//! 8-bit text, one-byte modifier opcodes and FKP entries of other sizes. The +//! modifiers are translated to Word 97's here, so that the rest of the reader +//! knows nothing of the old form. + +use super::fib::{self, Clx, Fib, Piece}; +use super::props::ico; +use super::styles::{Std, Stsh}; +use super::{Reader, Run, SectionSpan, read_plc}; +use crate::formats::office::common::bytes::{u16_at, u32_at}; +use crate::formats::office::common::image; +use crate::formats::office::docs::error::{Error, Result}; +use crate::formats::office::docs::limits::Budget; +use crate::formats::office::docs::model::*; + +/// Positions of the (fc, lcb) pairs in the FIB. +mod at { + pub const STSHF: usize = 96; + pub const FND_REF: usize = 104; + pub const FND_TXT: usize = 112; + pub const SED: usize = 136; + pub const HDD: usize = 176; + pub const BTE_CHPX: usize = 184; + pub const BTE_PAPX: usize = 192; + pub const FFN: usize = 208; + pub const BKMK: usize = 256; + pub const BKF: usize = 264; + pub const BKL: usize = 272; + pub const DOP: usize = 336; + pub const CLX: usize = 352; + pub const END_REF: usize = 466; + pub const END_TXT: usize = 474; +} + +/// The (offset, size) of a structure of the main stream, by its place in the FIB. +fn pair(wd: &[u8], pos: usize) -> (usize, usize) { + (u32_at(wd, pos) as usize, u32_at(wd, pos + 4) as usize) +} + +fn part(wd: &[u8], pos: usize) -> &[u8] { + let (o, l) = pair(wd, pos); + wd.get(o..o.saturating_add(l).min(wd.len())).unwrap_or(&[]) +} + +/// How a modifier's operand is sized. +enum Size { + /// This many bytes. + Fixed(usize), + /// A length byte, then that many. + Counted, + /// Two bytes of length (the operand's size plus one), then the operand. + Long, +} + +fn size(op: u8) -> Option { + use Size::*; + Some(match op { + 4..=11 + | 13 + | 14 + | 24 + | 25 + | 29 + | 37 + | 44 + | 50 + | 51 + | 65 + | 66 + | 67 + | 71 + | 75 + | 85..=92 + | 94 + | 98..=102 + | 104 + | 117..=119 + | 131 + | 132 + | 138 + | 139 + | 142 + | 143 + | 146 + | 147 + | 150..=153 + | 158 + | 159 + | 162 + | 185 + | 186 => Fixed(1), + 2 + | 16..=19 + | 21 + | 22 + | 26..=28 + | 30..=36 + | 38..=43 + | 45..=49 + | 69 + | 72 + | 80 + | 93 + | 96 + | 97 + | 107 + | 109 + | 110 + | 121..=124 + | 140 + | 141 + | 144 + | 145 + | 148 + | 149 + | 154..=157 + | 160 + | 161 + | 164..=171 + | 182..=184 + | 189 + | 195 + | 197 + | 198 => Fixed(2), + 73 | 95 | 136 | 137 => Fixed(3), + 20 | 70 | 192 | 194 | 196 | 200 => Fixed(4), + 193 | 199 => Fixed(5), + 187 => Fixed(12), + 83 => Fixed(0), + 3 | 12 | 15 | 23 | 52 | 68 | 74 | 81 | 82 | 103 | 105 | 106 | 108 | 120 | 133 | 163 => { + Counted + } + 188 | 190 | 191 => Long, + _ => return None, + }) +} + +fn put1(o: &mut Vec, op: u16, v: u8) { + o.extend_from_slice(&op.to_le_bytes()); + o.push(v); +} + +fn put2(o: &mut Vec, op: u16, v: u16) { + o.extend_from_slice(&op.to_le_bytes()); + o.extend_from_slice(&v.to_le_bytes()); +} + +fn put4(o: &mut Vec, op: u16, v: &[u8]) { + o.extend_from_slice(&op.to_le_bytes()); + o.extend_from_slice(v); +} + +fn put_var(o: &mut Vec, op: u16, v: &[u8]) { + o.extend_from_slice(&op.to_le_bytes()); + o.push(v.len().min(255) as u8); + o.extend_from_slice(&v[..v.len().min(255)]); +} + +/// A Word 6 border (a word) as Word 97's Brc80: line width in eighths of a +/// point, type, colour, space and shadow. +fn brc(w: u16) -> [u8; 4] { + let units = (w & 7) as u8; + let kind = ((w >> 3) & 3) as u8; + let shadow = (w >> 5) & 1; + let color = ((w >> 6) & 0x1F) as u8; + let space = ((w >> 11) & 0x1F) as u8; + if kind == 0 { + return [0, 0, 0, 0]; + } + // 0.75 point units; 6 and 7 are dotted and dashed lines. + let (width, style) = match units { + 6 => (6, 6), + 7 => (6, 7), + u => ( + (u.max(1) * 6).saturating_mul(if kind == 2 { 2 } else { 1 }), + if kind == 3 { 3 } else { 1 }, + ), + }; + [width, style, color, space | (shadow as u8) << 5] +} + +/// The key of an autonumber, for lists. +const ANLD_LEN: usize = 52; + +/// Translates a Word 6 modifier list to Word 97's. Autonumber descriptors +/// are collected in `anlds`; a paragraph gets the list of its descriptor. +pub(super) fn translate(g: &[u8], anlds: &mut Vec>) -> Vec { + let mut out = vec![]; + let mut p = 0usize; + while p < g.len() { + let op = g[p]; + let Some(sz) = size(op) else { break }; + let (b0, n, cb): (usize, usize, u8) = match sz { + Size::Fixed(n) => (p + 1, n, 0), + Size::Counted => { + let Some(&c) = g.get(p + 1) else { break }; + if op == 23 && c == 255 { + // The tabs to delete and to add give the size. + let del = usize::from(g.get(p + 2).copied().unwrap_or(0)); + let add = usize::from(g.get(p + 3 + 4 * del).copied().unwrap_or(0)); + (p + 2, 2 + 4 * del + 3 * add, 255) + } else { + (p + 2, usize::from(c), c) + } + } + Size::Long => { + let len = usize::from(u16_at(g, p + 1)); + (p + 3, len.saturating_sub(1), 0) + } + }; + let Some(b) = g.get(b0..b0 + n) else { break }; + p = b0 + n; + modifier(op, b, cb, &mut out, anlds); + } + out +} + +fn modifier(op: u8, b: &[u8], cb: u8, o: &mut Vec, anlds: &mut Vec>) { + let w = || u16_at(b, 0); + let b0 = b.first().copied().unwrap_or(0); + match op { + // ---- paragraphs ---- + 2 => put2(o, 0x4600, w()), + 5 => put1(o, 0x2403, b0), + 7 => put1(o, 0x2405, b0), + 8 => put1(o, 0x2406, b0), + 9 => put1(o, 0x2407, b0), + 12 => { + if b.len() >= ANLD_LEN { + let key = b[..ANLD_LEN].to_vec(); + let at = anlds.iter().position(|a| *a == key).unwrap_or_else(|| { + anlds.push(key); + anlds.len() - 1 + }); + put2(o, 0x460B, at as u16 + 1); + put1(o, 0x260A, 0); + } + } + 14 => put1(o, 0x240C, b0), + 15 => put_var(o, 0xC60D, b), + 16 => put2(o, 0x845D, w()), + 17 => put2(o, 0x845E, w()), + 19 => put2(o, 0x8460, w()), + 20 => put4(o, 0x6412, b), + 21 => put2(o, 0xA413, w()), + 22 => put2(o, 0xA414, w()), + 23 => { + o.extend_from_slice(&0xC615u16.to_le_bytes()); + o.push(cb); + o.extend_from_slice(b); + } + 24 => put1(o, 0x2416, b0), + 25 => put1(o, 0x2417, b0), + 38..=42 => put4(o, 0x6424 + u16::from(op - 38), &brc(w())), + 47 => put2(o, 0x442D, w()), + 51 => put1(o, 0x2431, b0), + // ---- characters ---- + 65 => put1(o, 0x0800, b0), + 66 => put1(o, 0x0801, b0), + 67 => put1(o, 0x0802, b0), + 68 => { + put4(o, 0x6A03, b.get(..4).unwrap_or(&[0; 4])); + put1(o, 0x0855, 1); + } + 71 => put1(o, 0x0806, b0), + 74 => { + put4( + o, + 0x6A09, + &[ + b0, + b.get(1).copied().unwrap_or(0), + b.get(2).copied().unwrap_or(0), + 0, + ], + ); + put1(o, 0x0855, 1); + } + 75 => put1(o, 0x080A, b0), + 80 => put2(o, 0x4A30, w()), + 82 | 83 => put1(o, 0x2A33, 0), + 85..=92 => put1(o, 0x0835 + u16::from(op - 85), b0), + 93 => { + put2(o, 0x4A4F, w()); + put2(o, 0x4A51, w()); + } + 94 => put1(o, 0x2A3E, b0), + 95 => { + if b0 != 0 { + put2(o, 0x4A43, u16::from(b0)); + } + let pos = b.get(2).copied().unwrap_or(128); + if pos != 128 { + put2(o, 0x4845, i16::from(pos as i8) as u16); + } + } + 96 => put2(o, 0x8840, w()), + 98 => put1(o, 0x2A42, b0), + 99 => put2(o, 0x4A43, u16::from(b0)), + 101 => put2(o, 0x4845, i16::from(b0 as i8) as u16), + 104 => put1(o, 0x2A48, b0), + 105 => put2(o, 0x4A43, w()), + 107 => put2(o, 0x484B, w()), + 117 => put1(o, 0x0855, b0), + 118 => put1(o, 0x0856, b0), + // ---- sections ---- + 136 => put4(o, 0xF203, b), + 137 => put4(o, 0xF204, b), + 142 => put1(o, 0x3009, b0), + 143 => put1(o, 0x300A, b0), + 144 => put2(o, 0x500B, w()), + 145 => put2(o, 0x900C, w()), + 147 => put1(o, 0x300E, b0), + 150 => put1(o, 0x3011, b0), + 154 => put2(o, 0x5015, w()), + 155 => put2(o, 0x9016, w()), + 156 => put2(o, 0xB017, w()), + 157 => put2(o, 0xB018, w()), + 158 => put1(o, 0x3019, b0), + 159 => put1(o, 0x301A, b0), + 160 => put2(o, 0x501B, w()), + 161 => put2(o, 0x501C, w()), + 162 => put1(o, 0x301D, b0), + 164 => put2(o, 0xB01F, w()), + 165 => put2(o, 0xB020, w()), + 166 => put2(o, 0xB021, w()), + 167 => put2(o, 0xB022, w()), + 168 => put2(o, 0x9023, w()), + 169 => put2(o, 0x9024, w()), + 170 => put2(o, 0xB025, w()), + // ---- tables ---- + 182 => put2(o, 0x5400, w()), + 183 => put2(o, 0x9601, w()), + 184 => put2(o, 0x9602, w()), + 185 => put1(o, 0x3403, b0), + 186 => put1(o, 0x3404, b0), + 187 => { + let mut v = vec![]; + for k in 0..6 { + v.extend_from_slice(&brc(u16_at(b, 2 * k))); + } + put_var(o, 0xD605, &v); + } + 189 => put2(o, 0x9407, w()), + 190 => table_definition(b, o), + 191 => { + let n = usize::from(b0); + let mut v = vec![]; + for k in 0..n { + v.extend_from_slice(b.get(1 + 2 * k..3 + 2 * k).unwrap_or(&[0, 0])); + } + put_var(o, 0xD609, &v); + } + 193 => { + let mut v = vec![ + b0, + b.get(1).copied().unwrap_or(0), + b.get(2).copied().unwrap_or(0), + ]; + v.extend_from_slice(&brc(u16_at(b, 3))); + put_var(o, 0xD620, &v); + } + 194 => put4(o, 0x7621, b), + 195 => put2(o, 0x5622, w()), + 196 => put4(o, 0x7623, b), + 197 => put2(o, 0x5624, w()), + 198 => put2(o, 0x5625, w()), + 200 => { + // A Shd80 over a range of cells, as the long form. + let (f, l) = (b0, b.get(1).copied().unwrap_or(0)); + let s = u16_at(b, 2); + let color = |c: u8| match ico(c) { + Color::Rgb(v) => (v >> 16 & 0xFF, v >> 8 & 0xFF, v & 0xFF), + Color::Auto => (0xFF, 0xFF, 0xFF), + }; + let (fr, fg, fb) = color((s & 0x1F) as u8); + let (br, bg, bb) = color(((s >> 5) & 0x1F) as u8); + let mut v = vec![ + f, l, fr as u8, fg as u8, fb as u8, 0, br as u8, bg as u8, bb as u8, 0, + ]; + v.extend_from_slice(&(s >> 10 & 0x3F).to_le_bytes()); + put_var(o, 0xD62D, &v); + } + _ => {} + } +} + +/// sprmTDefTable: the cell edges and each cell's flags and borders, as +/// Word 97's TC80 of 20 bytes. +fn table_definition(b: &[u8], o: &mut Vec) { + let n = usize::from(b.first().copied().unwrap_or(0)).min(32); + let mut v = vec![n as u8]; + for k in 0..=n { + v.extend_from_slice(b.get(1 + 2 * k..3 + 2 * k).unwrap_or(&[0, 0])); + } + let base = 1 + 2 * (n + 1); + for k in 0..n { + let tc = b.get(base + 10 * k..base + 10 * k + 10).unwrap_or(&[0; 10]); + let rgf = u16_at(tc, 0); + // Merged with the cell before is 1, the first of a set 2. + let merge: u16 = if rgf & 1 != 0 { + 2 + } else if rgf & 2 != 0 { + 1 + } else { + 0 + }; + v.extend_from_slice(&merge.to_le_bytes()); + v.extend_from_slice(&[0, 0]); + for e in 0..4 { + v.extend_from_slice(&brc(u16_at(tc, 2 + 2 * e))); + } + } + o.extend_from_slice(&0xD608u16.to_le_bytes()); + o.extend_from_slice(&(v.len() as u16 + 1).to_le_bytes()); + o.extend_from_slice(&v); +} + +/// The modifiers `g` (Word 6 form) as a list at the end of the main stream. +struct Lists<'a> { + wd: &'a mut Vec, + b: &'a mut Budget, + anlds: Vec>, +} + +impl Lists<'_> { + /// Translates `g` and keeps it, against the memory budget; `prefix` + /// leads it (a style code). + fn keep(&mut self, prefix: &[u8], g: &[u8]) -> Result<(u32, u16)> { + let mut v = prefix.to_vec(); + v.extend(translate(g, &mut self.anlds)); + let len = v.len().min(0xFFFF); + self.b.alloc(len as u64)?; + let off = self.wd.len(); + self.wd.extend_from_slice(&v[..len]); + Ok((off as u32, len as u16)) + } +} + +pub(crate) fn read(wd: Vec, budget: &mut Budget) -> Result { + if wd.len() < 0x200 { + return Err(Error::Malformed( + "the WordDocument stream is too short".into(), + )); + } + let flags = u16_at(&wd, 10); + if flags & 0x0100 != 0 { + return Err(Error::Encrypted); + } + let lw = |o: usize| i64::from(u32_at(&wd, o) as i32).max(0); + let fib = Fib { + n_fib: u16_at(&wd, 2), + which_table: false, + encrypted: false, + ccp_text: lw(52), + ccp_ftn: lw(56), + ccp_hdd: lw(60), + ccp_atn: lw(68), + ccp_edn: lw(72), + ccp_txbx: lw(76), + ccp_hdr_txbx: lw(80), + pairs: vec![], + }; + let fc_min = u32_at(&wd, 24); + let fc_mac = u32_at(&wd, 28); + let total_cp = (fib.total_cp() + lw(64)).max(fib.ccp_text); + budget.alloc(wd.len() as u64)?; + // The pieces: the whole text from fcMin, or the piece table of a file + // that was fast-saved. + let complex = flags & 0x0004 != 0; + let mut clx = Clx { + pieces: vec![], + prcs: vec![], + }; + if complex { + clx = read_clx(&wd)?; + } + if clx.pieces.is_empty() { + let avail = i64::from(fc_mac.saturating_sub(fc_min)); + let total = total_cp.min(avail).max(fib.ccp_text.min(avail)).max(1) as u32; + clx.pieces.push(Piece { + cp0: 0, + cp1: total, + fc: fc_min, + compressed: true, + prm: 0, + }); + } + let last = clx.pieces.iter().map(|p| p.cp1 as usize).max().unwrap_or(1); + let total = usize::try_from(total_cp) + .unwrap_or(usize::MAX) + .min(last) + .max(fib.ccp_text.max(0) as usize) + .min(last.max(1)); + if total > 1 << 28 { + return Err(Error::TooLarge("characters")); + } + budget.alloc(2 * total as u64)?; + let table = codepage(&wd).map(|cp| { + std::array::from_fn(|b| { + crate::formats::office::common::codepage::decode(&[b as u8], cp) + .encode_utf16() + .next() + .unwrap_or(0xFFFD) + }) + }); + let text = fib::read_text_in(&wd, &clx.pieces, total, table.as_ref()); + // A modifier of a piece, or an index to a list of them, in Word 97's form. + let mut anlds = vec![]; + let mut prcs: Vec> = vec![]; + for g in &clx.prcs { + prcs.push(translate(g, &mut anlds)); + } + for p in &mut clx.pieces { + if p.prm & 1 == 0 && p.prm != 0 { + // One modifier: its opcode (7 bits) and its operand byte. + let g = [((p.prm >> 1) & 0x7F) as u8, (p.prm >> 8) as u8]; + prcs.push(translate(&g, &mut anlds)); + p.prm = ((prcs.len() - 1) as u16) << 1 | 1; + } + } + clx.prcs = prcs; + let mut r = Reader::new(budget, wd, vec![], vec![], fib, clx, text); + r.word6 = true; + r.load6(anlds)?; + r.convert()?; + Ok(r.doc) +} + +/// The Windows code page of a Word 6 or 95 document other than 1252, from +/// the language in its header (the text is in the code page of the system +/// that wrote it), or Mac Roman for a Macintosh one. +fn codepage(wd: &[u8]) -> Option { + if u16_at(wd, 0x14) == 256 { + return Some(10000); + } + Some(match u16_at(wd, 6) & 0x3FF { + 0x19 | 0x22 | 0x02 | 0x23 | 0x2F | 0x3F | 0x40 | 0x44 | 0x50 => 1251, + 0x15 | 0x05 | 0x0E | 0x1B | 0x24 | 0x1A | 0x18 | 0x1C => 1250, + 0x08 => 1253, + 0x1F | 0x2C => 1254, + 0x0D => 1255, + 0x01 | 0x29 | 0x20 => 1256, + 0x25..=0x27 => 1257, + 0x2A => 1258, + 0x1E => 874, + _ => return None, + }) +} + +/// The grpprls and the piece table of a fast-saved file: the length of each +/// group is two bytes, that of the piece table four. +fn read_clx(wd: &[u8]) -> Result { + let d = part(wd, at::CLX); + let mut p = 0usize; + let mut prcs = vec![]; + loop { + match d.get(p).copied() { + Some(1) => { + let n = usize::from(u16_at(d, p + 1)); + prcs.push(d.get(p + 3..p + 3 + n).unwrap_or(&[]).to_vec()); + p += 3 + n; + if prcs.len() > 1 << 15 { + return Err(Error::Malformed("too many property modifier groups".into())); + } + } + Some(2) => { + // The piece table's length is four bytes, the groups' two. + let n = u32_at(d, p + 1) as usize; + let plc = d + .get(p + 5..p.saturating_add(5).saturating_add(n)) + .unwrap_or(&[]); + let count = plc.len().saturating_sub(4) / 12; + let mut pieces = Vec::with_capacity(count); + for i in 0..count { + let (cp0, cp1) = (u32_at(plc, 4 * i), u32_at(plc, 4 * (i + 1))); + if cp1 < cp0 { + return Err(Error::Malformed("pieces out of order".into())); + } + let pcd = 4 * (count + 1) + 8 * i; + pieces.push(Piece { + cp0, + cp1, + fc: u32_at(plc, pcd + 2), + compressed: true, + prm: u16_at(plc, pcd + 6), + }); + } + return Ok(Clx { pieces, prcs }); + } + _ => return Err(Error::Malformed("no piece table in the file".into())), + } + } +} + +impl Reader<'_> { + pub(crate) fn load6(&mut self, anlds: Vec>) -> Result<()> { + let mut lists = Lists { + wd: &mut self.wd, + b: &mut *self.b, + anlds, + }; + let (fonts, stsh) = (read_fonts(lists.wd), read_styles(&mut lists)?); + let (chpx, papx) = ( + read_fkps(&mut lists, at::BTE_CHPX, false)?, + read_fkps(&mut lists, at::BTE_PAPX, true)?, + ); + let sections = read_sections(&mut lists)?; + let anlds = std::mem::take(&mut lists.anlds); + self.fonts = fonts; + self.stsh = stsh; + self.chpx = chpx; + self.papx = papx; + let (heads, ihdt) = (sections.0, sections.1); + self.sections = heads; + self.read_dop6(); + self.read_notes6(); + self.read_bookmarks6(); + self.hdd = story_table(&self.wd, &ihdt); + self.doc.numbering = numbering(&anlds, &self.fonts); + Ok(()) + } + + fn read_dop6(&mut self) { + let d = part(&self.wd, at::DOP); + if d.len() < 12 { + return; + } + self.facing = d[0] & 1 != 0; + self.doc.settings.even_and_odd_headers = self.facing; + let tab = i32::from(u16_at(d, 10)); + if tab > 0 { + self.doc.settings.default_tab_stop = Some(tab); + } + } + + fn read_notes6(&mut self) { + let base_ftn = self.fib.ccp_text as u32; + let (o, l) = pair(&self.wd, at::FND_REF); + self.ftn_ref = read_plc(&self.wd, o, l, 2).cps; + self.ftn_ref.pop(); + let (o, l) = pair(&self.wd, at::FND_TXT); + self.ftn_txt = read_plc(&self.wd, o, l, 0) + .cps + .iter() + .map(|c| c.saturating_add(base_ftn)) + .collect(); + let f = &self.fib; + // The macro story (ccpMcr) sits between the headers and the annotations. + let mcr = i64::from(u32_at(&self.wd, 64)); + let base_edn = fib::cp(f.ccp_text + f.ccp_ftn + f.ccp_hdd + mcr + f.ccp_atn); + let (o, l) = pair(&self.wd, at::END_REF); + self.edn_ref = read_plc(&self.wd, o, l, 2).cps; + self.edn_ref.pop(); + let (o, l) = pair(&self.wd, at::END_TXT); + self.edn_txt = read_plc(&self.wd, o, l, 0) + .cps + .iter() + .map(|c| c.saturating_add(base_edn)) + .collect(); + } + + fn read_bookmarks6(&mut self) { + let d = part(&self.wd, at::BKMK); + let mut p = 2usize; + while p < d.len() && self.bookmark_names.len() < 1 << 16 { + let n = usize::from(d[p]); + let s = d.get(p + 1..p + 1 + n).unwrap_or(&[]); + self.bookmark_names + .push(s.iter().map(|&c| char::from(c)).collect()); + p += 1 + n; + } + let (o, l) = pair(&self.wd, at::BKF); + let bkf = read_plc(&self.wd, o, l, 4); + let (o, l) = pair(&self.wd, at::BKL); + let bkl = read_plc(&self.wd, o, l, 0); + for (i, d) in bkf.data.iter().enumerate() { + let Some(name) = self.bookmark_names.get(i) else { + continue; + }; + if name.starts_with('_') && !name.starts_with("_Toc") && !name.starts_with("_Ref") { + continue; + } + let ibkl = usize::from(u16_at(d, 0)); + let (Some(&start), Some(&end)) = (bkf.cps.get(i), bkl.cps.get(ibkl)) else { + continue; + }; + self.marks.push((start, 0, i as u32)); + self.marks.push((end.max(start), 1, i as u32)); + } + self.marks.sort_by_key(|m| (m.0, m.1)); + } + + /// A picture character's picture: a metafile after its PIC, in the main + /// stream. + pub(crate) fn word6_picture(&mut self, loc: i32) -> Result> { + let at = loc as usize; + if loc < 0 || at + 58 > self.wd.len() { + return Ok(None); + } + let lcb = u32_at(&self.wd, at) as usize; + let cb_header = usize::from(u16_at(&self.wd, at + 4)); + let mm = u16_at(&self.wd, at + 6); + if cb_header < 58 || lcb < cb_header || mm == 98 || mm == 99 { + // A TIFF file's name, or a device bitmap. + return Ok(None); + } + let (gx, gy) = ( + i64::from(u16_at(&self.wd, at + 28)), + i64::from(u16_at(&self.wd, at + 30)), + ); + let (mx, my) = ( + i64::from(u16_at(&self.wd, at + 32)), + i64::from(u16_at(&self.wd, at + 34)), + ); + let end = at.saturating_add(lcb).min(self.wd.len()); + let Some(body) = self.wd.get(at + cb_header..end) else { + return Ok(None); + }; + if body.len() < 18 { + return Ok(None); + } + let data = placeable(body, gx, gy); + self.b.alloc(data.len() as u64)?; + let media = self.doc.add_media("wmf", data); + let (mut cx, mut cy) = (gx * mx / 1000 * TWIP_EMU, gy * my / 1000 * TWIP_EMU); + if cx <= 0 || cy <= 0 { + let (nx, ny) = + image::natural_emu(&self.doc.media[media].data).unwrap_or((914_400, 914_400)); + cx = nx; + cy = ny; + } + Ok(Some(Drawing { + anchor: None, + cx, + cy, + name: "Picture".into(), + descr: None, + kind: DrawingKind::Picture { + media, + crop: Crop::default(), + }, + })) + } +} + +/// EMU in a twip. +const TWIP_EMU: i64 = 635; + +/// A metafile with the header that says its size, from twips. +fn placeable(wmf: &[u8], gx: i64, gy: i64) -> Vec { + if wmf.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) { + return wmf.to_vec(); + } + let mut h = vec![0xD7, 0xCD, 0xC6, 0x9A, 0, 0]; + for v in [0i64, 0, gx.clamp(1, 32767), gy.clamp(1, 32767)] { + h.extend_from_slice(&(v as i16).to_le_bytes()); + } + h.extend_from_slice(&1440u16.to_le_bytes()); + h.extend_from_slice(&[0, 0, 0, 0]); + let mut sum = 0u16; + for k in 0..10 { + sum ^= u16::from_le_bytes([h[2 * k], h[2 * k + 1]]); + } + h.extend_from_slice(&sum.to_le_bytes()); + h.extend_from_slice(wmf); + h +} + +/// The font names: a size, then FFNs (a length, flags, weight, character +/// set, the name from byte 6). +fn read_fonts(wd: &[u8]) -> Vec { + let d = part(wd, at::FFN); + let mut out = vec![]; + let mut p = 2usize; + while p < d.len() && out.len() < 4096 { + let cb = usize::from(d[p]); + let ffn = d.get(p + 1..p + 1 + cb).unwrap_or(&[]); + let name = ffn.get(5..).unwrap_or(&[]); + let end = name.iter().position(|&c| c == 0).unwrap_or(name.len()); + out.push(name[..end].iter().map(|&c| char::from(c)).collect()); + p += 1 + cb; + } + out +} + +fn read_styles(l: &mut Lists) -> Result { + let mut out = Stsh::default(); + let d = part(l.wd, at::STSHF).to_vec(); + let cb_stshi = usize::from(u16_at(&d, 0)); + let Some(stshi) = d.get(2..2 + cb_stshi) else { + return Ok(out); + }; + if stshi.len() < 12 { + return Ok(out); + } + let cstd = usize::from(u16_at(stshi, 0)); + let cb_base = usize::from(u16_at(stshi, 2)); + out.ftc_ascii = u16_at(stshi, 12) as i16; + out.ftc_other = out.ftc_ascii; + out.ftc_fe = out.ftc_ascii; + let mut p = 2 + cb_stshi; + for _ in 0..cstd.min(4096) { + let cb = usize::from(u16_at(&d, p)); + if p + 2 > d.len() { + break; + } + let std = d.get(p + 2..p + 2 + cb).unwrap_or(&[]); + p += 2 + cb + (cb & 1); + if cb == 0 { + out.stds.push(None); + continue; + } + out.stds.push(read_std(std, cb_base, l)); + } + Ok(out) +} + +fn read_std(d: &[u8], cb_base: usize, l: &mut Lists) -> Option { + if cb_base < 8 || d.len() < cb_base + 1 { + return None; + } + let w0 = u16_at(d, 0); + let w1 = u16_at(d, 2); + let w2 = u16_at(d, 4); + let sti = w0 & 0x0FFF; + let kind = ((w0 >> 12) & 0xF) as u8; + let base = w1 & 0x0FFF; + let cupx = usize::from(w1 >> 12); + let next = w2 & 0x0FFF; + // The name, a counted string, then each part on an even offset. + let n = usize::from(d[cb_base]); + let name: String = d + .get(cb_base + 1..cb_base + 1 + n) + .unwrap_or(&[]) + .iter() + .map(|&c| char::from(c)) + .collect(); + let mut p = cb_base + 1 + n + 1; + p += p & 1; + let mut upx: Vec<&[u8]> = vec![]; + for _ in 0..cupx { + if p + 2 > d.len() { + break; + } + let cb = usize::from(u16_at(d, p)); + upx.push(d.get(p + 2..p + 2 + cb).unwrap_or(&[])); + p += 2 + cb + (cb & 1); + } + let mut keep = |prefix: &[u8], g: &[u8]| { + let mut v = prefix.to_vec(); + v.extend(translate(g, &mut l.anlds)); + v + }; + let (papx, chpx) = match kind { + 1 => { + let u = upx.first().copied().unwrap_or(&[]); + let istd = u.get(..2).unwrap_or(&[0, 0]).to_vec(); + ( + keep(&istd, u.get(2..).unwrap_or(&[])), + keep(&[], upx.get(1).copied().unwrap_or(&[])), + ) + } + 2 => (vec![], keep(&[], upx.first().copied().unwrap_or(&[]))), + _ => return None, + }; + Some(Std { + name, + sti, + kind, + base, + next, + link: 0, + papx, + chpx, + }) +} + +/// The runs of the character or paragraph FKPs, their modifier lists +/// translated and kept. +fn read_fkps(l: &mut Lists, pos: usize, para: bool) -> Result> { + let (o, len) = pair(l.wd, pos); + let plc = read_plc(l.wd, o, len, 2); + let mut runs = vec![]; + // A page the bin table names twice is read once, and a modifier list + // several runs share is kept once: neither is more memory than the file. + let mut seen = std::collections::HashSet::new(); + for d in &plc.data { + let base = usize::from(u16_at(d, 0)) * 512; + if !seen.insert(base) { + continue; + } + let page = match l.wd.get(base..base + 512) { + Some(p) => p.to_vec(), + None => continue, + }; + let mut kept: std::collections::HashMap = + std::collections::HashMap::new(); + let n = usize::from(page[511]); + let entry = if para { 7 } else { 1 }; + if n == 0 || 4 * (n + 1) + entry * n > 511 { + continue; + } + for k in 0..n { + let (lo, hi) = (u32_at(&page, 4 * k), u32_at(&page, 4 * (k + 1))); + let at = usize::from(page[4 * (n + 1) + entry * k]) * 2; + let (off, len) = if at == 0 || at >= 511 { + (0, 0) + } else if let Some(&k) = kept.get(&at) { + k + } else { + let k = if para { + // Words, the style code, then the modifiers. + let cw = usize::from(page[at]); + let g = page.get(at + 3..(at + 2 * cw).min(511)).unwrap_or(&[]); + let istd = page.get(at + 1..at + 3).unwrap_or(&[0, 0]); + l.keep(istd, g)? + } else { + let cb = usize::from(page[at]); + l.keep(&[], page.get(at + 1..(at + 1 + cb).min(511)).unwrap_or(&[]))? + }; + kept.insert(at, k); + k + }; + if hi > lo { + runs.push(Run { lo, hi, off, len }); + } + } + } + runs.sort_by_key(|r| r.lo); + Ok(runs) +} + +/// The sections, with their modifiers translated, and the header stories +/// each has (its `grpfIhdt`). +fn read_sections(l: &mut Lists) -> Result<(Vec, Vec)> { + let (o, len) = pair(l.wd, at::SED); + let plc = read_plc(l.wd, o, len, 12); + let mut out = vec![]; + let mut ihdt = vec![]; + for (i, d) in plc.data.iter().enumerate() { + let fc = u32_at(d, 2); + let raw = if fc == 0xFFFF_FFFF { + vec![] + } else { + let cb = usize::from(l.wd.get(fc as usize).copied().unwrap_or(0)); + l.wd.get(fc as usize + 1..fc as usize + 1 + cb) + .unwrap_or(&[]) + .to_vec() + }; + ihdt.push(headers_of(&raw)); + let grpprl = translate(&raw, &mut l.anlds); + out.push(SectionSpan { + cp1: plc.cps.get(i + 1).copied().unwrap_or(0), + grpprl, + }); + } + if out.is_empty() { + out.push(SectionSpan { + cp1: u32::MAX, + grpprl: vec![], + }); + ihdt.push(0); + } + Ok((out, ihdt)) +} + +/// The `grpfIhdt` of a section's modifiers (sprmSGprfIhdt). +fn headers_of(g: &[u8]) -> u8 { + let mut p = 0usize; + let mut out = 0; + while p < g.len() { + let op = g[p]; + let Some(sz) = size(op) else { break }; + let (b0, n) = match sz { + Size::Fixed(n) => (p + 1, n), + Size::Counted => (p + 2, usize::from(g.get(p + 1).copied().unwrap_or(0))), + Size::Long => (p + 3, usize::from(u16_at(g, p + 1)).saturating_sub(1)), + }; + if op == 153 { + out = g.get(b0).copied().unwrap_or(0); + } + p = b0 + n; + } + out +} + +/// The header stories in the layout of Word 97: six for the notes' +/// separators, then six for each section (even header, default header, even +/// footer, default footer, first header, first footer), the ones the file +/// has taken in order from its list. +fn story_table(wd: &[u8], ihdt: &[u8]) -> super::Plc { + let (o, l) = pair(wd, at::HDD); + let have = read_plc(wd, o, l, 0).cps; + let dop_bits = part(wd, at::DOP).get(1).copied().unwrap_or(0); + let mut cps = vec![have.first().copied().unwrap_or(0)]; + let mut next = 0usize; + let mut slot = |present: bool, cps: &mut Vec| { + let last = *cps.last().unwrap_or(&0); + if present { + if let Some(&end) = have.get(next + 1) { + cps.push(end); + } else { + cps.push(last); + } + next += 1; + } else { + cps.push(last); + } + }; + // The footnote separator, its continuation separator and notice. + for k in 0..6u8 { + slot(k < 3 && dop_bits & (0x80 >> k) != 0, &mut cps); + } + for bits in ihdt { + for k in 0..6u8 { + slot(bits & (0x80 >> k) != 0, &mut cps); + } + } + super::Plc { cps, data: vec![] } +} + +/// One list per autonumber descriptor: the number format, the text around +/// it, and the indents. +fn numbering(anlds: &[Vec], fonts: &[String]) -> Numbering { + let mut out = Numbering::default(); + for (i, a) in anlds.iter().enumerate() { + let nfc = a[0]; + let before = usize::from(a[1]); + let after = usize::from(a[2]); + let text = a.get(20..52).unwrap_or(&[]); + let pre: String = text + .get(..before.min(32)) + .unwrap_or(&[]) + .iter() + .map(|&c| char::from(c)) + .collect(); + let post: String = text + .get(before.min(32)..(before + after).min(32)) + .unwrap_or(&[]) + .iter() + .map(|&c| char::from(c)) + .collect(); + let fmt = match nfc { + 0 => "decimal", + 1 => "upperRoman", + 2 => "lowerRoman", + 3 => "upperLetter", + 4 => "lowerLetter", + 5 => "ordinal", + _ => "bullet", + }; + let hang = a[3] & 0x08 != 0; + let dxa_indent = i32::from(u16_at(a, 12)); + let dxa_space = i32::from(u16_at(a, 14)); + let mut level = Level { + start: i64::from(u16_at(a, 10).max(1)), + fmt, + jc: match a[3] & 3 { + 1 => Jc::Center, + 2 => Jc::Right, + _ => Jc::Left, + }, + ..Level::default() + }; + level.text = if fmt == "bullet" { + // A bullet is the text before the number, in a symbol font. + let font = fonts + .get(usize::from(u16_at(a, 6))) + .map(String::as_str) + .unwrap_or(""); + let symbolic = font.eq_ignore_ascii_case("symbol") + || font.to_ascii_lowercase().contains("wingdings"); + match pre.chars().next() { + Some(_) if symbolic => "\u{2022}".to_string(), + Some(c) => c.to_string(), + None => "\u{2022}".to_string(), + } + } else { + format!("{pre}%1{post}") + }; + if hang { + level.ppr.ind_left = Some(dxa_indent.max(dxa_space)); + level.ppr.ind_hanging = Some(dxa_indent.max(dxa_space)); + } + out.abstracts.push(AbstractNum { + id: i as u32, + multi_level: false, + levels: vec![level], + }); + out.nums.push(Num { + id: i as u32 + 1, + abstract_id: i as u32, + overrides: vec![], + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tr(g: &[u8]) -> Vec { + translate(g, &mut vec![]) + } + + #[test] + fn every_modifier_of_the_format_has_a_size() { + for op in (2..=52u8) + .chain(65..=75) + .chain(80..=83) + .chain(85..=110) + .chain(117..=124) + .chain(131..=133) + .chain(136..=171) + .chain(182..=200) + { + assert!(size(op).is_some(), "{op}"); + } + assert!(size(60).is_none()); + assert!(size(250).is_none()); + } + + #[test] + fn modifiers_become_word_97s() { + // Bold, 12 pt, centered, 360 after: the opcodes and operands. + assert_eq!(tr(&[85, 1, 99, 24]), [0x35, 0x08, 1, 0x43, 0x4A, 24, 0]); + assert_eq!( + tr(&[5, 1, 22, 0x68, 1]), + [0x03, 0x24, 1, 0x14, 0xA4, 0x68, 1] + ); + // A symbol sets the special flag; a picture location too. + assert_eq!( + tr(&[74, 3, 7, 0, 0xB7]), + [0x09, 0x6A, 7, 0, 0xB7, 0, 0x55, 0x08, 1] + ); + // A modifier of no known size ends the list. + assert_eq!(tr(&[85, 1, 60, 1, 86, 1]), [0x35, 0x08, 1]); + // A truncated operand ends it too. + assert_eq!(tr(&[85, 1, 99]), [0x35, 0x08, 1]); + } + + #[test] + fn borders_keep_their_width_type_colour_and_space() { + // 1 unit (0.75 pt) single, red, 2 points of space, shadow. + let w = 1 | 1 << 3 | 1 << 5 | 6 << 6 | 2 << 11; + assert_eq!(brc(w), [6, 1, 6, 2 | 0x20]); + // Thick doubles the width; double is its own type; none is nothing. + assert_eq!(brc(1 | 2 << 3), [12, 1, 0, 0]); + assert_eq!(brc(2 | 3 << 3)[1], 3); + assert_eq!(brc(0), [0, 0, 0, 0]); + // Dotted and dashed lines are the widths 6 and 7. + assert_eq!(brc(6 | 1 << 3), [6, 6, 0, 0]); + assert_eq!(brc(7 | 1 << 3), [6, 7, 0, 0]); + } + + #[test] + fn table_cells_are_two_edges_and_ten_bytes_each() { + // Two cells: edges 0, 1000, 2000; the first the first of a merged + // set (bit 0), the second merged with it (bit 1). + let mut g = vec![190, 0, 0, 2]; + for e in [0i16, 1000, 2000] { + g.extend_from_slice(&e.to_le_bytes()); + } + let len = g.len() as u16 - 3 + 20 + 1; + g.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + g[1..3].copy_from_slice(&len.to_le_bytes()); + g.extend_from_slice(&[85, 1]); + let o = tr(&g); + // The edges, then 20 bytes a cell: the merge in the first word. + assert_eq!(&o[..2], [0x08, 0xD6]); + // The size is one more than what follows it: the cell count, the + // three edges and the two cells. + assert_eq!(u16::from_le_bytes([o[2], o[3]]) as usize, 1 + 6 + 40 + 1); + assert_eq!(o[4], 2); + let cells = &o[4 + 1 + 6..]; + assert_eq!(u16::from_le_bytes([cells[0], cells[1]]), 2); + assert_eq!(u16::from_le_bytes([cells[20], cells[21]]), 1); + // And the modifier after it is still read. + assert_eq!(&o[o.len() - 3..], [0x35, 0x08, 1]); + } + + #[test] + fn an_autonumber_is_a_list_shared_by_equal_descriptors() { + let mut a = vec![0u8; ANLD_LEN]; + a[0] = 4; + let mut b = a.clone(); + b[0] = 0; + let mut anlds = vec![]; + let mut g = vec![12, ANLD_LEN as u8]; + g.extend_from_slice(&a); + let first = translate(&g, &mut anlds); + let again = translate(&g, &mut anlds); + assert_eq!(first, again); + assert_eq!(anlds.len(), 1); + // The list number is one-based. + assert_eq!(&first[..4], [0x0B, 0x46, 1, 0]); + let mut g = vec![12, ANLD_LEN as u8]; + g.extend_from_slice(&b); + let other = translate(&g, &mut anlds); + assert_eq!(&other[..4], [0x0B, 0x46, 2, 0]); + let n = numbering(&anlds, &[]); + assert_eq!(n.nums.len(), 2); + assert_eq!(n.abstracts[0].levels[0].fmt, "lowerLetter"); + assert_eq!(n.abstracts[1].levels[0].fmt, "decimal"); + } + + #[test] + fn the_text_of_a_word_6_file_is_in_the_code_page_of_its_language() { + let mut wd = vec![0u8; 0x20]; + assert_eq!(codepage(&wd), None); + // Russian, 0x0419: Windows 1251; Greek: 1253; Mac: Roman. + wd[6..8].copy_from_slice(&0x0419u16.to_le_bytes()); + assert_eq!(codepage(&wd), Some(1251)); + wd[6..8].copy_from_slice(&0x0408u16.to_le_bytes()); + assert_eq!(codepage(&wd), Some(1253)); + wd[0x14] = 0; + wd[0x15] = 1; + assert_eq!(codepage(&wd), Some(10000)); + // The bytes of "Привет" in 1251 come out as Cyrillic. + let pieces = [Piece { + cp0: 0, + cp1: 6, + fc: 0x20, + compressed: true, + prm: 0, + }]; + let mut wd = vec![0u8; 0x30]; + wd[0x20..0x26].copy_from_slice(&[0xCF, 0xF0, 0xE8, 0xE2, 0xE5, 0xF2]); + let table = std::array::from_fn(|b| { + crate::formats::office::common::codepage::decode(&[b as u8], 1251) + .encode_utf16() + .next() + .unwrap() + }); + let t = fib::read_text_in(&wd, &pieces, 6, Some(&table)); + assert_eq!(String::from_utf16(&t).unwrap(), "Привет"); + // Without a table it is Windows 1252, as before. + let t = fib::read_text_in(&wd, &pieces, 6, None); + assert_eq!(String::from_utf16(&t).unwrap(), "Ã�ðèâåò"); + } + + #[test] + fn a_fast_saved_file_has_its_pieces_and_modifier_lists_in_the_complex_part() { + // A list of one modifier (bold), then a piece table of two pieces: the + // first at byte 0x500 with a list, the second at 0x600 with a single + // modifier (italic, 1) in its prm. + let mut clx = vec![1u8, 2, 0, 85, 1, 2]; + let mut plc = vec![]; + for cp in [0u32, 5, 9] { + plc.extend_from_slice(&cp.to_le_bytes()); + } + for (fc, prm) in [(0x500u32, 1u16), (0x600, (86 << 1) | (1 << 8))] { + plc.extend_from_slice(&[0, 0]); + plc.extend_from_slice(&fc.to_le_bytes()); + plc.extend_from_slice(&prm.to_le_bytes()); + } + clx.extend_from_slice(&(plc.len() as u32).to_le_bytes()); + clx.extend_from_slice(&plc); + let mut wd = vec![0u8; 0x300]; + wd[0x100..0x100 + clx.len()].copy_from_slice(&clx); + wd[352..356].copy_from_slice(&0x100u32.to_le_bytes()); + wd[356..360].copy_from_slice(&(clx.len() as u32).to_le_bytes()); + let c = read_clx(&wd).unwrap(); + assert_eq!(c.prcs, vec![vec![85, 1]]); + assert_eq!(c.pieces.len(), 2); + assert_eq!( + (c.pieces[0].cp0, c.pieces[0].cp1, c.pieces[0].fc), + (0, 5, 0x500) + ); + assert_eq!( + (c.pieces[1].cp0, c.pieces[1].cp1, c.pieces[1].fc), + (5, 9, 0x600) + ); + assert!(c.pieces.iter().all(|p| p.compressed)); + assert_eq!(c.pieces[1].prm, (86 << 1) | (1 << 8)); + // Neither a list nor a piece table: refused. + wd[0x100] = 9; + assert!(read_clx(&wd).is_err()); + } + + #[test] + fn translated_modifier_lists_are_kept_once_and_count_against_the_memory_limit() { + use crate::formats::office::docs::limits::Limits; + // A character FKP at 0x200 of three runs that all point at one CHPX + // (bold), twice named by the bin table. + let mut wd = vec![0u8; 0x400]; + let page = 0x200; + for (i, fc) in [0x100u32, 0x110, 0x120, 0x130].iter().enumerate() { + wd[page + 4 * i..page + 4 * i + 4].copy_from_slice(&fc.to_le_bytes()); + } + // One word offset (0x80) for each of the three runs: the CHPX at + // page + 0x100 is a count of 2 and bold on. + wd[page + 16..page + 19].copy_from_slice(&[0x80, 0x80, 0x80]); + wd[page + 0x100..page + 0x103].copy_from_slice(&[2, 85, 1]); + wd[page + 511] = 3; + let bte: Vec = [0x100u32, 0x120, 0x130] + .iter() + .flat_map(|c| c.to_le_bytes()) + .chain([1, 0, 1, 0]) + .collect(); + wd[0x20..0x20 + bte.len()].copy_from_slice(&bte); + // Both entries name page 1 (0x200 / 512 = 1). + wd[184..188].copy_from_slice(&0x20u32.to_le_bytes()); + wd[188..192].copy_from_slice(&(bte.len() as u32).to_le_bytes()); + let mut b = Budget::new(Limits::default()); + let mut l = Lists { + wd: &mut wd, + b: &mut b, + anlds: vec![], + }; + let runs = read_fkps(&mut l, at::BTE_CHPX, false).unwrap(); + assert_eq!(runs.len(), 3); + // One list of the three bytes of Word 97's bold, shared. + assert_eq!(l.wd.len(), 0x400 + 3); + assert!(runs.iter().all(|r| r.off == runs[0].off && r.len == 3)); + // The memory is the budget's. + let mut b = Budget::new(Limits::default().with_max_memory(4)); + let mut wd = vec![]; + let mut l = Lists { + wd: &mut wd, + b: &mut b, + anlds: vec![], + }; + assert!(l.keep(&[], &[85, 1]).is_ok()); + assert!(l.keep(&[], &[85, 1]).is_err()); + } + + #[test] + fn a_metafile_gets_the_header_that_gives_its_size() { + let wmf = [1u8, 0, 9, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + let h = placeable(&wmf, 1440, 720); + assert_eq!(&h[..4], [0xD7, 0xCD, 0xC6, 0x9A]); + // Bounds 0, 0, 1440, 720 at 1440 units to the inch. + assert_eq!(&h[6..14], [0, 0, 0, 0, 0xA0, 5, 0xD0, 2]); + assert_eq!(&h[14..16], 1440u16.to_le_bytes()); + assert!(h.ends_with(&wmf)); + // One that has it is left alone. + assert_eq!(placeable(&h, 1, 1), h); + } + + #[test] + fn a_section_says_which_headers_it_has() { + assert_eq!(headers_of(&[142, 2, 153, 0x44, 143, 1]), 0x44); + assert_eq!(headers_of(&[]), 0); + // Each story the section's bits name is the next of the file's list; + // the first slots are the notes' separators. + let mut wd = vec![0u8; 0x400]; + let hdd: Vec = [0u32, 4, 9, 12] + .iter() + .flat_map(|c| c.to_le_bytes()) + .collect(); + wd[0x100..0x100 + hdd.len()].copy_from_slice(&hdd); + wd[176..180].copy_from_slice(&0x100u32.to_le_bytes()); + wd[180..184].copy_from_slice(&(hdd.len() as u32).to_le_bytes()); + // A default header (bit 6) and a first-page footer (bit 2). + let t = story_table(&wd, &[0x40 | 0x04]); + assert_eq!(t.cps.len(), 13); + // Slots 0 to 5 are empty, then the section's: even header (empty), + // default header 0..4, even footer, odd footer (empty), first header, + // first footer 4..9. + assert_eq!(t.cps[6], 0); + assert_eq!(&t.cps[7..9], [0, 4]); + assert_eq!(&t.cps[9..13], [4, 4, 4, 9]); + // The document's own bits (7, 6 and 5) name the notes' separators, the + // first stories of the list: here the footnote separator. + wd[0x200 + 1] = 0x80; + wd[336..340].copy_from_slice(&0x200u32.to_le_bytes()); + wd[340..344].copy_from_slice(&84u32.to_le_bytes()); + let t = story_table(&wd, &[0]); + assert_eq!(&t.cps[..3], [0, 4, 4]); + // A section with none: every slot is empty after it. + assert_eq!(t.cps.len(), 13); + assert!(t.cps[1..].iter().all(|&c| c == 4)); + } +} diff --git a/crates/exav-render/src/formats/office/docs/docx/body.rs b/crates/exav-render/src/formats/office/docs/docx/body.rs new file mode 100644 index 00000000..9c95dd7f --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/docx/body.rs @@ -0,0 +1,621 @@ +//! Blocks, paragraphs, runs and drawings as WordprocessingML. + +use super::props::*; +use crate::formats::office::common::xmlout::{esc_into, hex}; +use crate::formats::office::docs::model::*; +use std::fmt::Write; + +pub const REL_IMAGE: &str = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; +pub const REL_HYPERLINK: &str = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; +pub const REL_HEADER: &str = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"; +pub const REL_FOOTER: &str = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"; + +#[derive(Default)] +pub struct Rels { + pub items: Vec<(String, String, String, bool)>, +} + +impl Rels { + pub fn add(&mut self, typ: &str, target: &str, external: bool) -> String { + if let Some(r) = self + .items + .iter() + .find(|r| r.1 == typ && r.2 == target && r.3 == external) + { + return r.0.clone(); + } + let id = format!("rId{}", self.items.len() + 1); + self.items + .push((id.clone(), typ.to_string(), target.to_string(), external)); + id + } +} + +/// A header or footer part written while a section is. +pub struct HfPart { + pub name: String, + pub header: bool, + pub xml: String, + pub rels: Rels, +} + +pub struct W<'a> { + pub doc: &'a Document, + pub next_id: u32, + pub parts: Vec, + /// The part being written is a note or a comment: no section breaks. + pub depth: u32, + /// What one part's XML may take in memory before it is deflated. + pub limit: u64, + /// A part's XML grew past `limit`: what is left is not written. + pub over: bool, +} + +const NS_DECL: &str = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\" xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\" xmlns:wpg=\"http://schemas.microsoft.com/office/word/2010/wordprocessingGroup\""; + +pub fn root(name: &str) -> String { + format!( + "{}", + crate::formats::office::common::xmlout::XML_HEAD + ) +} + +impl<'a> W<'a> { + pub fn new(doc: &'a Document, limit: u64) -> W<'a> { + W { + doc, + next_id: 1, + parts: vec![], + depth: 0, + limit, + over: false, + } + } + + pub fn blocks(&mut self, o: &mut String, blocks: &[Block], rels: &mut Rels) { + for b in blocks { + match b { + Block::Para(p) => self.para(o, p, rels), + Block::Table(t) => self.table(o, t, rels), + } + // The XML of a part is held whole before it is deflated, and it + // is what the model's text and runs become with their markup. + if o.len() as u64 > self.limit { + self.over = true; + return; + } + } + } + + /// The content of a cell, note or text box: it must end with a + /// paragraph. + pub fn story(&mut self, o: &mut String, blocks: &[Block], rels: &mut Rels) { + self.blocks(o, blocks, rels); + if !matches!(blocks.last(), Some(Block::Para(_))) { + o.push_str(""); + } + } + + fn table(&mut self, o: &mut String, t: &Table, rels: &mut Rels) { + // A DOCX table has a row; one without has nothing to show. + if t.rows.is_empty() { + return; + } + o.push_str(""); + table_props(o, &t.props, true); + o.push_str(""); + for g in &t.grid { + let _ = write!(o, "", (*g).max(0)); + } + o.push_str(""); + for r in &t.rows { + o.push_str(""); + row_props(o, &r.props); + for c in &r.cells { + o.push_str(""); + cell_props(o, &c.props); + self.story(o, &c.content, rels); + o.push_str(""); + } + o.push_str(""); + } + o.push_str(""); + } + + fn para(&mut self, o: &mut String, p: &Para, rels: &mut Rels) { + o.push_str(""); + let sect = p + .section + .as_ref() + .filter(|_| self.depth == 0) + .map(|s| self.sect_pr(s, rels, false)); + para_props(o, &p.props, &p.mark, sect.as_deref()); + self.inlines(o, &p.content, rels); + o.push_str(""); + } + + fn inlines(&mut self, o: &mut String, content: &[Inline], rels: &mut Rels) { + for i in content { + match i { + Inline::Run(r) => self.run(o, r, rels), + Inline::FieldChar(k) => { + let t = match k { + FieldCharKind::Begin => "begin", + FieldCharKind::Separate => "separate", + FieldCharKind::End => "end", + }; + let _ = write!(o, ""); + } + Inline::InstrText(s) => { + o.push_str(""); + esc_into(o, s); + o.push_str(""); + } + Inline::Field { + instr, + props, + result, + } => { + o.push_str(""); + run_props(o, props); + o.push_str(""); + run_props(o, props); + o.push_str(""); + esc_into(o, instr); + o.push_str(""); + run_props(o, props); + o.push_str(""); + self.inlines(o, result, rels); + o.push_str(""); + run_props(o, props); + o.push_str(""); + } + Inline::Hyperlink { + target, + anchor, + tooltip, + content, + } => { + o.push_str("'); + self.inlines(o, content, rels); + o.push_str(""); + } + Inline::BookmarkStart { id, name } => { + let _ = write!(o, ""); + } + Inline::BookmarkEnd(id) => { + let _ = write!(o, ""); + } + Inline::CommentStart(id) => { + let _ = write!(o, ""); + } + Inline::CommentEnd(id) => { + let _ = write!(o, ""); + } + } + } + } + + fn run(&mut self, o: &mut String, r: &Run, rels: &mut Rels) { + if r.items.is_empty() { + return; + } + o.push_str(""); + run_props(o, &r.props); + for it in &r.items { + match it { + RunItem::Text(t) => { + o.push_str(""); + esc_into(o, t); + o.push_str(""); + } + RunItem::Tab => o.push_str(""), + RunItem::Break(k) => match k { + BreakKind::Line => o.push_str(""), + BreakKind::Page => o.push_str(""), + BreakKind::Column => o.push_str(""), + }, + RunItem::NoBreakHyphen => o.push_str(""), + RunItem::SoftHyphen => o.push_str(""), + RunItem::Symbol { font, ch } => { + o.push_str(""); + } + RunItem::FootnoteRef(id) => { + let _ = write!(o, ""); + } + RunItem::EndnoteRef(id) => { + let _ = write!(o, ""); + } + RunItem::NoteMark(endnote) => { + o.push_str(if *endnote { + "" + } else { + "" + }); + } + RunItem::CommentRef(id) => { + let _ = write!(o, ""); + } + RunItem::PageNumber => { + o.push_str(" PAGE 1"); + } + RunItem::Drawing(d) => self.drawing(o, d, rels), + } + } + o.push_str(""); + } + + fn drawing(&mut self, o: &mut String, d: &Drawing, rels: &mut Rels) { + let id = self.next_id; + self.next_id += 1; + let (cx, cy) = (d.cx.clamp(0, 1 << 40), d.cy.clamp(0, 1 << 40)); + o.push_str(""); + match &d.anchor { + None => { + o.push_str(""); + } + Some(a) => { + let _ = write!( + o, + "", + a.dist[0].clamp(0, 1 << 32), + a.dist[1].clamp(0, 1 << 32), + a.dist[2].clamp(0, 1 << 32), + a.dist[3].clamp(0, 1 << 32), + 251_658_240u64 + u64::from(a.z.min(1 << 24)), + u8::from(a.behind) + ); + for (tag, rel, off, align) in [ + ("positionH", a.h_rel, a.h_off, a.h_align), + ("positionV", a.v_rel, a.v_off, a.v_align), + ] { + let _ = write!(o, ""); + match align { + Some(al) => { + let _ = write!(o, "{al}"); + } + None => { + let _ = write!( + o, + "{}", + off.clamp(-(1 << 36), 1 << 36) + ); + } + } + let _ = write!(o, ""); + } + } + } + let _ = write!(o, ""); + if let Some(a) = &d.anchor { + o.push_str(""); + match a.wrap { + Wrap::None => o.push_str(""), + Wrap::Square => o.push_str(""), + Wrap::TopBottom => o.push_str(""), + Wrap::Tight | Wrap::Through => { + o.push_str(""); + } + } + } + let _ = write!(o, ""); + o.push_str(""); + o.push_str(""); + match &d.kind { + DrawingKind::Picture { media, crop } => { + let m = self.doc.media.get(*media); + let target = m.map(|m| format!("media/image{}.{}", media + 1, m.ext)); + o.push_str(""); + match target { + Some(t) => { + let rid = rels.add(REL_IMAGE, &t, false); + o.push_str(""); + } + None => o.push_str(""), + } + if *crop != Crop::default() { + let _ = write!( + o, + "", + crop.l, crop.t, crop.r, crop.b + ); + } + let _ = write!( + o, + "" + ); + } + DrawingKind::Shape(s) => { + o.push_str(""); + o.push_str(if s.text.is_empty() { + "" + } else { + "" + }); + o.push_str("", + s.preset + ); + match &s.fill { + Fill::None => o.push_str(""), + Fill::Solid(c) => { + let _ = write!( + o, + "", + hex(*c) + ); + } + } + match &s.line { + None => o.push_str(""), + Some(l) => { + let c = match l.color { + Color::Rgb(v) => v, + Color::Auto => 0, + }; + let _ = write!( + o, + "", + l.width.clamp(0, 1 << 24), + hex(c), + l.dash + ); + } + } + o.push_str(""); + if !s.text.is_empty() { + o.push_str(""); + let save = self.depth; + self.depth += 1; + self.story(o, &s.text, rels); + self.depth = save; + o.push_str(""); + } + let _ = write!( + o, + "", + s.insets[0], s.insets[1], s.insets[2], s.insets[3], s.v_anchor + ); + } + } + o.push_str(""); + o.push_str(if d.anchor.is_some() { + "" + } else { + "" + }); + o.push_str(""); + } + + /// A w:sectPr for `s`, writing its headers and footers as parts. + pub fn sect_pr(&mut self, s: &Section, rels: &mut Rels, last: bool) -> String { + let mut o = String::from(""); + let _ = last; + for (header, hf) in [(true, &s.headers), (false, &s.footers)] { + for (kind, content) in [ + ("default", &hf.default), + ("first", &hf.first), + ("even", &hf.even), + ] { + let Some(blocks) = content else { continue }; + let n = self.parts.iter().filter(|p| p.header == header).count() + 1; + let name = format!("{}{}.xml", if header { "header" } else { "footer" }, n); + let mut xml = root(if header { "hdr" } else { "ftr" }); + let mut prels = Rels::default(); + let save = self.depth; + self.depth += 1; + self.story(&mut xml, blocks, &mut prels); + self.depth = save; + xml.push_str(if header { "" } else { "" }); + self.parts.push(HfPart { + name: name.clone(), + header, + xml, + rels: prels, + }); + let rid = rels.add(if header { REL_HEADER } else { REL_FOOTER }, &name, false); + let _ = write!( + o, + "", + if header { "header" } else { "footer" } + ); + } + } + if s.kind != SectionType::NextPage { + let k = match s.kind { + SectionType::Continuous => "continuous", + SectionType::EvenPage => "evenPage", + SectionType::OddPage => "oddPage", + SectionType::NextColumn => "nextColumn", + SectionType::NextPage => "nextPage", + }; + let _ = write!(o, ""); + } + let _ = write!( + o, + "", + s.top, + s.right, + s.bottom, + s.left, + s.header.max(0), + s.footer.max(0), + s.gutter.max(0) + ); + if !s.page_borders.is_empty() { + o.push_str(""); + let b = &s.page_borders; + for (n, v) in [ + ("top", &b.top), + ("left", &b.left), + ("bottom", &b.bottom), + ("right", &b.right), + ] { + if let Some(v) = v { + border(&mut o, n, v); + } + } + o.push_str(""); + } + if let Some((start, count_by, distance)) = s.line_numbers { + let _ = write!( + o, + "", + count_by.max(1), + start, + distance.max(0) + ); + } + if s.page_start.is_some() || s.page_fmt.is_some() { + o.push_str(""); + } + let c = &s.columns; + if c.num > 1 || !c.widths.is_empty() { + let _ = write!(o, "", c.space.max(0)); + } else { + o.push_str(" w:equalWidth=\"0\">"); + for (w, sp) in &c.widths { + let _ = write!( + o, + "", + (*w).max(0), + (*sp).max(0) + ); + } + o.push_str(""); + } + } + if let Some(v) = s.v_align { + let _ = write!(o, ""); + } + if s.title_page { + o.push_str(""); + } + if let Some(d) = s.text_direction { + let _ = write!(o, ""); + } + if s.bidi { + o.push_str(""); + } + if s.rtl_gutter { + o.push_str(""); + } + o.push_str(""); + o + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::formats::office::common::package::Zip; + + #[test] + fn a_table_without_a_row_is_not_written() { + let doc = Document { + body: vec![ + Block::Table(Table { + props: TableProps::default(), + grid: vec![1], + rows: vec![], + }), + Block::Para(Para::default()), + ], + ..Document::default() + }; + let bytes = crate::formats::office::docs::docx::write(&doc, 1 << 20).unwrap(); + let mut z = Zip::open(&bytes).unwrap(); + let body = + String::from_utf8(z.read("word/document.xml", 1 << 20).unwrap().unwrap()).unwrap(); + assert!( + !body.contains("") && body.contains(""), + "{body}" + ); + } + + /// The XML of the body is held whole before it is deflated, so a body that + /// deflates to nothing still counts at its own size. + #[test] + fn a_body_that_deflates_small_is_still_refused_at_its_size() { + let para = |text: &str| { + Block::Para(Para { + content: vec![Inline::Run(Run::text(RunProps::default(), text))], + ..Para::default() + }) + }; + let long = "a".repeat(1000); + let doc = Document { + body: (0..1000).map(|_| para(&long)).collect(), + ..Document::default() + }; + // About a megabyte of XML, a few kilobytes deflated. + assert!(crate::formats::office::docs::docx::write(&doc, 2 << 20).is_ok()); + assert_eq!( + crate::formats::office::docs::docx::write(&doc, 100 << 10), + Err(crate::formats::office::docs::error::Error::TooLarge( + "memory" + )) + ); + } +} diff --git a/crates/exav-render/src/formats/office/docs/docx/mod.rs b/crates/exav-render/src/formats/office/docs/docx/mod.rs new file mode 100644 index 00000000..8d69b068 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/docx/mod.rs @@ -0,0 +1,198 @@ +//! The document model to a DOCX package (ECMA-376 WordprocessingML). + +mod body; +mod parts; +mod props; + +use crate::formats::office::common::package::PackageWriter; +use crate::formats::office::common::xmlout::XML_HEAD; +use crate::formats::office::docs::error::{Error, Result}; +use crate::formats::office::docs::model::*; +use body::{Rels, W, root}; +use std::fmt::Write; + +const CT_MAIN: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml"; +const REL_BASE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +fn mime(ext: &str) -> &'static str { + match ext { + "png" => "image/png", + "jpeg" => "image/jpeg", + "gif" => "image/gif", + "bmp" => "image/bmp", + "tiff" => "image/tiff", + "emf" => "image/x-emf", + "wmf" => "image/x-wmf", + "svg" => "image/svg+xml", + _ => "application/octet-stream", + } +} + +fn rels_xml(r: &Rels) -> String { + let mut o = String::from(XML_HEAD); + o.push_str( + "", + ); + for (id, typ, target, external) in &r.items { + let _ = write!(o, ""); + } + o.push_str(""); + o +} + +/// Writes `doc` as a DOCX, refusing to grow past `limit` bytes. +pub fn write(doc: &Document, limit: u64) -> Result> { + let mut z = PackageWriter::new(limit); + let mut w = W::new(doc, limit); + let mut rels = Rels::default(); + + // Parts that other parts refer to are numbered first, so that their + // ids do not depend on the body. + let styles = parts::styles(doc); + let numbering = parts::numbering(&doc.numbering); + let settings = parts::settings(doc); + rels.add(&format!("{REL_BASE}/styles"), "styles.xml", false); + if numbering.is_some() { + rels.add(&format!("{REL_BASE}/numbering"), "numbering.xml", false); + } + rels.add(&format!("{REL_BASE}/settings"), "settings.xml", false); + + // The body, in a string; the last section's properties close it. + let mut body = root("document"); + body.push_str(""); + w.blocks(&mut body, &doc.body, &mut rels); + let last = w.sect_pr(&doc.section, &mut rels, true); + body.push_str(&last); + body.push_str(""); + if w.over { + return Err(Error::TooLarge("memory")); + } + + let mut notes_rels = Rels::default(); + let footnotes = (!doc.footnotes.is_empty()) + .then(|| parts::notes(&mut w, &doc.footnotes, false, &mut notes_rels)); + let mut endnotes_rels = Rels::default(); + let endnotes = (!doc.endnotes.is_empty()) + .then(|| parts::notes(&mut w, &doc.endnotes, true, &mut endnotes_rels)); + let mut comments_rels = Rels::default(); + let comments = (!doc.comments.is_empty()) + .then(|| parts::comments(&mut w, &doc.comments, &mut comments_rels)); + if footnotes.is_some() { + rels.add(&format!("{REL_BASE}/footnotes"), "footnotes.xml", false); + } + if endnotes.is_some() { + rels.add(&format!("{REL_BASE}/endnotes"), "endnotes.xml", false); + } + if comments.is_some() { + rels.add(&format!("{REL_BASE}/comments"), "comments.xml", false); + } + + let mut ct = String::from(XML_HEAD); + ct.push_str(""); + let mut exts: Vec<&str> = doc.media.iter().map(|m| m.ext).collect(); + exts.sort_unstable(); + exts.dedup(); + for e in exts { + let _ = write!( + ct, + "", + mime(e) + ); + } + let over = |ct: &mut String, name: &str, kind: &str| { + let _ = write!( + ct, + "" + ); + }; + over(&mut ct, "document.xml", "document.main"); + over(&mut ct, "styles.xml", "styles"); + over(&mut ct, "settings.xml", "settings"); + if numbering.is_some() { + over(&mut ct, "numbering.xml", "numbering"); + } + if footnotes.is_some() { + over(&mut ct, "footnotes.xml", "footnotes"); + } + if endnotes.is_some() { + over(&mut ct, "endnotes.xml", "endnotes"); + } + if comments.is_some() { + over(&mut ct, "comments.xml", "comments"); + } + for p in &w.parts { + over(&mut ct, &p.name, if p.header { "header" } else { "footer" }); + } + ct.push_str(""); + + let pk = |e: crate::formats::office::common::package::Error| -> Error { e.into() }; + z.put("[Content_Types].xml", ct.as_bytes(), true) + .map_err(pk)?; + let mut top = String::from(XML_HEAD); + let _ = write!( + top, + "" + ); + z.put("_rels/.rels", top.as_bytes(), true).map_err(pk)?; + z.put("word/document.xml", body.as_bytes(), true) + .map_err(pk)?; + z.put( + "word/_rels/document.xml.rels", + rels_xml(&rels).as_bytes(), + true, + ) + .map_err(pk)?; + z.put("word/styles.xml", styles.as_bytes(), true) + .map_err(pk)?; + z.put("word/settings.xml", settings.as_bytes(), true) + .map_err(pk)?; + if let Some(n) = &numbering { + z.put("word/numbering.xml", n.as_bytes(), true) + .map_err(pk)?; + } + for (name, xml, r) in [ + ("footnotes.xml", &footnotes, ¬es_rels), + ("endnotes.xml", &endnotes, &endnotes_rels), + ("comments.xml", &comments, &comments_rels), + ] { + if let Some(x) = xml { + z.put(&format!("word/{name}"), x.as_bytes(), true) + .map_err(pk)?; + if !r.items.is_empty() { + z.put( + &format!("word/_rels/{name}.rels"), + rels_xml(r).as_bytes(), + true, + ) + .map_err(pk)?; + } + } + } + for p in &w.parts { + z.put(&format!("word/{}", p.name), p.xml.as_bytes(), true) + .map_err(pk)?; + if !p.rels.items.is_empty() { + z.put( + &format!("word/_rels/{}.rels", p.name), + rels_xml(&p.rels).as_bytes(), + true, + ) + .map_err(pk)?; + } + } + for (i, m) in doc.media.iter().enumerate() { + let compress = !matches!(m.ext, "png" | "jpeg" | "gif"); + z.put( + &format!("word/media/image{}.{}", i + 1, m.ext), + &m.data, + compress, + ) + .map_err(pk)?; + } + z.finish().map_err(pk) +} diff --git a/crates/exav-render/src/formats/office/docs/docx/parts.rs b/crates/exav-render/src/formats/office/docs/docx/parts.rs new file mode 100644 index 00000000..0ef544d1 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/docx/parts.rs @@ -0,0 +1,318 @@ +//! styles.xml, numbering.xml, settings.xml, notes and comments. + +use super::body::{Rels, W, root}; +use super::props::*; +use crate::formats::office::common::xmlout::XML_HEAD; +use crate::formats::office::docs::model::*; +use std::fmt::Write; + +const NS_W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +pub fn styles(doc: &Document) -> String { + let mut o = String::from(XML_HEAD); + let _ = write!(o, ""); + o.push_str(""); + let mut rp = doc.defaults_run.clone(); + if rp.size.is_none() { + rp.size = Some(20); + } + run_props(&mut o, &rp); + o.push_str(""); + let mut pp = String::new(); + para_props_body(&mut pp, &doc.defaults_para); + if !pp.is_empty() { + o.push_str(""); + o.push_str(&pp); + o.push_str(""); + } + o.push_str(""); + let has = |kind: StyleKind, default: bool| { + doc.styles + .iter() + .any(|s| s.kind == kind && s.default == default) + }; + if !has(StyleKind::Paragraph, true) { + let n = Style { + default: true, + ..Style::new("Normal", "Normal", StyleKind::Paragraph) + }; + style(&mut o, &n); + } + if !has(StyleKind::Character, true) { + let n = Style { + default: true, + ..Style::new( + "DefaultParagraphFont", + "Default Paragraph Font", + StyleKind::Character, + ) + }; + style(&mut o, &n); + } + if !has(StyleKind::Table, true) { + let mut n = Style { + default: true, + ..Style::new("TableNormal", "Normal Table", StyleKind::Table) + }; + n.tbl = Some(TableProps { + cell_margins: Margins { + top: Some(0), + left: Some(108), + bottom: Some(0), + right: Some(108), + }, + ..TableProps::default() + }); + style(&mut o, &n); + } + for s in &doc.styles { + style(&mut o, s); + } + o.push_str(""); + o +} + +fn style(o: &mut String, s: &Style) { + let kind = match s.kind { + StyleKind::Paragraph => "paragraph", + StyleKind::Character => "character", + StyleKind::Table => "table", + StyleKind::Numbering => "numbering", + }; + let _ = write!(o, ""); + if let Some(b) = &s.based_on { + o.push_str(""); + } + if let Some(b) = &s.next { + o.push_str(""); + } + if let Some(b) = &s.link { + o.push_str(""); + } + if let Some(p) = s.ui_priority { + let _ = write!(o, ""); + } + if s.hidden { + o.push_str(""); + } + if s.q_format { + o.push_str(""); + } + if s.kind != StyleKind::Table || s.tbl.is_none() { + let mut pp = String::new(); + para_props_body(&mut pp, &s.ppr); + if !pp.is_empty() { + o.push_str(""); + o.push_str(&pp); + o.push_str(""); + } + } + run_props(o, &s.rpr); + if let Some(t) = &s.tbl { + table_props(o, t, false); + } + o.push_str(""); +} + +fn level(o: &mut String, ilvl: usize, l: &Level, in_override: bool) { + let _ = write!(o, ""); + let _ = write!(o, "", l.start); + let _ = write!(o, "", l.fmt); + if let Some(r) = l.restart { + let _ = write!(o, ""); + } + if let Some(s) = &l.style { + o.push_str(""); + } + if l.legal { + o.push_str(""); + } + if l.suff != "tab" { + let _ = write!(o, "", l.suff); + } + o.push_str(""); + let jc = match l.jc { + Jc::Right => "right", + Jc::Center => "center", + _ => "left", + }; + let _ = write!(o, ""); + let mut pp = String::new(); + para_props_body(&mut pp, &l.ppr); + if !pp.is_empty() { + o.push_str(""); + o.push_str(&pp); + o.push_str(""); + } + run_props(o, &l.rpr); + let _ = in_override; + o.push_str(""); +} + +pub fn numbering(n: &Numbering) -> Option { + if n.abstracts.is_empty() && n.nums.is_empty() { + return None; + } + let mut o = String::from(XML_HEAD); + let _ = write!(o, ""); + for a in &n.abstracts { + let _ = write!(o, "", a.id); + let _ = write!( + o, + "", + if a.multi_level { + "multilevel" + } else { + "singleLevel" + } + ); + for (i, l) in a.levels.iter().enumerate().take(9) { + level(&mut o, i, l, false); + } + o.push_str(""); + } + for m in &n.nums { + let _ = write!( + o, + "", + m.id, m.abstract_id + ); + for (lvl, start, def) in &m.overrides { + let _ = write!(o, ""); + if let Some(s) = start { + let _ = write!(o, ""); + } + if let Some(d) = def { + level(&mut o, *lvl as usize, d, true); + } + o.push_str(""); + } + o.push_str(""); + } + o.push_str(""); + Some(o) +} + +pub fn settings(doc: &Document) -> String { + let s = &doc.settings; + let mut o = String::from(XML_HEAD); + let _ = write!(o, ""); + if s.mirror_margins { + o.push_str(""); + } + if s.gutter_at_top { + o.push_str(""); + } + let _ = write!( + o, + "", + s.default_tab_stop.unwrap_or(720).max(1) + ); + if s.even_and_odd_headers { + o.push_str(""); + } + for (tag, pos, fmt, start, restart, has_sep) in [ + ( + "footnotePr", + s.footnote_pos, + s.footnote_fmt, + s.footnote_start, + s.footnote_restart, + !doc.footnotes.is_empty(), + ), + ( + "endnotePr", + None, + s.endnote_fmt, + s.endnote_start, + s.endnote_restart, + !doc.endnotes.is_empty(), + ), + ] { + if pos.is_none() && fmt.is_none() && start.is_none() && restart.is_none() && !has_sep { + continue; + } + let _ = write!(o, ""); + if let Some(p) = pos { + let _ = write!(o, ""); + } + if let Some(f) = fmt { + let _ = write!(o, ""); + } + if let Some(v) = start { + let _ = write!(o, ""); + } + if let Some(r) = restart { + let _ = write!(o, ""); + } + let n = if tag == "footnotePr" { + "footnote" + } else { + "endnote" + }; + if has_sep { + let _ = write!(o, ""); + } + let _ = write!(o, ""); + } + o.push_str(""); + o +} + +pub fn notes(w: &mut W, notes: &[Note], endnotes: bool, rels: &mut Rels) -> String { + let tag = if endnotes { "endnote" } else { "footnote" }; + let mut o = root(if endnotes { "endnotes" } else { "footnotes" }); + let _ = write!( + o, + "" + ); + w.depth += 1; + for n in notes { + let _ = write!(o, "", n.id); + w.story(&mut o, &n.content, rels); + let _ = write!(o, ""); + } + w.depth -= 1; + let _ = write!( + o, + "", + if endnotes { "endnotes" } else { "footnotes" } + ); + o +} + +pub fn comments(w: &mut W, comments: &[Comment], rels: &mut Rels) -> String { + let mut o = root("comments"); + w.depth += 1; + for c in comments { + let _ = write!(o, "'); + w.story(&mut o, &c.content, rels); + o.push_str(""); + } + w.depth -= 1; + o.push_str(""); + o +} diff --git a/crates/exav-render/src/formats/office/docs/docx/props.rs b/crates/exav-render/src/formats/office/docs/docx/props.rs new file mode 100644 index 00000000..a44b7e31 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/docx/props.rs @@ -0,0 +1,647 @@ +//! Property elements (WordprocessingML w:rPr, w:pPr, w:tblPr, ...) written in +//! schema order. + +use crate::formats::office::common::xmlout::{esc_into, hex}; +use crate::formats::office::docs::model::*; +use std::fmt::Write; + +pub fn attr(o: &mut String, k: &str, v: &str) { + o.push(' '); + o.push_str(k); + o.push_str("=\""); + esc_into(o, v); + o.push('"'); +} + +fn num_attr(o: &mut String, k: &str, v: impl std::fmt::Display) { + let _ = write!(o, " {k}=\"{v}\""); +} + +fn color_val(c: Color) -> String { + match c { + Color::Auto => "auto".to_string(), + Color::Rgb(v) => hex(v), + } +} + +/// `` for a boolean toggle. +fn toggle(o: &mut String, name: &str, v: Option) { + match v { + Some(true) => { + let _ = write!(o, ""); + } + Some(false) => { + let _ = write!(o, ""); + } + None => {} + } +} + +pub fn border(o: &mut String, name: &str, b: &Border) { + let _ = write!(o, ""); +} + +pub fn shading(o: &mut String, s: &Shading) { + o.push_str(""); +} + +pub fn run_props_body(o: &mut String, p: &RunProps) { + if let Some(s) = &p.style { + o.push_str(""); + } + let f = &p.fonts; + if f.ascii.is_some() || f.hansi.is_some() || f.east_asia.is_some() || f.cs.is_some() { + o.push_str(""); + } + toggle(o, "b", p.bold); + toggle(o, "bCs", p.bold_cs); + toggle(o, "i", p.italic); + toggle(o, "iCs", p.italic_cs); + toggle(o, "caps", p.caps); + toggle(o, "smallCaps", p.small_caps); + toggle(o, "strike", p.strike); + toggle(o, "dstrike", p.dstrike); + toggle(o, "outline", p.outline); + toggle(o, "shadow", p.shadow); + toggle(o, "emboss", p.emboss); + toggle(o, "imprint", p.imprint); + toggle(o, "vanish", p.vanish); + if let Some(c) = p.color { + o.push_str(""); + } + if let Some(v) = p.spacing { + let _ = write!(o, ""); + } + if let Some(v) = p.scale { + let _ = write!(o, "", v.clamp(1, 600)); + } + if let Some(v) = p.kerning { + let _ = write!(o, ""); + } + if let Some(v) = p.position { + let _ = write!(o, ""); + } + if let Some(v) = p.size { + let _ = write!(o, "", v.clamp(1, 3277)); + } + if let Some(v) = p.size_cs { + let _ = write!(o, "", v.clamp(1, 3277)); + } + if let Some(h) = p.highlight { + o.push_str(""); + } + if let Some(u) = p.underline { + o.push_str(""); + } + if let Some(b) = &p.border { + border(o, "bdr", b); + } + if let Some(s) = &p.shading { + shading(o, s); + } + if let Some(v) = p.vert_align { + let v = match v { + VertAlign::Baseline => "baseline", + VertAlign::Superscript => "superscript", + VertAlign::Subscript => "subscript", + }; + let _ = write!(o, ""); + } + toggle(o, "rtl", p.rtl); + toggle(o, "cs", p.cs); + if p.lang.is_some() || p.lang_east_asia.is_some() || p.lang_bidi.is_some() { + o.push_str(""); + } +} + +pub fn run_props(o: &mut String, p: &RunProps) { + let mut b = String::new(); + run_props_body(&mut b, p); + if !b.is_empty() { + o.push_str(""); + o.push_str(&b); + o.push_str(""); + } +} + +fn jc_val(j: Jc) -> &'static str { + match j { + Jc::Left => "left", + Jc::Center => "center", + Jc::Right => "right", + Jc::Both => "both", + Jc::Distribute => "distribute", + } +} + +fn frame_pr(o: &mut String, f: &Frame) { + o.push_str(""); +} + +/// The children of a w:pPr, in schema order, without the paragraph mark's +/// run properties. +pub fn para_props_body(o: &mut String, p: &ParaProps) { + if let Some(s) = &p.style { + o.push_str(""); + } + toggle(o, "keepNext", p.keep_next); + toggle(o, "keepLines", p.keep_lines); + toggle(o, "pageBreakBefore", p.page_break_before); + if let Some(f) = &p.frame { + frame_pr(o, f); + } + toggle(o, "widowControl", p.widow_control); + if p.num_id.is_some() || p.num_level.is_some() { + o.push_str(""); + if let Some(l) = p.num_level { + let _ = write!(o, "", l.min(8)); + } + if let Some(n) = p.num_id { + let _ = write!(o, ""); + } + o.push_str(""); + } + toggle(o, "suppressLineNumbers", p.suppress_line_numbers); + if !p.borders.is_empty() { + o.push_str(""); + let b = &p.borders; + for (name, v) in [ + ("top", &b.top), + ("left", &b.left), + ("bottom", &b.bottom), + ("right", &b.right), + ("between", &b.between), + ] { + if let Some(v) = v { + border(o, name, v); + } + } + o.push_str(""); + } + if let Some(s) = &p.shading { + shading(o, s); + } + if !p.tabs.is_empty() { + o.push_str(""); + for t in p.tabs.iter().take(64) { + let kind = match t.kind { + TabKind::Left => "left", + TabKind::Center => "center", + TabKind::Right => "right", + TabKind::Decimal => "decimal", + TabKind::Bar => "bar", + TabKind::Clear => "clear", + }; + let leader = match t.leader { + TabLeader::None => "none", + TabLeader::Dot => "dot", + TabLeader::Hyphen => "hyphen", + TabLeader::Underscore => "underscore", + TabLeader::MiddleDot => "middleDot", + TabLeader::Heavy => "heavy", + }; + let _ = write!( + o, + "", + t.pos + ); + } + o.push_str(""); + } + toggle(o, "bidi", p.bidi); + toggle(o, "snapToGrid", p.snap_to_grid); + if p.space_before.is_some() + || p.space_after.is_some() + || p.line.is_some() + || p.before_auto.is_some() + || p.after_auto.is_some() + { + o.push_str(" "auto", + LineRule::Exact => "exact", + LineRule::AtLeast => "atLeast", + }, + ); + } + o.push_str("/>"); + } + if p.ind_left.is_some() + || p.ind_right.is_some() + || p.ind_first_line.is_some() + || p.ind_hanging.is_some() + { + o.push_str(""); + } + toggle(o, "contextualSpacing", p.contextual_spacing); + if let Some(j) = p.jc { + let _ = write!(o, "", jc_val(j)); + } + if let Some(l) = p.outline_level { + let _ = write!(o, "", l.min(9)); + } +} + +pub fn para_props(o: &mut String, p: &ParaProps, mark: &RunProps, section: Option<&str>) { + let mut b = String::new(); + para_props_body(&mut b, p); + run_props(&mut b, mark); + if let Some(s) = section { + b.push_str(s); + } + if !b.is_empty() { + o.push_str(""); + o.push_str(&b); + o.push_str(""); + } +} + +pub fn width(o: &mut String, name: &str, w: Width) { + match w { + Width::Auto => { + let _ = write!(o, ""); + } + Width::Twips(v) => { + let _ = write!(o, ""); + } + Width::Pct(v) => { + let _ = write!(o, ""); + } + } +} + +fn margins(o: &mut String, name: &str, m: &Margins) { + if m.top.is_none() && m.left.is_none() && m.bottom.is_none() && m.right.is_none() { + return; + } + let _ = write!(o, ""); + for (n, v) in [ + ("top", m.top), + ("left", m.left), + ("bottom", m.bottom), + ("right", m.right), + ] { + if let Some(v) = v { + let _ = write!(o, "", v.max(0)); + } + } + let _ = write!(o, ""); +} + +fn table_borders(o: &mut String, name: &str, b: &TableBorders, cell: bool) { + let any = b.top.is_some() + || b.left.is_some() + || b.bottom.is_some() + || b.right.is_some() + || b.inside_h.is_some() + || b.inside_v.is_some(); + if !any { + return; + } + let _ = write!(o, ""); + let all = [ + ("top", &b.top), + ("left", &b.left), + ("bottom", &b.bottom), + ("right", &b.right), + ("insideH", &b.inside_h), + ("insideV", &b.inside_v), + ]; + for (n, v) in all { + if let Some(v) = v { + border(o, n, v); + } + } + let _ = cell; + let _ = write!(o, ""); +} + +pub fn table_props(o: &mut String, t: &TableProps, with_style: bool) { + let mut b = String::new(); + if with_style && let Some(s) = &t.style { + b.push_str(""); + } + if let Some(f) = &t.float { + b.push_str(""); + } + if t.bidi { + b.push_str(""); + } + if let Some(w) = t.width { + width(&mut b, "tblW", w); + } + if let Some(j) = t.jc { + let _ = write!(b, "", jc_val(j)); + } + if let Some(v) = t.cell_spacing { + let _ = write!(b, ""); + } + if let Some(v) = t.indent { + let _ = write!(b, ""); + } + table_borders(&mut b, "tblBorders", &t.borders, false); + if let Some(s) = &t.shading { + shading(&mut b, s); + } + if t.fixed_layout { + b.push_str(""); + } + margins(&mut b, "tblCellMar", &t.cell_margins); + if !b.is_empty() || with_style { + o.push_str(""); + o.push_str(&b); + o.push_str(""); + } +} + +pub fn row_props(o: &mut String, r: &RowProps) { + let mut b = String::new(); + if r.grid_before > 0 { + let _ = write!(b, "", r.grid_before); + } + if r.grid_after > 0 { + let _ = write!(b, "", r.grid_after); + } + if let Some(w) = r.width_before { + width(&mut b, "wBefore", w); + } + if let Some(w) = r.width_after { + width(&mut b, "wAfter", w); + } + if r.cant_split { + b.push_str(""); + } + if let Some(h) = r.height { + let _ = write!( + b, + "", + h.max(0), + if r.height_exact { "exact" } else { "atLeast" } + ); + } + if r.header { + b.push_str(""); + } + if let Some(j) = r.jc { + let _ = write!(b, "", jc_val(j)); + } + if !b.is_empty() { + o.push_str(""); + o.push_str(&b); + o.push_str(""); + } +} + +pub fn cell_props(o: &mut String, c: &CellProps) { + let mut b = String::new(); + if let Some(w) = c.width { + width(&mut b, "tcW", w); + } + if c.grid_span > 1 { + let _ = write!(b, "", c.grid_span); + } + match c.v_merge { + VMerge::None => {} + VMerge::Restart => b.push_str(""), + VMerge::Continue => b.push_str(""), + } + table_borders(&mut b, "tcBorders", &c.borders, true); + if let Some(s) = &c.shading { + shading(&mut b, s); + } + if c.no_wrap { + b.push_str(""); + } + margins(&mut b, "tcMar", &c.margins); + if let Some(d) = c.text_direction { + let _ = write!(b, ""); + } + if c.fit_text { + b.push_str(""); + } + if let Some(v) = c.v_align { + let v = match v { + VAlign::Top => "top", + VAlign::Center => "center", + VAlign::Bottom => "bottom", + }; + let _ = write!(b, ""); + } + if !b.is_empty() { + o.push_str(""); + o.push_str(&b); + o.push_str(""); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_properties_come_in_schema_order() { + let p = RunProps { + bold: Some(true), + size: Some(24), + color: Some(Color::Rgb(0xFF0000)), + underline: Some("single"), + italic: Some(false), + ..RunProps::default() + }; + let mut o = String::new(); + run_props(&mut o, &p); + assert_eq!( + o, + "" + ); + // Sizes the format cannot hold are cut to its range. + let big = RunProps { + size: Some(100_000), + ..RunProps::default() + }; + let mut o = String::new(); + run_props(&mut o, &big); + assert_eq!(o, ""); + } + + #[test] + fn paragraph_properties() { + let p = ParaProps { + style: Some("Heading1".into()), + jc: Some(Jc::Center), + ind_left: Some(720), + ind_first_line: Some(360), + space_after: Some(120), + num_id: Some(3), + num_level: Some(1), + ..ParaProps::default() + }; + let mut o = String::new(); + para_props(&mut o, &p, &RunProps::default(), None); + assert_eq!( + o, + "" + ); + } +} diff --git a/crates/exav-render/src/formats/office/docs/error.rs b/crates/exav-render/src/formats/office/docs/error.rs new file mode 100644 index 00000000..672cff91 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/error.rs @@ -0,0 +1,70 @@ +use std::fmt; + +/// Why a document was not converted. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// The bytes are not a document of the format asked for. + NotFormat(String), + /// Encrypted with a password. + Encrypted, + /// A valid document of a version or kind this reader does not convert. + Unsupported(String), + /// Past one of the [`Limits`](crate::formats::office::docs::Limits): what it was. + TooLarge(&'static str), + /// Damaged beyond what this reader recovers from. + Malformed(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::NotFormat(why) => write!(f, "not a document of this format: {why}"), + Error::Encrypted => f.write_str("encrypted with a password"), + Error::Unsupported(what) => write!(f, "unsupported: {what}"), + Error::TooLarge(what) => write!(f, "too large: {what}"), + Error::Malformed(why) => write!(f, "damaged: {why}"), + } + } +} + +impl std::error::Error for Error {} + +impl Error { + /// A short machine-readable kind: `not-format`, `encrypted`, + /// `unsupported`, `too-large` or `malformed`. + pub fn kind(&self) -> &'static str { + match self { + Error::NotFormat(_) => "not-format", + Error::Encrypted => "encrypted", + Error::Unsupported(_) => "unsupported", + Error::TooLarge(_) => "too-large", + Error::Malformed(_) => "malformed", + } + } +} + +impl From for Error { + fn from(e: crate::formats::office::common::xml::Error) -> Self { + use crate::formats::office::common::xml::Error as X; + match e { + X::Malformed(why) => Error::Malformed(format!("XML: {why}")), + X::TooLarge(what) => Error::TooLarge(what), + X::NotFormat(why) => Error::NotFormat(why), + } + } +} + +impl From for Error { + fn from(e: crate::formats::office::common::package::Error) -> Self { + use crate::formats::office::common::package::Error as P; + match e { + P::NotZip(why) => Error::NotFormat(format!("not a ZIP package: {why}")), + P::Encrypted => Error::Encrypted, + P::TooLarge => Error::TooLarge("a package part"), + P::Write(why) => Error::Malformed(format!("writing the package: {why}")), + } + } +} + +pub(crate) type Result = std::result::Result; diff --git a/crates/exav-render/src/formats/office/docs/fields.rs b/crates/exav-render/src/formats/office/docs/fields.rs new file mode 100644 index 00000000..56a4e2b5 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/fields.rs @@ -0,0 +1,77 @@ +//! Field instructions as the formats write them. + +/// `HYPERLINK "target" \l "anchor" \o "tip"`: the target, the anchor and the +/// tooltip. Targets that run script are dropped. +pub fn parse_hyperlink(instr: &str) -> (Option, Option, Option) { + let mut target = None; + let mut anchor = None; + let mut tip = None; + let mut words: Vec = vec![]; + let mut cur = String::new(); + let mut quoted = false; + let mut had = false; + for c in instr.chars() { + match c { + '"' => { + quoted = !quoted; + had = true; + } + c if c.is_whitespace() && !quoted => { + if !cur.is_empty() || had { + words.push(std::mem::take(&mut cur)); + had = false; + } + } + c => cur.push(c), + } + } + if !cur.is_empty() || had { + words.push(cur); + } + let mut i = 1; + while i < words.len() { + match words[i].as_str() { + "\\l" => { + anchor = words.get(i + 1).cloned(); + i += 1; + } + "\\o" => { + tip = words.get(i + 1).cloned(); + i += 1; + } + "\\t" | "\\m" | "\\n" => { + i += 1; + } + w if !w.starts_with('\\') && target.is_none() => target = Some(w.to_string()), + _ => {} + } + i += 1; + } + let target = target.filter(|t| safe_target(t)); + (target, anchor, tip) +} + +/// Whether a link target is one a viewer may follow: not a script or data +/// URL. +pub fn safe_target(t: &str) -> bool { + let l = t.trim().to_ascii_lowercase(); + !(l.starts_with("javascript:") || l.starts_with("vbscript:") || l.starts_with("data:")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hyperlink_instructions() { + let (t, a, tip) = parse_hyperlink("HYPERLINK \"http://example.org/a b\" \\o \"Tip\""); + assert_eq!(t.as_deref(), Some("http://example.org/a b")); + assert_eq!(tip.as_deref(), Some("Tip")); + assert!(a.is_none()); + let (t, a, _) = parse_hyperlink("HYPERLINK \\l \"_Toc1\""); + assert!(t.is_none()); + assert_eq!(a.as_deref(), Some("_Toc1")); + let (t, _, _) = parse_hyperlink("HYPERLINK \"javascript:alert(1)\""); + assert!(t.is_none()); + } +} diff --git a/crates/exav-render/src/formats/office/docs/grid.rs b/crates/exav-render/src/formats/office/docs/grid.rs new file mode 100644 index 00000000..57aa78ad --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/grid.rs @@ -0,0 +1,169 @@ +//! Tables whose rows give their own cell edges (DOC, RTF, Word 2003 XML): the +//! edges of all rows make up the grid, and a cell spans the grid columns +//! between its edges. + +use crate::formats::office::docs::model::*; + +const TOL: i32 = 3; + +/// A cell of a row as its reader found it. +pub struct CellIn { + pub props: CellProps, + pub content: Vec, + /// Merged into the cell before it (its edge is joined to that cell). + pub merged_left: bool, +} + +/// A row: the cell edges in twips (one more than cells), and the cells. +pub struct RowIn { + pub edges: Vec, + pub props: RowProps, + pub cells: Vec, +} + +fn nearest(grid: &[i32], x: i32) -> usize { + let mut best = 0usize; + for (i, g) in grid.iter().enumerate() { + if (g - x).abs() < (grid[best] - x).abs() { + best = i; + } + } + best +} + +/// The table made of `rows`, with `props` for the whole table. +pub fn build(rows: Vec, mut props: TableProps) -> Table { + let mut all: Vec = rows.iter().flat_map(|r| r.edges.iter().copied()).collect(); + all.sort_unstable(); + let mut grid: Vec = vec![]; + for x in all { + if grid.last().is_none_or(|l| x - l > TOL) { + grid.push(x); + } + } + if grid.len() < 2 { + grid = vec![0, 1]; + } + props.fixed_layout = true; + if props.width.is_none() { + props.width = Some(Width::Twips(grid[grid.len() - 1] - grid[0])); + } + let m = grid.len() - 1; + let mut table = Table { + props, + grid: grid.array_windows().map(|[a, b]| b - a).collect(), + rows: vec![], + }; + for r in rows { + let mut row = Row { + props: r.props, + cells: vec![], + }; + let mut placed: Vec<(usize, usize, Cell)> = vec![]; + for (k, c) in r.cells.into_iter().enumerate() { + let (Some(&le), Some(&re)) = (r.edges.get(k), r.edges.get(k + 1)) else { + // A cell with no edges: its content joins the last cell. + if let Some(last) = placed.last_mut() { + last.2.content.extend(c.content); + } + continue; + }; + let (l, rr) = (nearest(&grid, le), nearest(&grid, re)); + if c.merged_left + && let Some(prev) = placed.last_mut() + { + prev.1 = rr.max(prev.1); + continue; + } + let mut cell = Cell { + props: c.props, + content: if c.content.is_empty() { + vec![Block::Para(Para::default())] + } else { + c.content + }, + }; + cell.props.width = Some(Width::Twips((re - le).max(0))); + placed.push((l, rr, cell)); + } + if placed.is_empty() { + continue; + } + let first = placed[0].0; + row.props.grid_before = first as u32; + if first > 0 { + row.props.width_before = Some(Width::Twips(grid[first] - grid[0])); + } + let last = placed[placed.len() - 1].1; + row.props.grid_after = m.saturating_sub(last) as u32; + if last < m { + row.props.width_after = Some(Width::Twips(grid[m] - grid[last])); + } + for (l, rr, mut c) in placed { + c.props.grid_span = rr.saturating_sub(l).max(1) as u32; + row.cells.push(c); + } + table.rows.push(row); + } + table +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cell(merged_left: bool) -> CellIn { + CellIn { + props: CellProps::default(), + content: vec![], + merged_left, + } + } + + #[test] + fn edges_make_the_grid_and_spans() { + let rows = vec![ + RowIn { + edges: vec![0, 1000, 2000], + props: RowProps::default(), + cells: vec![cell(false), cell(false)], + }, + RowIn { + edges: vec![0, 1000, 1500, 2000], + props: RowProps::default(), + cells: vec![cell(false), cell(false), cell(false)], + }, + // The second and third cells are one. + RowIn { + edges: vec![0, 1000, 1500, 2000], + props: RowProps::default(), + cells: vec![cell(false), cell(false), cell(true)], + }, + ]; + let t = build(rows, TableProps::default()); + assert_eq!(t.grid, vec![1000, 500, 500]); + assert_eq!(t.rows[0].cells[1].props.grid_span, 2); + assert_eq!(t.rows[1].cells.len(), 3); + assert_eq!(t.rows[2].cells.len(), 2); + assert_eq!(t.rows[2].cells[1].props.grid_span, 2); + } + + #[test] + fn a_row_that_starts_late_has_grid_before() { + let rows = vec![ + RowIn { + edges: vec![0, 1000, 2000], + props: RowProps::default(), + cells: vec![cell(false), cell(false)], + }, + RowIn { + edges: vec![1000, 2000], + props: RowProps::default(), + cells: vec![cell(false)], + }, + ]; + let t = build(rows, TableProps::default()); + assert_eq!(t.rows[1].props.grid_before, 1); + assert_eq!(t.rows[1].props.width_before, Some(Width::Twips(1000))); + } +} diff --git a/crates/exav-render/src/formats/office/docs/html/mime.rs b/crates/exav-render/src/formats/office/docs/html/mime.rs new file mode 100644 index 00000000..9cc19ef3 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/html/mime.rs @@ -0,0 +1,226 @@ +//! The HTML page and the pictures of a MIME-wrapped page: what Word saves as +//! "Single File Web Page" (MHTML), often under the name `.doc`. A multipart +//! message whose first HTML part is the page and whose image parts are named by +//! their `Content-Location` or `Content-ID`. + +use crate::formats::office::common::base64; +use crate::formats::office::docs::error::Result; +use crate::formats::office::docs::limits::Budget; +use exav_unpack::html::decode as html_utf8; + +/// Parts read, and levels of multipart in multipart. +const MAX_PARTS: usize = 4096; +const MAX_DEPTH: usize = 3; + +pub(super) struct Picture { + /// What the page may call it: the location as given, its last component, + /// the content ID. Lower case. + pub names: Vec, + pub data: Vec, +} + +pub(super) struct Mime { + pub html: String, + pub pictures: Vec, +} + +/// The page and pictures of `input` when it is a MIME message with an HTML part. +pub(super) fn parse(input: &[u8], budget: &mut Budget) -> Result> { + let head = String::from_utf8_lossy(&input[..input.len().min(2048)]).to_ascii_lowercase(); + if !head.trim_start().starts_with("mime-version:") + && !(head.contains("\nmime-version:") && head.contains("content-type:")) + { + return Ok(None); + } + let mut mime = Mime { + html: String::new(), + pictures: vec![], + }; + let mut parts = 0; + let found = walk(input, 0, &mut parts, &mut mime, budget)?; + Ok((found && !mime.html.is_empty()).then_some(mime)) +} + +/// A message or part: its headers, and its body. +fn split(data: &[u8]) -> (Vec<(String, String)>, &[u8]) { + let (end, skip) = ["\r\n\r\n", "\n\n"] + .iter() + .filter_map(|sep| find(data, sep.as_bytes()).map(|at| (at, sep.len()))) + .min() + .unwrap_or((data.len(), 0)); + let block = String::from_utf8_lossy(&data[..end]).to_string(); + let mut headers: Vec<(String, String)> = vec![]; + for line in block.lines() { + if line.starts_with([' ', '\t']) { + if let Some(last) = headers.last_mut() { + last.1.push(' '); + last.1.push_str(line.trim()); + } + } else if let Some((k, v)) = line.split_once(':') { + headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string())); + } + } + (headers, &data[(end + skip).min(data.len())..]) +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +fn header<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> { + h.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) +} + +/// A parameter of a header value: `charset=utf-8`, `boundary="x"`. +fn param(value: &str, name: &str) -> Option { + let lower = value.to_ascii_lowercase(); + let at = lower.find(&format!("{name}="))?; + let rest = &value[at + name.len() + 1..]; + let rest = rest.trim_start(); + Some(if let Some(q) = rest.strip_prefix('"') { + q.split('"').next()?.to_string() + } else { + rest.split([';', ' ', '\t']).next()?.to_string() + }) +} + +fn walk( + data: &[u8], + depth: usize, + parts: &mut usize, + out: &mut Mime, + budget: &mut Budget, +) -> Result { + *parts += 1; + if *parts > MAX_PARTS || depth > MAX_DEPTH { + return Ok(false); + } + let (h, body) = split(data); + let ctype = header(&h, "content-type").unwrap_or("text/plain"); + let lower = ctype.to_ascii_lowercase(); + if lower.starts_with("multipart/") { + let Some(boundary) = param(ctype, "boundary") else { + return Ok(false); + }; + let delim = format!("--{boundary}"); + let mut found = false; + let mut at = 0; + // The parts: between one delimiter line and the next. + let mut starts = vec![]; + while let Some(p) = find(&body[at..], delim.as_bytes()).map(|p| p + at) { + if p == 0 || body[p - 1] == b'\n' { + starts.push(p); + } + at = p + delim.len(); + } + for &[start, next] in starts.array_windows() { + let after = start + delim.len(); + // The delimiter's line ends; the part follows, up to the line break + // that belongs to the next delimiter. + let Some(nl) = body[after..] + .iter() + .position(|&b| b == b'\n') + .map(|n| after + n + 1) + else { + continue; + }; + let mut end = next; + if end > nl && body[end - 1] == b'\n' { + end -= 1; + if end > nl && body[end - 1] == b'\r' { + end -= 1; + } + } + if end < nl { + continue; + } + found |= walk(&body[nl..end], depth + 1, parts, out, budget)?; + } + return Ok(found); + } + let decoded = decode(&h, body, budget)?; + if lower.starts_with("text/html") { + if out.html.is_empty() { + let text = match param(ctype, "charset") + .and_then(|c| encoding_rs::Encoding::for_label(c.as_bytes())) + { + Some(enc) => enc.decode_without_bom_handling(&decoded).0.into_owned(), + None => html_utf8(&decoded).into_owned(), + }; + out.html = text; + return Ok(true); + } + } else if lower.starts_with("image/") { + let mut names = vec![]; + if let Some(l) = header(&h, "content-location") { + let l = l.trim().to_ascii_lowercase(); + if let Some(last) = l.rsplit(['/', '\\']).next() { + names.push(last.to_string()); + } + names.push(l); + } + if let Some(id) = header(&h, "content-id") { + names.push(id.trim().trim_matches(['<', '>']).to_ascii_lowercase()); + } + if !names.is_empty() { + out.pictures.push(Picture { + names, + data: decoded, + }); + } + } + Ok(false) +} + +/// A body with its transfer encoding undone. +fn decode(h: &[(String, String)], body: &[u8], budget: &mut Budget) -> Result> { + let max = budget.limits.max_part as usize; + let out = match header(h, "content-transfer-encoding") + .map(|e| e.trim().to_ascii_lowercase()) + .as_deref() + { + Some("quoted-printable") => quoted_printable(body), + Some("base64") => { + let text = String::from_utf8_lossy(body); + base64::decode(&text, max).unwrap_or_default() + } + _ => body.to_vec(), + }; + budget.alloc(out.len() as u64)?; + Ok(out) +} + +fn quoted_printable(b: &[u8]) -> Vec { + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] != b'=' { + out.push(b[i]); + i += 1; + continue; + } + // A soft line break, or a byte in hexadecimal. + match (b.get(i + 1), b.get(i + 2)) { + (Some(b'\r'), Some(b'\n')) => i += 3, + (Some(b'\n'), _) => i += 2, + (Some(&a), Some(&c)) => { + let hex = |x: u8| (x as char).to_digit(16); + match (hex(a), hex(c)) { + (Some(a), Some(c)) => { + out.push((a * 16 + c) as u8); + i += 3; + } + _ => { + out.push(b'='); + i += 1; + } + } + } + _ => { + out.push(b'='); + i += 1; + } + } + } + out +} diff --git a/crates/exav-render/src/formats/office/docs/html/mod.rs b/crates/exav-render/src/formats/office/docs/html/mod.rs new file mode 100644 index 00000000..aa922658 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/html/mod.rs @@ -0,0 +1,1421 @@ +//! HTML pages to the document model: Word's "Save as Web Page" (a `.doc` that +//! is HTML, the most common thing named `.doc` that is not a Word document) and +//! other pages read as documents. Text, headings, lists, tables, links, and the +//! character and paragraph formatting of the style sheet and the `style` +//! attributes. Data only: scripts, frames and forms are dropped, nothing is +//! fetched, a picture held in a `data:` address is embedded and one named by a +//! file or a URL is told by its alternative text, and a link is kept only when +//! it goes to a web, mail, phone or FTP address or to a place in the page. +//! +//! The tokens are those of `exav_unpack::html`, which the spreadsheet +//! converter reads Excel's pages with too; the style sheets are +//! `common::css`. + +use crate::formats::office::common::css::{self, Sheet}; +use crate::formats::office::common::{base64, image}; +use crate::formats::office::docs::error::{Error, Result}; +use crate::formats::office::docs::limits::Budget; +use crate::formats::office::docs::model::*; +use exav_unpack::html::{self, Token, attr as get}; +use std::borrow::Cow; +use std::collections::HashMap; + +mod mime; + +/// Open elements kept; deeper ones are read as text of their parent. +const MAX_DEPTH: usize = 128; +/// Twips in a pixel at 96 dpi. +const PX: f64 = 15.0; +/// EMU in a pixel. +const PX_EMU: i64 = 9525; +/// The widest a picture is drawn: the text width of a letter page. +const MAX_PICTURE: i64 = 5_943_600; + +/// Elements that have no end tag. +const VOID: &[&str] = &[ + "br", "hr", "img", "meta", "link", "input", "col", "base", "area", "param", "wbr", "embed", + "source", "track", +]; +/// Elements whose content is not text of the page. +const HIDDEN: &[&str] = &[ + "head", "script", "style", "title", "xml", "noscript", "template", "iframe", "frameset", + "select", "textarea", "object", "applet", "svg", "math", "canvas", "audio", "video", +]; +/// Elements that start a paragraph of their own. +const BLOCKS: &[&str] = &[ + "p", + "div", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "pre", + "blockquote", + "center", + "address", + "li", + "dt", + "dd", + "ul", + "ol", + "dl", + "form", + "fieldset", + "legend", + "section", + "article", + "header", + "footer", + "nav", + "main", + "aside", + "figure", + "figcaption", + "caption", + "body", + "menu", + "dir", + "listing", + "xmp", + "plaintext", +]; + +struct Frame { + name: String, + /// The character properties in force, from the document's root to here. + run: RunProps, + /// The paragraph properties a paragraph started here has. + para: ParaProps, + hidden: bool, + /// Whitespace is kept (`pre`). + pre: bool, + /// A list: whether numbered, and the count so far. + list: Option<(bool, u32)>, +} + +struct CellB { + blocks: Vec, + colspan: u32, + rowspan: u32, + props: CellProps, +} + +#[derive(Default)] +struct TableB { + props: TableProps, + rows: Vec>, + row: Option>, +} + +enum Cont { + Body(Vec), + Table(TableB), + Cell(CellB), +} + +struct Link { + target: Option, + anchor: Option, + tooltip: Option, + content: Vec, +} + +struct Html<'a> { + b: &'a mut Budget, + doc: Document, + css: Sheet, + conts: Vec, + frames: Vec, + cur: Option, + /// Bytes of text in `cur`, and whether it ends with a space. + cur_len: usize, + space: bool, + links: Vec, + next_bookmark: u32, + pictures: Vec, +} + +pub(crate) fn read(input: &[u8], budget: &mut Budget) -> Result { + budget.alloc(input.len() as u64 * 6)?; + // A page saved with its pictures in one MIME message is read from it. + let (text, pictures): (Cow, Vec) = match mime::parse(input, budget)? { + Some(m) => (Cow::Owned(m.html), m.pictures), + None => (html::decode(input), vec![]), + }; + let tokens = html::without_downlevel( + html::read(&text, &mut budget.tree()).map_err(|_| Error::TooLarge("HTML elements"))?, + ); + if !tokens.iter().any(|t| { + matches!(t, Token::Start { name, .. } + if matches!(name.as_str(), "html" | "body" | "p" | "div" | "table" | "h1" | "span")) + }) { + return Err(Error::NotFormat("not an HTML document".into())); + } + let mut css = Sheet::default(); + let mut page = None; + for (k, t) in tokens.iter().enumerate() { + if let Token::Start { name, .. } = t + && name == "style" + && let Some(Token::Text(c)) = tokens.get(k + 1) + { + css.add(c); + page = page.or_else(|| page_rule(c)); + } + } + let mut h = Html { + b: budget, + doc: Document::default(), + css, + conts: vec![Cont::Body(vec![])], + frames: vec![], + cur: None, + cur_len: 0, + space: true, + links: vec![], + next_bookmark: 1, + pictures, + }; + h.doc.defaults_run.size = Some(24); + h.doc.defaults_run.fonts.ascii = Some("Times New Roman".into()); + h.doc.defaults_run.fonts.hansi = Some("Times New Roman".into()); + if let Some(s) = page { + h.doc.section = s; + } + let mut root = Frame { + name: String::new(), + run: RunProps::default(), + para: ParaProps::default(), + hidden: false, + pre: false, + list: None, + }; + { + // What the style sheet says of the whole page. + let mut d = vec![]; + h.css.lookup("body", &[], &mut d); + apply_run(&d, &mut root.run); + apply_para(&d, &mut root.para); + } + h.frames.push(root); + for t in &tokens { + match t { + Token::Start { + name, + attrs, + closed, + } => h.start(name, attrs, *closed)?, + Token::End(name) => h.end(name)?, + Token::Text(s) => h.text(s)?, + Token::Comment(_) => {} + } + } + h.finish()?; + Ok(h.doc) +} + +impl Html<'_> { + fn top(&self) -> &Frame { + self.frames.last().expect("the root frame stays") + } + + fn hidden(&self) -> bool { + self.top().hidden + } + + /// The declarations of an element: the style sheet's for its name and + /// classes, then its `style` attribute's. + fn decls(&self, name: &str, attrs: &[(String, String)]) -> Vec<(String, String)> { + let classes: Vec<&str> = get(attrs, "class") + .map(|c| c.split_whitespace().collect()) + .unwrap_or_default(); + let mut d = vec![]; + self.css.lookup(name, &classes, &mut d); + if let Some(s) = get(attrs, "style") { + d.extend(css::declarations(s)); + } + d + } + + // ------------------------------------------------------------ paragraphs + + /// The paragraph being built, started with the properties in force. + fn para(&mut self) -> &mut Para { + if self.cur.is_none() { + let top = self.top(); + self.cur = Some(Para { + props: top.para.clone(), + mark: RunProps::default(), + content: vec![], + section: None, + }); + self.cur_len = 0; + self.space = true; + } + self.cur.as_mut().expect("just made") + } + + fn add(&mut self, i: Inline) { + if self.links.is_empty() { + self.para().content.push(i); + } else { + self.para(); + if let Some(l) = self.links.last_mut() { + l.content.push(i); + } + } + } + + fn text(&mut self, s: &str) -> Result<()> { + if self.hidden() { + return Ok(()); + } + let pre = self.top().pre; + let mut out = String::with_capacity(s.len()); + if pre { + out.push_str(&s.replace("\r\n", "\n").replace('\r', "\n")); + } else { + // White space runs to one space, and none after another. + let mut after_space = self.space; + for c in s.chars() { + if matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0c') { + if !after_space { + out.push(' '); + after_space = true; + } + } else { + out.push(c); + after_space = false; + } + } + } + if out.is_empty() { + return Ok(()); + } + self.b.alloc(out.len() as u64 * 2)?; + self.cur_len += out.len(); + self.space = out.ends_with(' ') || out.ends_with('\n'); + let props = self.top().run.clone(); + if pre { + // Lines are breaks in the one paragraph. + let mut items = vec![]; + for (n, line) in out.split('\n').enumerate() { + if n > 0 { + items.push(RunItem::Break(BreakKind::Line)); + } + if !line.is_empty() { + items.push(RunItem::Text(line.to_string())); + } + } + self.add(Inline::Run(Run { props, items })); + } else { + self.add(Inline::Run(Run::text(props, out))); + } + Ok(()) + } + + /// Ends the paragraph being built. One with no content is dropped, unless + /// `keep` (an element that is a line of its own). + fn flush(&mut self, keep: bool) -> Result<()> { + while let Some(l) = self.links.pop() { + self.link_into(l); + } + // The next paragraph starts at the start of a line. + self.space = true; + let Some(mut p) = self.cur.take() else { + return Ok(()); + }; + // Trailing white space is not shown. + if let Some(Inline::Run(r)) = p.content.last_mut() + && let Some(RunItem::Text(t)) = r.items.last_mut() + { + let trimmed = t.trim_end_matches(' ').len(); + t.truncate(trimmed); + } + // One with nothing to show (the white space between two blocks) is not + // a paragraph. + let empty = p.content.iter().all(|i| { + matches!(i, Inline::Run(r) + if r.items.iter().all(|it| matches!(it, RunItem::Text(t) if t.is_empty()))) + }); + if empty && !keep { + return Ok(()); + } + self.b.paragraph(self.cur_len)?; + self.cur_len = 0; + self.push_block(Block::Para(p)); + Ok(()) + } + + fn push_block(&mut self, b: Block) { + match self.conts.last_mut() { + Some(Cont::Body(v)) => v.push(b), + Some(Cont::Cell(c)) => c.blocks.push(b), + _ => { + // Content between rows of a table: the cell after it, or the page. + if let Some(Cont::Body(v)) = self.conts.iter_mut().rev().find_map(|c| match c { + Cont::Body(_) | Cont::Cell(_) => Some(c), + Cont::Table(_) => None, + }) { + v.push(b); + } + } + } + } + + fn link_into(&mut self, l: Link) { + let inline = Inline::Hyperlink { + target: l.target, + anchor: l.anchor, + tooltip: l.tooltip, + content: l.content, + }; + match self.links.last_mut() { + Some(outer) => outer.content.push(inline), + None => self.para().content.push(inline), + } + } + + // -------------------------------------------------------------- elements + + fn push_frame(&mut self, name: &str, attrs: &[(String, String)]) { + if self.frames.len() >= MAX_DEPTH { + return; + } + let d = self.decls(name, attrs); + let parent = self.top(); + let mut f = Frame { + name: name.to_string(), + run: parent.run.clone(), + para: parent.para.clone(), + hidden: parent.hidden || HIDDEN.contains(&name), + pre: parent.pre || matches!(name, "pre" | "listing" | "xmp" | "plaintext"), + list: None, + }; + // What the element is, before what the page says of it. + match name { + "b" | "strong" | "th" => f.run.bold = Some(true), + "i" | "em" | "cite" | "dfn" | "var" | "address" => f.run.italic = Some(true), + "u" | "ins" => f.run.underline = Some("single"), + "s" | "strike" | "del" => f.run.strike = Some(true), + "sub" => f.run.vert_align = Some(VertAlign::Subscript), + "sup" => f.run.vert_align = Some(VertAlign::Superscript), + "small" => f.run.size = Some(f.run.size.unwrap_or(24).saturating_sub(2).max(2)), + "big" => f.run.size = Some(f.run.size.unwrap_or(24) + 2), + "code" | "tt" | "kbd" | "samp" | "pre" | "listing" | "xmp" | "plaintext" => { + f.run.fonts.ascii = Some("Courier New".into()); + f.run.fonts.hansi = Some("Courier New".into()); + if name == "pre" { + f.run.size = Some(20); + } + } + "mark" => f.run.highlight = Some("yellow"), + // A link that goes somewhere looks like one. + "a" if get(attrs, "href").is_some_and(|h| safe_link(h) != (None, None)) => { + f.run.color = Some(Color::Rgb(0x0563C1)); + f.run.underline = Some("single"); + } + "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => { + f.run.bold = Some(true); + f.run.size = Some(match name { + "h1" => 48, + "h2" => 36, + "h3" => 28, + "h4" => 24, + "h5" => 20, + _ => 16, + }); + f.para.keep_next = Some(true); + f.para.space_before = Some(240); + f.para.space_after = Some(120); + f.para.outline_level = Some(u32::from(name.as_bytes()[1] - b'1')); + } + "p" | "div" | "dl" | "dt" => {} + "blockquote" | "dd" => { + f.para.ind_left = Some(f.para.ind_left.unwrap_or(0) + 720); + } + "center" => f.para.jc = Some(Jc::Center), + "font" => { + if let Some(c) = get(attrs, "color").and_then(rgb) { + f.run.color = Some(Color::Rgb(c)); + } + if let Some(face) = get(attrs, "face") { + set_font(&mut f.run, face); + } + if let Some(n) = get(attrs, "size").and_then(|s| s.trim().parse::().ok()) { + // The seven sizes of the old `size` attribute, in half points. + let table = [14, 20, 24, 28, 36, 48, 72]; + f.run.size = Some(table[(n.clamp(1, 7) - 1) as usize]); + } + } + "ul" | "menu" | "dir" => f.list = Some((false, 0)), + "ol" => { + f.list = Some(( + true, + get(attrs, "start") + .and_then(|s| s.parse().ok()) + .unwrap_or(1) + - 1, + )) + } + _ => {} + } + if let Some(a) = get(attrs, "align") { + f.para.jc = jc(a).or(f.para.jc); + } + apply_run(&d, &mut f.run); + if BLOCKS.contains(&name) || matches!(name, "td" | "th") { + apply_para(&d, &mut f.para); + if name == "p" && f.para.space_after.is_none() { + f.para.space_after = Some(160); + } + } + if d.iter().any(|(k, v)| { + (k == "display" && v.trim().starts_with("none")) + || (k == "mso-hide" && v.trim() == "all") + || (k == "visibility" && v.trim() == "hidden") + }) { + f.hidden = true; + } + self.frames.push(f); + } + + fn start(&mut self, name: &str, attrs: &[(String, String)], closed: bool) -> Result<()> { + // An element that ends another. + match name { + "p" => self.close_to("p", &["td", "th", "li", "body", "div", "blockquote"])?, + "li" => self.close_to("li", &["ul", "ol", "menu", "dir"])?, + "dt" | "dd" => { + self.close_to("dt", &["dl"])?; + self.close_to("dd", &["dl"])?; + } + "tr" => { + self.close_cell()?; + self.close_to("tr", &["table"])?; + } + "td" | "th" => { + self.close_cell()?; + } + "tbody" | "thead" | "tfoot" => self.close_cell()?, + _ => {} + } + if self.hidden() && !matches!(name, "table") { + if !VOID.contains(&name) && !closed { + self.push_frame(name, attrs); + } + return Ok(()); + } + match name { + "br" => { + self.add(Inline::Run(Run { + props: self.top().run.clone(), + items: vec![RunItem::Break(BreakKind::Line)], + })); + self.space = true; + return Ok(()); + } + "hr" => { + self.flush(false)?; + let mut p = Para::default(); + p.props.borders.bottom = Some(Border::single(6, Color::Auto)); + p.props.space_after = Some(120); + self.b.paragraph(0)?; + self.push_block(Block::Para(p)); + return Ok(()); + } + "img" => return self.image(attrs), + "wbr" => return Ok(()), + _ => {} + } + if VOID.contains(&name) { + return Ok(()); + } + let block = BLOCKS.contains(&name); + if block { + self.flush(false)?; + } + match name { + "table" => { + // Tables nest as deep as elements do, and no deeper. + if self.frames.len() >= MAX_DEPTH { + return Ok(()); + } + self.flush(false)?; + self.push_frame(name, attrs); + let t = table_props(attrs, &self.decls(name, attrs)); + self.conts.push(Cont::Table(TableB { + props: t, + ..TableB::default() + })); + } + "tr" => { + self.push_frame(name, attrs); + if let Some(Cont::Table(t)) = self.conts.last_mut() { + if let Some(r) = t.row.take() { + t.rows.push(r); + } + t.row = Some(vec![]); + } + } + "td" | "th" => { + // A cell with no row or table around it is not one. + if !matches!(self.conts.last(), Some(Cont::Table(_))) { + return Ok(()); + } + if let Some(Cont::Table(t)) = self.conts.last_mut() { + t.row.get_or_insert_with(Vec::new); + } + self.push_frame(name, attrs); + let d = self.decls(name, attrs); + let mut props = cell_props(attrs, &d); + if name == "th" && props.v_align.is_none() { + props.v_align = Some(VAlign::Center); + } + if name == "th" + && let Some(f) = self.frames.last_mut() + { + f.para.jc = f.para.jc.or(Some(Jc::Center)); + } + self.b.cell()?; + let n = |k: &str| get(attrs, k).and_then(|v| v.trim().parse::().ok()); + self.conts.push(Cont::Cell(CellB { + blocks: vec![], + colspan: n("colspan").unwrap_or(1).clamp(1, 1000), + rowspan: n("rowspan").unwrap_or(1).clamp(1, 1000), + props, + })); + } + "a" => { + // A link does not hold another: it ends where the next begins. + if let Some(open) = self.links.pop() { + self.link_into(open); + } + self.push_frame(name, attrs); + if let Some(n) = get(attrs, "name").or_else(|| get(attrs, "id")) { + let id = self.next_bookmark; + self.next_bookmark += 1; + self.add(Inline::BookmarkStart { + id, + name: n.chars().take(40).collect(), + }); + self.add(Inline::BookmarkEnd(id)); + } + if let Some(href) = get(attrs, "href") { + let (target, anchor) = safe_link(href); + if target.is_some() || anchor.is_some() { + self.para(); + self.links.push(Link { + target, + anchor, + tooltip: get(attrs, "title").map(|t| t.chars().take(200).collect()), + content: vec![], + }); + } + } + } + "li" => { + self.push_frame(name, attrs); + let (ordered, n, depth) = self.list_state(); + let marker = if ordered { + format!("{n}. ") + } else { + format!( + "{} ", + ["\u{2022}", "\u{25E6}", "\u{25AA}"][(depth - 1).min(2)] + ) + }; + if let Some(f) = self.frames.last_mut() { + f.para.ind_left = Some(f.para.ind_left.unwrap_or(0) + 360 * depth as i32); + f.para.ind_hanging = Some(360); + f.para.space_after = f.para.space_after.or(Some(40)); + } + self.text_marker(&marker); + } + _ => self.push_frame(name, attrs), + } + if closed && !matches!(name, "table" | "tr" | "td" | "th") { + self.end(name)?; + } + Ok(()) + } + + /// Whether the list around, its count (this item's), and how deep it is. + fn list_state(&mut self) -> (bool, u32, usize) { + let depth = self.frames.iter().filter(|f| f.list.is_some()).count(); + for f in self.frames.iter_mut().rev() { + if let Some((ordered, n)) = &mut f.list { + *n += 1; + return (*ordered, *n, depth); + } + } + (false, 0, 1) + } + + /// A list item's marker, in the item's own paragraph. + fn text_marker(&mut self, m: &str) { + let mut props = self.top().run.clone(); + props.bold = None; + props.italic = None; + self.cur_len += m.len(); + self.add(Inline::Run(Run::text(props, m))); + self.space = true; + } + + /// The picture a page's `src` names among those of its MIME message: by + /// content ID (`cid:`), or by the location it was saved at or its file name. + fn picture(&self, src: &str) -> Option<&[u8]> { + if self.pictures.is_empty() { + return None; + } + let s = src.trim().to_ascii_lowercase(); + let mut names = vec![]; + if let Some(id) = s.strip_prefix("cid:") { + names.push(id.trim_matches(['<', '>']).to_string()); + } else { + let plain = percent_decode(&s); + for n in [s.as_str(), plain.as_str()] { + names.push(n.to_string()); + if let Some(last) = n.rsplit(['/', '\\']).next() { + names.push(last.to_string()); + } + } + } + self.pictures + .iter() + .find(|p| p.names.iter().any(|n| names.contains(n))) + .map(|p| p.data.as_slice()) + } + + fn image(&mut self, attrs: &[(String, String)]) -> Result<()> { + if self.hidden() { + return Ok(()); + } + let src = get(attrs, "src").unwrap_or(""); + let data = src + .strip_prefix("data:") + .and_then(|r| r.split_once(',')) + .filter(|(meta, _)| meta.contains("base64")) + .and_then(|(_, b)| base64::decode(b, self.b.limits.max_part as usize)) + .or_else(|| self.picture(src).map(<[u8]>::to_vec)); + let media = data.and_then(|d| { + let ext = image::ext(&d)?; + Some((ext, d)) + }); + let Some((ext, d)) = media else { + // Nothing to draw: the alternative text, as a browser shows it. + let alt = get(attrs, "alt").map(str::trim).unwrap_or(""); + let text = if alt.is_empty() { + "[image]".to_string() + } else { + format!("[{alt}]") + }; + let mut props = self.top().run.clone(); + props.italic = Some(true); + self.b.alloc(text.len() as u64)?; + self.cur_len += text.len(); + self.add(Inline::Run(Run::text(props, text))); + self.space = false; + return Ok(()); + }; + self.b.alloc(d.len() as u64)?; + let natural = image::natural_emu(&d); + let dim = |k: &str| { + get(attrs, k) + .and_then(|v| v.trim().trim_end_matches("px").parse::().ok()) + .filter(|v| *v > 0.0) + .map(|v| (v * PX_EMU as f64) as i64) + }; + let (mut cx, mut cy) = match (dim("width"), dim("height"), natural) { + (Some(w), Some(h), _) => (w, h), + (Some(w), None, Some((nw, nh))) if nw > 0 => (w, w * nh / nw), + (None, Some(h), Some((nw, nh))) if nh > 0 => (h * nw / nh, h), + (_, _, Some(n)) => n, + _ => (96 * PX_EMU, 96 * PX_EMU), + }; + if cx > MAX_PICTURE { + cy = cy * MAX_PICTURE / cx.max(1); + cx = MAX_PICTURE; + } + let media = self.doc.add_media(ext, d); + let drawing = Drawing { + anchor: None, + cx: cx.max(PX_EMU), + cy: cy.max(PX_EMU), + name: "Picture".into(), + descr: get(attrs, "alt").map(|a| a.chars().take(200).collect()), + kind: DrawingKind::Picture { + media, + crop: Crop::default(), + }, + }; + self.add(Inline::Run(Run { + props: self.top().run.clone(), + items: vec![RunItem::Drawing(Box::new(drawing))], + })); + self.space = false; + Ok(()) + } + + /// Closes the open element `name` if one is open, within the elements that + /// stop the search. + fn close_to(&mut self, name: &str, stop: &[&str]) -> Result<()> { + let mut at = None; + for (i, f) in self.frames.iter().enumerate().rev() { + if f.name == name { + at = Some(i); + break; + } + if stop.contains(&f.name.as_str()) { + break; + } + } + if at.is_some() { + self.end(name)?; + } + Ok(()) + } + + fn end(&mut self, name: &str) -> Result<()> { + if VOID.contains(&name) { + return Ok(()); + } + let Some(at) = self.frames.iter().rposition(|f| f.name == name) else { + return Ok(()); + }; + if at == 0 { + return Ok(()); + } + // What the elements opened since this one stay open for is closed. + while self.frames.len() > at + 1 { + let inner = self.top().name.clone(); + self.end(&inner)?; + } + match name { + "td" | "th" => self.close_cell()?, + "tr" => { + self.close_cell()?; + if let Some(Cont::Table(t)) = self.conts.last_mut() + && let Some(r) = t.row.take() + { + t.rows.push(r); + } + } + "table" => { + self.close_cell()?; + self.flush(false)?; + if let Some(Cont::Table(mut t)) = self.conts.pop() { + if let Some(r) = t.row.take() { + t.rows.push(r); + } + if let Some(table) = self.build_table(t)? { + self.push_block(Block::Table(table)); + } + } + } + "a" => { + if let Some(l) = self.links.pop() { + self.link_into(l); + } + } + _ if BLOCKS.contains(&name) => { + let keep = false; + self.flush(keep)?; + } + _ => {} + } + if self.frames.len() > 1 { + self.frames.pop(); + } + Ok(()) + } + + /// Ends the cell being built, if one is. + fn close_cell(&mut self) -> Result<()> { + if !matches!(self.conts.last(), Some(Cont::Cell(_))) { + return Ok(()); + } + self.flush(false)?; + // The frame of the cell goes with it, and what is open inside it. + while let Some(f) = self.frames.last() { + let name = f.name.clone(); + if name == "td" || name == "th" { + self.frames.pop(); + break; + } + if self.frames.len() <= 1 || name == "table" || name == "tr" { + break; + } + self.frames.pop(); + } + if let Some(Cont::Cell(mut c)) = self.conts.pop() { + if c.blocks.is_empty() { + self.b.paragraph(0)?; + c.blocks.push(Block::Para(Para::default())); + } + if let Some(Cont::Table(t)) = self.conts.last_mut() { + t.row.get_or_insert_with(Vec::new).push(c); + } + } + Ok(()) + } + + fn finish(&mut self) -> Result<()> { + // Whatever is left open ends with the document. + loop { + self.close_cell()?; + match self.conts.last() { + Some(Cont::Table(_)) => { + self.flush(false)?; + if let Some(Cont::Table(mut t)) = self.conts.pop() { + if let Some(r) = t.row.take() { + t.rows.push(r); + } + if let Some(table) = self.build_table(t)? { + self.push_block(Block::Table(table)); + } + } + } + _ => break, + } + } + self.flush(false)?; + let body = match self.conts.pop() { + Some(Cont::Body(v)) => v, + _ => vec![], + }; + self.doc.body = body; + if self.doc.body.is_empty() { + self.b.paragraph(0)?; + self.doc.body.push(Block::Para(Para::default())); + } + Ok(()) + } + + /// The model table of the cells read: the grid, and the cells a row span + /// continues into the rows below. + fn build_table(&mut self, t: TableB) -> Result> { + let rows: Vec> = t.rows.into_iter().filter(|r| !r.is_empty()).collect(); + if rows.is_empty() { + return Ok(None); + } + // Columns: the widest row, its row-spanned cells counted. + let mut carry: HashMap = HashMap::new(); + let mut out_rows = vec![]; + let mut columns = 1usize; + for row in rows { + let mut cells = vec![]; + let mut col = 0usize; + let mut it = row.into_iter(); + loop { + if let Some(&(left, span)) = carry.get(&col) + && left > 0 + { + cells.push(self.continued(span)?); + carry.insert(col, (left - 1, span)); + col += span as usize; + continue; + } + let Some(mut c) = it.next() else { break }; + c.props.grid_span = c.colspan; + if c.rowspan > 1 { + c.props.v_merge = VMerge::Restart; + carry.insert(col, (c.rowspan - 1, c.colspan)); + } + col += c.colspan as usize; + cells.push(Cell { + props: c.props, + content: c.blocks, + }); + } + // A row span that reaches past the last cell of this row. + while let Some(&(left, span)) = carry.get(&col) { + if left == 0 { + break; + } + cells.push(self.continued(span)?); + carry.insert(col, (left - 1, span)); + col += span as usize; + } + columns = columns.max(col); + out_rows.push(Row { + props: RowProps::default(), + cells, + }); + } + let columns = columns.min(1000); + // The text area of the page, shared evenly. + let width = + (self.doc.section.width - self.doc.section.left - self.doc.section.right).max(1440); + let grid = vec![(width / columns as i32).max(120); columns]; + Ok(Some(Table { + props: t.props, + grid, + rows: out_rows, + })) + } + + /// The cell a row span continues into, counted like any other. + fn continued(&mut self, span: u32) -> Result { + self.b.cell()?; + self.b.paragraph(0)?; + Ok(Cell { + props: CellProps { + grid_span: span, + v_merge: VMerge::Continue, + ..CellProps::default() + }, + content: vec![Block::Para(Para::default())], + }) + } +} + +// ------------------------------------------------------------------- values + +fn rgb(s: &str) -> Option { + css::color(s).flatten() +} + +fn jc(v: &str) -> Option { + match v.trim().to_ascii_lowercase().as_str() { + "center" | "middle" => Some(Jc::Center), + "right" | "end" => Some(Jc::Right), + "justify" => Some(Jc::Both), + "left" | "start" => Some(Jc::Left), + _ => None, + } +} + +/// `%20` and the like undone. +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + let hex = |x: u8| (x as char).to_digit(16); + if b[i] == b'%' + && i + 2 < b.len() + && let (Some(a), Some(c)) = (hex(b[i + 1]), hex(b[i + 2])) + { + out.push((a * 16 + c) as u8); + i += 3; + continue; + } + out.push(b[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// A length in twips. +fn twips(v: &str) -> Option { + css::length_pt(v, "pt").map(|p| (p * 20.0).round().clamp(-31_000.0, 31_000.0) as i32) +} + +/// The first family of a `font-family` list that names a font. +fn set_font(r: &mut RunProps, families: &str) { + for f in families.split(',') { + let f = f.trim().trim_matches(['"', '\'']).trim(); + if f.is_empty() { + continue; + } + if matches!( + f.to_ascii_lowercase().as_str(), + "serif" | "sans-serif" | "monospace" | "cursive" | "fantasy" | "system-ui" | "inherit" + ) { + if f.eq_ignore_ascii_case("monospace") { + r.fonts.ascii = Some("Courier New".into()); + r.fonts.hansi = Some("Courier New".into()); + } + continue; + } + r.fonts.ascii = Some(f.chars().take(64).collect()); + r.fonts.hansi = r.fonts.ascii.clone(); + return; + } +} + +fn apply_run(d: &[(String, String)], r: &mut RunProps) { + for (k, v) in d { + let v = v.trim(); + match k.as_str() { + "color" => { + if let Some(c) = css::color(v) { + r.color = Some(c.map_or(Color::Auto, Color::Rgb)); + } + } + "font-family" => set_font(r, v), + "font-size" => { + let pt = match v.to_ascii_lowercase().as_str() { + "xx-small" => Some(7.0), + "x-small" => Some(7.5), + "small" => Some(10.0), + "medium" => Some(12.0), + "large" => Some(13.5), + "x-large" => Some(18.0), + "xx-large" => Some(24.0), + other => css::length_pt(other, "pt"), + }; + if let Some(pt) = pt.filter(|p: &f64| (1.0..=400.0).contains(p)) { + r.size = Some((pt * 2.0).round() as u32); + } + } + "font-weight" => { + let bold = match v { + "bold" | "bolder" => Some(true), + "normal" | "lighter" => Some(false), + n => n.parse::().ok().map(|n| n >= 600), + }; + if bold.is_some() { + r.bold = bold; + } + } + "font-style" => match v { + "italic" | "oblique" => r.italic = Some(true), + "normal" => r.italic = Some(false), + _ => {} + }, + "font-variant" => { + if v.contains("small-caps") { + r.small_caps = Some(true); + } + } + "text-transform" => { + if v == "uppercase" { + r.caps = Some(true); + } + } + "text-decoration" | "text-decoration-line" => { + if v.contains("underline") { + r.underline = Some("single"); + } + if v.contains("line-through") { + r.strike = Some(true); + } + if v == "none" { + r.underline = Some("none"); + r.strike = Some(false); + } + } + "background" | "background-color" => { + let first = v.split_whitespace().next().unwrap_or(""); + if let Some(Some(c)) = css::color(first) { + r.shading = Some(Shading::fill(Color::Rgb(c))); + } + } + "vertical-align" => match v { + "super" => r.vert_align = Some(VertAlign::Superscript), + "sub" => r.vert_align = Some(VertAlign::Subscript), + "baseline" => r.vert_align = Some(VertAlign::Baseline), + _ => {} + }, + _ => {} + } + } +} + +fn apply_para(d: &[(String, String)], p: &mut ParaProps) { + for (k, v) in d { + let v = v.trim(); + match k.as_str() { + "text-align" => p.jc = jc(v).or(p.jc), + "margin-left" => p.ind_left = twips(v).map(|t| t.max(0)).or(p.ind_left), + "margin-right" => p.ind_right = twips(v).map(|t| t.max(0)).or(p.ind_right), + "text-indent" => { + if let Some(t) = twips(v) { + if t < 0 { + p.ind_hanging = Some(-t); + p.ind_first_line = None; + } else { + p.ind_first_line = Some(t); + p.ind_hanging = None; + } + } + } + "margin-top" => p.space_before = twips(v).map(|t| t.max(0)).or(p.space_before), + "margin-bottom" => p.space_after = twips(v).map(|t| t.max(0)).or(p.space_after), + "margin" => { + // One to four values: top, right, bottom, left. + let parts: Vec> = v.split_whitespace().map(twips).collect(); + let at = |i: usize| parts.get(i).copied().flatten().map(|t| t.max(0)); + match parts.len() { + 1 => { + p.space_before = at(0).or(p.space_before); + p.space_after = at(0).or(p.space_after); + p.ind_left = at(0).or(p.ind_left); + p.ind_right = at(0).or(p.ind_right); + } + 2 => { + p.space_before = at(0).or(p.space_before); + p.space_after = at(0).or(p.space_after); + p.ind_left = at(1).or(p.ind_left); + p.ind_right = at(1).or(p.ind_right); + } + 3 | 4 => { + p.space_before = at(0).or(p.space_before); + p.ind_right = at(1).or(p.ind_right); + p.space_after = at(2).or(p.space_after); + p.ind_left = at(if parts.len() == 4 { 3 } else { 1 }).or(p.ind_left); + } + _ => {} + } + } + "page-break-before" | "break-before" => { + if matches!(v, "always" | "page" | "left" | "right") { + p.page_break_before = Some(true); + } + } + "background" | "background-color" => { + let first = v.split_whitespace().next().unwrap_or(""); + if let Some(Some(c)) = css::color(first) { + p.shading = Some(Shading::fill(Color::Rgb(c))); + } + } + _ => {} + } + } +} + +/// A CSS border (`1px solid #000`, `.5pt solid windowtext`) as a border. +fn border(v: &str) -> Option { + let (mut width, mut style, mut color) = (None, None, Color::Auto); + for t in v.split_whitespace() { + let l = t.to_ascii_lowercase(); + match l.as_str() { + "none" | "hidden" => return None, + "solid" | "dashed" | "dotted" | "double" | "groove" | "ridge" | "inset" | "outset" + | "hairline" => style = Some(l), + "thin" => width = Some(0.75), + "medium" => width = Some(1.5), + "thick" => width = Some(2.25), + _ => { + if let Some(w) = css::length_pt(&l, "px") { + width = Some(w); + } else if let Some(c) = css::color(&l) { + color = c.map_or(Color::Auto, Color::Rgb); + } + } + } + } + let style = style?; + let w: f64 = width.unwrap_or(0.75); + if w <= 0.0 { + return None; + } + Some(Border { + style: match style.as_str() { + "double" => "double", + "dashed" => "dashed", + "dotted" => "dotted", + _ => "single", + }, + size: (w * 8.0).round().clamp(2.0, 96.0) as u32, + space: 0, + color, + shadow: false, + }) +} + +fn table_props(attrs: &[(String, String)], d: &[(String, String)]) -> TableProps { + let mut t = TableProps::default(); + let mut width = get(attrs, "width").map(str::to_string); + for (k, v) in d { + match k.as_str() { + "width" => width = Some(v.clone()), + "background" | "background-color" => { + if let Some(Some(c)) = css::color(v.split_whitespace().next().unwrap_or("")) { + t.shading = Some(Shading::fill(Color::Rgb(c))); + } + } + "margin-left" => t.indent = twips(v), + _ => {} + } + } + if let Some(w) = width { + let w = w.trim(); + t.width = match w + .strip_suffix('%') + .and_then(|p| p.trim().parse::().ok()) + { + Some(p) if (1.0..=100.0).contains(&p) => Some(Width::Pct((p * 50.0) as i32)), + _ => twips(w).filter(|&n| n > 0).map(Width::Twips).or_else(|| { + w.parse::() + .ok() + .filter(|&n| n > 0.0) + .map(|n| Width::Twips((n * PX) as i32)) + }), + }; + } + // `border="1"` draws every line; the style's border, the outside ones. + let attr_border = get(attrs, "border") + .and_then(|b| b.trim().parse::().ok()) + .filter(|&n| n > 0.0); + let line = + attr_border.map(|n| Border::single((n * 6.0).round().clamp(2.0, 48.0) as u32, Color::Auto)); + let css_border = d + .iter() + .rev() + .find(|(k, _)| k == "border") + .and_then(|(_, v)| border(v)); + if let Some(b) = line.clone().or(css_border.clone()) { + t.borders = TableBorders { + top: Some(b.clone()), + left: Some(b.clone()), + bottom: Some(b.clone()), + right: Some(b.clone()), + inside_h: line.clone(), + inside_v: line, + }; + } + if let Some(p) = get(attrs, "cellpadding").and_then(|p| p.trim().parse::().ok()) { + let m = (p.clamp(0.0, 100.0) * PX) as i32; + t.cell_margins = Margins { + top: Some(m), + left: Some(m), + bottom: Some(m), + right: Some(m), + }; + } + if let Some(c) = get(attrs, "bgcolor").and_then(rgb) { + t.shading = Some(Shading::fill(Color::Rgb(c))); + } + t +} + +fn cell_props(attrs: &[(String, String)], d: &[(String, String)]) -> CellProps { + let mut c = CellProps::default(); + let mut width = get(attrs, "width").map(str::to_string); + let mut valign = get(attrs, "valign").map(str::to_string); + for (k, v) in d { + match k.as_str() { + "width" => width = Some(v.clone()), + "vertical-align" => valign = Some(v.clone()), + "background" | "background-color" => { + if let Some(Some(rgb)) = css::color(v.split_whitespace().next().unwrap_or("")) { + c.shading = Some(Shading::fill(Color::Rgb(rgb))); + } + } + "border" => { + let b = border(v); + c.borders = TableBorders { + top: b.clone(), + left: b.clone(), + bottom: b.clone(), + right: b, + ..TableBorders::default() + }; + } + "border-top" => c.borders.top = border(v), + "border-left" => c.borders.left = border(v), + "border-bottom" => c.borders.bottom = border(v), + "border-right" => c.borders.right = border(v), + "white-space" => c.no_wrap = v.starts_with("nowrap"), + _ => {} + } + } + if let Some(color) = get(attrs, "bgcolor").and_then(rgb) { + c.shading = Some(Shading::fill(Color::Rgb(color))); + } + if get(attrs, "nowrap").is_some() { + c.no_wrap = true; + } + if let Some(w) = width { + let w = w.trim(); + c.width = match w + .strip_suffix('%') + .and_then(|p| p.trim().parse::().ok()) + { + Some(p) if (1.0..=100.0).contains(&p) => Some(Width::Pct((p * 50.0) as i32)), + _ => twips(w).filter(|&n| n > 0).map(Width::Twips).or_else(|| { + w.parse::() + .ok() + .filter(|&n| n > 0.0) + .map(|n| Width::Twips((n * PX) as i32)) + }), + }; + } + c.v_align = valign.and_then(|v| match v.trim() { + "top" => Some(VAlign::Top), + "middle" | "center" => Some(VAlign::Center), + "bottom" => Some(VAlign::Bottom), + _ => None, + }); + c +} + +/// Where a link goes: a web, mail, phone or FTP address (its target), or a +/// place in the page (its anchor). Nothing else: a script, a data address or a +/// path on the author's computer is not followed. +fn safe_link(href: &str) -> (Option, Option) { + let h = href.trim(); + if let Some(a) = h.strip_prefix('#') { + return (None, (!a.is_empty()).then(|| a.chars().take(40).collect())); + } + let lower = h.to_ascii_lowercase(); + if ["http://", "https://", "ftp://", "mailto:", "tel:"] + .iter() + .any(|p| lower.starts_with(p)) + && !h.chars().any(|c| c.is_control()) + { + return (Some(h.chars().take(2000).collect()), None); + } + (None, None) +} + +/// The page size and margins of the first `@page` rule that gives a size +/// (Word writes one for each section): `size: 595.3pt 841.9pt; margin: 72pt`. +fn page_rule(css_text: &str) -> Option
{ + let mut rest = css_text; + while let Some(at) = rest.find("@page") { + let after = &rest[at + 5..]; + let open = after.find('{')?; + let close = after[open..].find('}').map(|c| open + c)?; + let d = css::declarations(&after[open + 1..close]); + rest = &after[close..]; + let Some((_, size)) = d.iter().find(|(k, _)| k == "size") else { + continue; + }; + let nums: Vec = size + .split_whitespace() + .filter_map(twips) + .filter(|&n| n > 0) + .collect(); + let mut s = Section::default(); + let landscape = size.contains("landscape"); + match nums.as_slice() { + [w, h] => { + s.width = *w; + s.height = *h; + } + [w] => { + s.width = *w; + s.height = *w; + } + _ => continue, + } + if landscape && s.width < s.height { + std::mem::swap(&mut s.width, &mut s.height); + } + s.landscape = s.width > s.height; + if let Some((_, m)) = d.iter().find(|(k, _)| k == "margin") { + let v: Vec = m.split_whitespace().filter_map(twips).collect(); + let (t, r, b, l) = match v.as_slice() { + [a] => (*a, *a, *a, *a), + [a, b] => (*a, *b, *a, *b), + [a, b, c] => (*a, *b, *c, *b), + [a, b, c, d] => (*a, *b, *c, *d), + _ => (1440, 1440, 1440, 1440), + }; + s.top = t.max(0); + s.right = r.max(0); + s.bottom = b.max(0); + s.left = l.max(0); + } + // A page too small to hold its margins keeps the defaults'. + if s.width - s.left - s.right < 720 || s.height - s.top - s.bottom < 720 { + continue; + } + return Some(s); + } + None +} diff --git a/crates/exav-render/src/formats/office/docs/limits.rs b/crates/exav-render/src/formats/office/docs/limits.rs new file mode 100644 index 00000000..cd3c390c --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/limits.rs @@ -0,0 +1,139 @@ +use crate::formats::office::docs::error::{Error, Result}; +use crate::formats::office::docs::model::{Block, Cell, Inline, inline_bytes}; + +/// Bounds on the work one conversion may do. A document past one of them is +/// refused with [`Error::TooLarge`] rather than partly converted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Limits { + /// Bytes of input. + pub max_input: u64, + /// Memory the model and the output may take, as this module counts it: + /// an estimate per paragraph and cell, the bytes of text, each picture + /// and decompressed part, and the DOCX written so far. + pub max_memory: u64, + /// Bytes of one decompressed part of a package or stream. + pub max_part: u64, + /// Paragraphs over the body, notes, headers and footers. + pub max_paragraphs: u64, + /// Table cells. + pub max_cells: u64, +} + +impl Default for Limits { + fn default() -> Self { + Limits { + max_input: 512 << 20, + max_memory: 1 << 30, + max_part: 256 << 20, + max_paragraphs: 5_000_000, + max_cells: 2_000_000, + } + } +} + +impl Limits { + /// Defaults with another memory bound. + pub fn with_max_memory(mut self, bytes: u64) -> Self { + self.max_memory = bytes; + self + } + + /// Defaults with another paragraph bound. + pub fn with_max_paragraphs(mut self, n: u64) -> Self { + self.max_paragraphs = n; + self + } + + /// Defaults with another cell bound. + pub fn with_max_cells(mut self, n: u64) -> Self { + self.max_cells = n; + self + } +} + +/// What a conversion has used of its [`Limits`]. +pub(crate) struct Budget { + pub limits: Limits, + paragraphs: u64, + cells: u64, + memory: u64, +} + +/// What one paragraph costs in the model, beyond its text: its slot among the +/// blocks and the first allocation of its runs (four). +pub(crate) const PARA_BYTES: u64 = (size_of::() + 4 * size_of::()) as u64; +/// What one table cell costs in the model, beyond its content: the cell, and +/// the room its block list has for more than the first paragraph. +pub(crate) const CELL_BYTES: u64 = (size_of::() + 3 * size_of::()) as u64; + +impl Budget { + pub fn new(limits: Limits) -> Self { + Budget { + limits, + paragraphs: 0, + cells: 0, + memory: 0, + } + } + + /// One more paragraph, and `extra` bytes of its own text. + pub fn paragraph(&mut self, extra: usize) -> Result<()> { + self.paragraphs += 1; + if self.paragraphs > self.limits.max_paragraphs { + return Err(Error::TooLarge("paragraphs")); + } + self.alloc(PARA_BYTES + extra as u64) + } + + /// One more table cell. + pub fn cell(&mut self) -> Result<()> { + self.cells += 1; + if self.cells > self.limits.max_cells { + return Err(Error::TooLarge("table cells")); + } + self.alloc(CELL_BYTES) + } + + /// `bytes` more held in memory. + pub fn alloc(&mut self, bytes: u64) -> Result<()> { + self.memory = self.memory.saturating_add(bytes); + if self.memory > self.limits.max_memory { + return Err(Error::TooLarge("memory")); + } + Ok(()) + } + + /// The inlines of a paragraph just built, by what they take. + pub fn inlines(&mut self, inlines: &[Inline]) -> Result<()> { + self.alloc(inline_bytes(inlines) as u64) + } + + /// What `xml::parse` charges the memory of a tree to. + pub fn tree(&mut self) -> impl FnMut(u64) -> bool + '_ { + |bytes| self.alloc(bytes).is_ok() + } + + pub fn used(&self) -> u64 { + self.memory + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paragraphs_cells_and_memory_are_bounded() { + let mut b = Budget::new(Limits::default().with_max_paragraphs(2)); + assert!(b.paragraph(0).is_ok()); + assert!(b.paragraph(0).is_ok()); + assert_eq!(b.paragraph(0), Err(Error::TooLarge("paragraphs"))); + let mut b = Budget::new(Limits::default().with_max_cells(1)); + assert!(b.cell().is_ok()); + assert_eq!(b.cell(), Err(Error::TooLarge("table cells"))); + let mut b = Budget::new(Limits::default().with_max_memory(1000)); + assert!(b.alloc(999).is_ok()); + assert_eq!(b.alloc(2), Err(Error::TooLarge("memory"))); + } +} diff --git a/crates/exav-render/src/formats/office/docs/mod.rs b/crates/exav-render/src/formats/office/docs/mod.rs new file mode 100644 index 00000000..857062a3 --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/mod.rs @@ -0,0 +1,138 @@ +//! Word-processing documents to DOCX: Word 6 to 2003 (DOC), OpenDocument +//! text (ODT, OTT, FODT), RTF, Word 2003 XML and Apple Pages, converted for a +//! DOCX renderer to draw ([`convert`]), or read for their text ([`text`]). + +mod doc; +mod docx; +mod error; +mod fields; +mod grid; +mod html; +mod limits; +mod model; +mod odt; +mod pages; +mod rtf; +mod text; +mod wml; + +pub use error::Error; +pub use limits::Limits; + +/// The formats this module converts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Format { + /// Word 6 to 2003 documents and templates (.doc, .dot). + Doc, + /// OpenDocument text and templates (.odt, .ott). + Odt, + /// Flat OpenDocument text (.fodt). + Fodt, + /// Rich Text Format (.rtf). + Rtf, + /// Word 2003 XML (WordprocessingML 2003, .xml). + Wml2003, + /// Apple Pages documents (.pages). + Pages, + /// HTML pages: Word's "Save as Web Page" (a `.doc` that is HTML). + Html, +} + +impl Format { + /// The format of a file extension (without its dot, any case). + pub fn from_extension(ext: &str) -> Option { + Some(match ext.to_ascii_lowercase().as_str() { + "doc" | "dot" => Format::Doc, + "odt" | "ott" => Format::Odt, + "fodt" => Format::Fodt, + "rtf" => Format::Rtf, + "xml" | "wml" | "wml2003" => Format::Wml2003, + "pages" => Format::Pages, + "html" | "htm" => Format::Html, + _ => return None, + }) + } + + pub fn name(self) -> &'static str { + match self { + Format::Doc => "doc", + Format::Odt => "odt", + Format::Fodt => "fodt", + Format::Rtf => "rtf", + Format::Wml2003 => "wml2003", + Format::Pages => "pages", + Format::Html => "html", + } + } +} + +/// A converted document. +#[derive(Debug)] +pub struct Converted { + /// The DOCX package. + pub docx: Vec, + /// Paragraphs in the body. + pub paragraphs: usize, + /// What was left out or could not be read, for diagnostics. + pub warnings: Vec, +} + +/// Converts `input`, a document of `format`, to DOCX. +pub fn convert(input: &[u8], format: Format, limits: &Limits) -> Result { + let (doc, budget) = read(input, format, limits)?; + finish(doc, &budget) +} + +/// The text of `input`, a document of `format`, cut at `cap` bytes: what a +/// reader sees, taken from the model the converter reads, with the same +/// limits and refusals as [`convert`]. See [`crate::formats::office::text`]. +pub fn text( + input: &[u8], + format: Format, + limits: &Limits, + cap: usize, +) -> Result { + let (doc, _) = read(input, format, limits)?; + let mut out = crate::formats::office::text::Out::new(cap); + text::lines(&doc, &mut out); + Ok(out.finish()) +} + +/// Reads `input` into the model, within `limits`. +fn read( + input: &[u8], + format: Format, + limits: &Limits, +) -> Result<(model::Document, limits::Budget), Error> { + if input.len() as u64 > limits.max_input { + return Err(Error::TooLarge("input")); + } + let mut budget = limits::Budget::new(*limits); + let doc = match format { + Format::Doc => doc::read(input, &mut budget)?, + Format::Odt => odt::read_odt( + crate::formats::office::common::package::Zip::open(input)?, + &mut budget, + )?, + Format::Fodt => odt::read_fodt(input, &mut budget)?, + Format::Rtf => rtf::read(input, &mut budget)?, + Format::Wml2003 => wml::read(input, &mut budget)?, + Format::Pages => pages::read( + crate::formats::office::common::package::Zip::open(input)?, + &mut budget, + )?, + Format::Html => html::read(input, &mut budget)?, + }; + Ok((doc, budget)) +} + +fn finish(doc: model::Document, budget: &limits::Budget) -> Result { + let left = budget.limits.max_memory.saturating_sub(budget.used()); + let docx = docx::write(&doc, left)?; + Ok(Converted { + docx, + paragraphs: doc.body.len(), + warnings: doc.warnings, + }) +} diff --git a/crates/exav-render/src/formats/office/docs/model.rs b/crates/exav-render/src/formats/office/docs/model.rs new file mode 100644 index 00000000..fab0543c --- /dev/null +++ b/crates/exav-render/src/formats/office/docs/model.rs @@ -0,0 +1,914 @@ +//! The document model every reader fills and the DOCX writer serializes. It +//! follows WordprocessingML closely: properties are optional, a style is +//! named by its id, lengths are in twips, font sizes in half points, drawing +//! sizes in EMU. + +pub type Emu = i64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Color { + Auto, + Rgb(u32), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Fonts { + pub ascii: Option, + pub hansi: Option, + pub east_asia: Option, + pub cs: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Border { + /// A ST_Border value: single, double, dotted, dashed, thick, ... + pub style: &'static str, + /// Eighths of a point. + pub size: u32, + /// Points between the border and the text. + pub space: u32, + pub color: Color, + pub shadow: bool, +} + +impl Border { + pub fn single(size: u32, color: Color) -> Border { + Border { + style: "single", + size, + space: 0, + color, + shadow: false, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Shading { + /// ST_Shd pattern: clear, solid, pct10, horzStripe, ... + pub pattern: &'static str, + pub fill: Option, + pub color: Option, +} + +impl Shading { + pub fn fill(c: Color) -> Shading { + Shading { + pattern: "clear", + fill: Some(c), + color: None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VertAlign { + Baseline, + Superscript, + Subscript, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RunProps { + pub style: Option, + pub fonts: Fonts, + /// Half points. + pub size: Option, + pub size_cs: Option, + pub bold: Option, + pub bold_cs: Option, + pub italic: Option, + pub italic_cs: Option, + pub caps: Option, + pub small_caps: Option, + pub strike: Option, + pub dstrike: Option, + pub vanish: Option, + pub outline: Option, + pub shadow: Option, + pub emboss: Option, + pub imprint: Option, + /// ST_Underline: single, double, words, dotted, ...; "none" turns it + /// off. + pub underline: Option<&'static str>, + pub underline_color: Option, + pub color: Option, + /// ST_HighlightColor: yellow, green, ...; "none" turns it off. + pub highlight: Option<&'static str>, + pub shading: Option, + pub vert_align: Option, + /// Twips added after each character. + pub spacing: Option, + /// Horizontal scale in percent. + pub scale: Option, + /// Half points raised (negative: lowered). + pub position: Option, + pub kerning: Option, + pub lang: Option, + pub lang_east_asia: Option, + pub lang_bidi: Option, + pub rtl: Option, + pub cs: Option, + pub border: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Jc { + Left, + Center, + Right, + Both, + Distribute, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineRule { + Auto, + Exact, + AtLeast, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TabKind { + Left, + Center, + Right, + Decimal, + Bar, + Clear, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TabLeader { + None, + Dot, + Hyphen, + Underscore, + MiddleDot, + Heavy, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Tab { + pub pos: i32, + pub kind: TabKind, + pub leader: TabLeader, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Borders { + pub top: Option, + pub left: Option, + pub bottom: Option, + pub right: Option, + pub between: Option, +} + +impl Borders { + pub fn is_empty(&self) -> bool { + self.top.is_none() + && self.left.is_none() + && self.bottom.is_none() + && self.right.is_none() + && self.between.is_none() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Frame { + pub width: Option, + pub height: Option, + pub height_exact: bool, + pub x: Option, + pub y: Option, + /// page, margin, text + pub h_anchor: Option<&'static str>, + pub v_anchor: Option<&'static str>, + pub x_align: Option<&'static str>, + pub y_align: Option<&'static str>, + pub wrap: Option<&'static str>, + pub drop_cap: Option<&'static str>, + pub lines: Option, + pub h_space: Option, + pub v_space: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParaProps { + pub style: Option, + pub keep_next: Option, + pub keep_lines: Option, + pub page_break_before: Option, + pub widow_control: Option, + pub num_id: Option, + pub num_level: Option, + pub borders: Borders, + pub shading: Option, + pub tabs: Vec, + pub suppress_line_numbers: Option, + pub space_before: Option, + pub space_after: Option, + pub before_auto: Option, + pub after_auto: Option, + pub line: Option, + pub line_rule: Option, + pub ind_left: Option, + pub ind_right: Option, + pub ind_first_line: Option, + pub ind_hanging: Option, + pub contextual_spacing: Option, + pub bidi: Option, + pub jc: Option, + pub outline_level: Option, + pub frame: Option, + pub snap_to_grid: Option, +} + +/// A table's borders: the edges and the inside lines. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TableBorders { + pub top: Option, + pub left: Option, + pub bottom: Option, + pub right: Option, + pub inside_h: Option, + pub inside_v: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Width { + Auto, + /// Twips. + Twips(i32), + /// Fiftieths of a percent. + Pct(i32), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Margins { + pub top: Option, + pub left: Option, + pub bottom: Option, + pub right: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TableProps { + pub style: Option, + pub width: Option, + pub jc: Option, + pub indent: Option, + pub borders: TableBorders, + pub shading: Option, + pub cell_margins: Margins, + pub fixed_layout: bool, + pub cell_spacing: Option, + pub bidi: bool, + /// Table positioned among the text (floating). + pub float: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RowProps { + /// Twips; `height_exact` makes it exact, else a minimum. + pub height: Option, + pub height_exact: bool, + pub header: bool, + pub cant_split: bool, + pub grid_before: u32, + pub grid_after: u32, + pub width_before: Option, + pub width_after: Option, + pub jc: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VMerge { + None, + Restart, + Continue, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VAlign { + Top, + Center, + Bottom, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CellProps { + pub width: Option, + pub grid_span: u32, + pub v_merge: VMerge, + pub borders: TableBorders, + pub shading: Option, + pub v_align: Option, + pub margins: Margins, + pub no_wrap: bool, + pub fit_text: bool, + /// lrTb, tbRl, btLr + pub text_direction: Option<&'static str>, +} + +impl Default for CellProps { + fn default() -> Self { + CellProps { + width: None, + grid_span: 1, + v_merge: VMerge::None, + borders: TableBorders::default(), + shading: None, + v_align: None, + margins: Margins::default(), + no_wrap: false, + fit_text: false, + text_direction: None, + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct Cell { + pub props: CellProps, + pub content: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct Row { + pub props: RowProps, + pub cells: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct Table { + pub props: TableProps, + /// Column widths in twips. + pub grid: Vec, + pub rows: Vec, +} + +/// Blocks are mostly paragraphs; boxing them would cost an allocation each. +#[derive(Clone, Debug)] +#[allow(clippy::large_enum_variant)] +pub enum Block { + Para(Para), + Table(Table), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BreakKind { + Line, + Page, + Column, +} + +#[derive(Clone, Debug)] +pub enum RunItem { + Text(String), + Tab, + Break(BreakKind), + NoBreakHyphen, + SoftHyphen, + Symbol { + font: String, + ch: u16, + }, + Drawing(Box), + FootnoteRef(u32), + EndnoteRef(u32), + /// The mark inside a footnote or endnote, repeating its number. + NoteMark(bool), + CommentRef(u32), + /// The page number field in its simple form: PAGE, NUMPAGES. + PageNumber, +} + +#[derive(Clone, Debug, Default)] +pub struct Run { + pub props: RunProps, + pub items: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldCharKind { + Begin, + Separate, + End, +} + +#[derive(Clone, Debug)] +pub enum Inline { + Run(Run), + /// A field mark whose partner is in another paragraph. + FieldChar(FieldCharKind), + InstrText(String), + /// A field with its stored result. + Field { + instr: String, + props: RunProps, + result: Vec, + }, + Hyperlink { + /// An external target, else `anchor`. + target: Option, + anchor: Option, + tooltip: Option, + content: Vec, + }, + BookmarkStart { + id: u32, + name: String, + }, + BookmarkEnd(u32), + CommentStart(u32), + CommentEnd(u32), +} + +#[derive(Clone, Debug, Default)] +pub struct Para { + pub props: ParaProps, + /// The paragraph mark's character properties. + pub mark: RunProps, + pub content: Vec, + /// The section this paragraph ends. + pub section: Option>, +} + +// ---- drawings ---- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Wrap { + None, + Square, + Tight, + TopBottom, + Through, +} + +#[derive(Clone, Debug)] +pub struct Anchor { + /// page, margin, column, character, paragraph, line, ... + pub h_rel: &'static str, + pub v_rel: &'static str, + /// Offsets in EMU, or an alignment (left, center, right, top, ...). + pub h_off: Emu, + pub v_off: Emu, + pub h_align: Option<&'static str>, + pub v_align: Option<&'static str>, + pub wrap: Wrap, + pub behind: bool, + pub z: u32, + pub dist: [Emu; 4], +} + +impl Default for Anchor { + fn default() -> Self { + Anchor { + h_rel: "column", + v_rel: "paragraph", + h_off: 0, + v_off: 0, + h_align: None, + v_align: None, + wrap: Wrap::Square, + behind: false, + z: 0, + dist: [0, 0, 0, 0], + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Crop { + /// Thousandths of a percent of the picture, each side. + pub l: i32, + pub t: i32, + pub r: i32, + pub b: i32, +} + +#[derive(Clone, Debug)] +pub struct Line { + pub color: Color, + /// EMU. + pub width: Emu, + pub dash: &'static str, +} + +#[derive(Clone, Debug)] +pub enum Fill { + None, + Solid(u32), +} + +#[derive(Clone, Debug)] +pub struct Shape { + /// DrawingML preset geometry name: rect, ellipse, line, ... + pub preset: &'static str, + pub fill: Fill, + pub line: Option, + /// Text inside the shape. + pub text: Vec, + pub rot: i32, + pub flip_h: bool, + pub flip_v: bool, + pub insets: [Emu; 4], + pub v_anchor: &'static str, +} + +impl Default for Shape { + fn default() -> Self { + Shape { + preset: "rect", + fill: Fill::None, + line: None, + text: vec![], + rot: 0, + flip_h: false, + flip_v: false, + insets: [91_440, 45_720, 91_440, 45_720], + v_anchor: "t", + } + } +} + +#[derive(Clone, Debug)] +pub enum DrawingKind { + /// An index into `Document::media`. + Picture { + media: usize, + crop: Crop, + }, + Shape(Shape), +} + +#[derive(Clone, Debug)] +pub struct Drawing { + /// `None`: inline in the text. + pub anchor: Option, + pub cx: Emu, + pub cy: Emu, + pub name: String, + pub descr: Option, + pub kind: DrawingKind, +} + +#[derive(Clone, Debug)] +pub struct Media { + pub ext: &'static str, + pub data: Vec, +} + +// ---- styles, numbering, sections ---- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StyleKind { + Paragraph, + Character, + Table, + Numbering, +} + +#[derive(Clone, Debug)] +pub struct Style { + pub id: String, + pub name: String, + pub kind: StyleKind, + pub based_on: Option, + pub next: Option, + pub link: Option, + pub default: bool, + pub q_format: bool, + pub ui_priority: Option, + pub hidden: bool, + pub ppr: ParaProps, + pub rpr: RunProps, + pub tbl: Option, +} + +impl Style { + pub fn new(id: &str, name: &str, kind: StyleKind) -> Style { + Style { + id: id.to_string(), + name: name.to_string(), + kind, + based_on: None, + next: None, + link: None, + default: false, + q_format: false, + ui_priority: None, + hidden: false, + ppr: ParaProps::default(), + rpr: RunProps::default(), + tbl: None, + } + } +} + +#[derive(Clone, Debug)] +pub struct Level { + pub start: i64, + /// ST_NumberFormat: decimal, bullet, lowerLetter, upperRoman, ... + pub fmt: &'static str, + /// The text with %1..%9 for the counters. + pub text: String, + pub jc: Jc, + /// tab, space, nothing + pub suff: &'static str, + pub style: Option, + pub ppr: ParaProps, + pub rpr: RunProps, + pub legal: bool, + /// The level whose counter restarts this one: Some(0) never restarts. + pub restart: Option, +} + +impl Default for Level { + fn default() -> Self { + Level { + start: 1, + fmt: "decimal", + text: String::new(), + jc: Jc::Left, + suff: "tab", + style: None, + ppr: ParaProps::default(), + rpr: RunProps::default(), + legal: false, + restart: None, + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct AbstractNum { + pub id: u32, + pub multi_level: bool, + pub levels: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct Num { + pub id: u32, + pub abstract_id: u32, + /// (level, start override, level definition override) + pub overrides: Vec<(u32, Option, Option)>, +} + +#[derive(Clone, Debug, Default)] +pub struct Numbering { + pub abstracts: Vec, + pub nums: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SectionType { + NextPage, + Continuous, + EvenPage, + OddPage, + NextColumn, +} + +#[derive(Clone, Debug, Default)] +pub struct Columns { + pub num: u32, + pub space: i32, + pub separator: bool, + /// (width, space after) per column when unequal. + pub widths: Vec<(i32, i32)>, +} + +#[derive(Clone, Debug, Default)] +pub struct HeaderFooter { + pub default: Option>, + pub first: Option>, + pub even: Option>, +} + +#[derive(Clone, Debug)] +pub struct Section { + pub kind: SectionType, + pub width: i32, + pub height: i32, + pub landscape: bool, + pub top: i32, + pub right: i32, + pub bottom: i32, + pub left: i32, + pub header: i32, + pub footer: i32, + pub gutter: i32, + pub columns: Columns, + pub title_page: bool, + pub page_start: Option, + pub page_fmt: Option<&'static str>, + pub v_align: Option<&'static str>, + pub rtl_gutter: bool, + pub bidi: bool, + pub page_borders: Borders, + pub headers: HeaderFooter, + pub footers: HeaderFooter, + pub text_direction: Option<&'static str>, + pub line_numbers: Option<(u32, u32, i32)>, +} + +impl Default for Section { + fn default() -> Self { + Section { + kind: SectionType::NextPage, + width: 12240, + height: 15840, + landscape: false, + top: 1440, + right: 1440, + bottom: 1440, + left: 1440, + header: 720, + footer: 720, + gutter: 0, + columns: Columns::default(), + title_page: false, + page_start: None, + page_fmt: None, + v_align: None, + rtl_gutter: false, + bidi: false, + page_borders: Borders::default(), + headers: HeaderFooter::default(), + footers: HeaderFooter::default(), + text_direction: None, + line_numbers: None, + } + } +} + +#[derive(Clone, Debug)] +pub struct Note { + pub id: u32, + pub content: Vec, +} + +#[derive(Clone, Debug)] +pub struct Comment { + pub id: u32, + pub author: String, + pub initials: String, + pub date: Option, + pub content: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct Settings { + pub default_tab_stop: Option, + pub even_and_odd_headers: bool, + pub footnote_pos: Option<&'static str>, + pub footnote_fmt: Option<&'static str>, + pub footnote_start: Option, + pub footnote_restart: Option<&'static str>, + pub endnote_fmt: Option<&'static str>, + pub endnote_start: Option, + pub endnote_restart: Option<&'static str>, + pub mirror_margins: bool, + pub gutter_at_top: bool, +} + +#[derive(Debug, Default)] +pub struct Document { + pub defaults_run: RunProps, + pub defaults_para: ParaProps, + pub styles: Vec + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemAmountoverflowing text
Rent$1,234.5010/1/20090042
Total1234.5TRUE#DIV/0!
1,000a linkline
two
+ + +"##; + +#[test] +fn excel_values() { + let wb = read_str(EXCEL); + let s = &wb.sheets[0]; + assert_eq!(s.name, "Budget & Plan"); + assert_eq!(value(s, 0, 0), &Value::text("Item")); + assert_eq!(value(s, 0, 1), &Value::text("Amount")); + // After a two-column cell, the next is in the fourth column. + assert_eq!(value(s, 0, 3), &Value::text("overflowing text")); + assert_eq!(value(s, 1, 1), &Value::Number(1234.5)); + assert_eq!(value(s, 1, 2), &Value::Number(40087.0)); + // x:str on the table: text, here a number's digits kept. + assert_eq!(value(s, 1, 3), &Value::text("0042")); + assert_eq!(value(s, 2, 2), &Value::Bool(true)); + assert_eq!(value(s, 2, 3), &Value::Error(CellError::Div0)); + // The row span pushes the next row's cells right. + assert_eq!(value(s, 3, 1), &Value::Number(1000.0)); + assert_eq!(value(s, 3, 2), &Value::text("a link")); + assert_eq!(value(s, 3, 3), &Value::text("line\ntwo")); + let f = |r, c| s.cells[&(r, c)].formula.as_deref().map(str::to_string); + assert_eq!(f(2, 1).as_deref(), Some("SUM(B2:B2)")); + assert_eq!(f(2, 3).as_deref(), Some("1/0")); + assert_eq!(s.hyperlinks[0].url.as_deref(), Some("https://example.com/")); +} + +#[test] +fn excel_merges_sizes_options() { + let wb = read_str(EXCEL); + let s = &wb.sheets[0]; + // A column span merges; one Excel marks mso-ignore:colspan does not. + assert_eq!( + s.merges, + [ + Range { + r1: 0, + c1: 1, + r2: 0, + c2: 2 + }, + Range { + r1: 2, + c1: 0, + r2: 3, + c2: 0 + } + ] + ); + // 98 pixels: 13.29 characters of 7 pixels after 5 of padding, stored + // with the padding. + assert_eq!(s.col_info(0).and_then(|c| c.width), Some(3585.0 / 256.0)); + assert_eq!(s.col_info(2).and_then(|c| c.width), Some(9.140625)); + assert!(s.col_info(3).unwrap().hidden); + assert_eq!(s.rows[&0].height, Some(25.5)); + assert!(s.rows[&3].hidden); + assert_eq!(s.freeze, Some((1, 0))); + assert!(!s.show_grid); +} + +#[test] +fn excel_styles() { + let wb = read_str(EXCEL); + let s = &wb.sheets[0]; + assert_eq!( + ( + wb.styles.get(0).font.name.as_str(), + wb.styles.get(0).font.size_centipt + ), + ("Arial", 1000) + ); + let head = style(&wb, s, 0, 0); + assert!(head.font.bold); + assert_eq!(head.align.horizontal, Some("center")); + assert_eq!(head.fill, Fill::solid(0xFFFF00)); + assert_eq!(head.border.bottom.style, Some("thin")); + assert_eq!(head.border.top.style, None); + // CSS escapes resolved: Excel's format code. + assert_eq!(style(&wb, s, 1, 1).numfmt, "\"$\"#,##0.00"); + assert_eq!(style(&wb, s, 1, 2).numfmt, "m/d/yyyy"); + assert_eq!(style(&wb, s, 1, 3).numfmt, "@"); + assert!(style(&wb, s, 1, 3).align.wrap); + assert!(style(&wb, s, 3, 3).align.wrap); +} + +/// What later Excel versions write (no `x:num`, numbers in the text with +/// the separators the table names), and Excel's other habits. +#[test] +fn excel_details() { + let page = r##" + + + + + + + + + +
Aug-011.234,512,5%
 
phantom
+

a comment

+"##; + let wb = read_str(page); + let s = &wb.sheets[0]; + assert!(wb.date1904); + assert_eq!(value(s, 0, 0), &Value::text("Aug-01")); + assert_eq!(value(s, 0, 1), &Value::Number(1234.5)); + assert_eq!(value(s, 0, 2), &Value::Number(0.125)); + // One row standing for two empty ones, their height shared. + assert_eq!(s.rows[&1].height, Some(14.0)); + assert_eq!(s.rows[&2].height, Some(14.0)); + // An unmerged run of formatted cells: each formatted, none merged. + assert!(s.merges.is_empty()); + for c in 0..3 { + assert_eq!(style(&wb, s, 3, c).fill, Fill::solid(0xDEEBF7)); + assert_eq!(style(&wb, s, 3, c).border.left.style, Some("thin")); + } + // Excel's own width unit, the phantom row and the comments left out. + assert_eq!(s.col_info(0).and_then(|c| c.width), Some(14.0)); + assert_eq!(s.cells.keys().map(|k| k.0).max(), Some(3)); + assert!( + !s.cells + .values() + .any(|c| c.value == Value::text("a comment")) + ); +} + +#[test] +fn several_tables_and_plain_html() { + let page = "Report +

Heading

+ +
NameScore
Ann12%
Bob(1,234.50)
+

between

+ +
bold and italiccafé & téa !
innercells
+ +"; + let wb = read_str(page); + let s = &wb.sheets[0]; + assert_eq!(s.name, "Sheet1"); + // Text around tables, a row each in the first column. + assert_eq!(value(s, 0, 0), &Value::text("Heading")); + assert!(style(&wb, s, 0, 0).font.bold); + assert_eq!(value(s, 1, 0), &Value::text("Name")); + let th = style(&wb, s, 1, 0); + assert!(th.font.bold && th.align.horizontal == Some("center")); + assert_eq!(value(s, 2, 1), &Value::Number(0.12)); + assert_eq!(style(&wb, s, 2, 1).numfmt, "0%"); + assert_eq!(style(&wb, s, 2, 1).align.horizontal, Some("right")); + assert_eq!(value(s, 3, 1), &Value::Number(-1234.5)); + assert_eq!(style(&wb, s, 3, 1).numfmt, "#,##0.00"); + assert_eq!(style(&wb, s, 3, 0).fill, Fill::solid(0xFF0000)); + assert_eq!(value(s, 4, 0), &Value::text("between")); + let Value::Rich(runs) = value(s, 5, 0) else { + panic!("{:?}", value(s, 5, 0)) + }; + let parts: Vec<(&str, bool, bool)> = runs + .iter() + .map(|r| { + ( + r.text.as_str(), + r.font.as_ref().unwrap().bold, + r.font.as_ref().unwrap().italic, + ) + }) + .collect(); + assert_eq!( + parts, + [ + ("bold", true, false), + (" and ", false, false), + ("italic", false, true) + ] + ); + assert_eq!(value(s, 5, 1), &Value::text("café & téa !")); + // A table in a cell: its text. + assert_eq!(value(s, 6, 0), &Value::text("inner cells")); + // Nothing from the script. + assert!(!s.cells.values().any(|c| c.value == Value::text("no"))); + assert_eq!(wb.styles.get(0).font.name, "Times New Roman"); +} + +#[test] +fn captions_rules_and_breaks() { + // No style sheet: a line break starts a cell a row down. + let page = "
Facts
one
two
x
y
"; + let wb = read_str(page); + let s = &wb.sheets[0]; + assert_eq!(value(s, 0, 0), &Value::text("Facts")); + assert_eq!(value(s, 1, 0), &Value::text("one")); + assert_eq!(value(s, 2, 0), &Value::text("two")); + assert_eq!(value(s, 2, 1), &Value::text("y")); + assert_eq!(style(&wb, s, 1, 1).border.left.style, Some("thin")); + // Excel's style sheet keeps it in the cell. + let wb = read_str( + "
one
two
", + ); + assert_eq!(value(&wb.sheets[0], 0, 0), &Value::text("one\ntwo")); +} + +#[test] +fn encodings() { + let page = b"
\xcf\xf0\xe8\xe2\xe5\xf2
"; + let wb = read(page, &mut Budget::new(Limits::default())).unwrap(); + assert_eq!(value(&wb.sheets[0], 0, 0), &Value::text("Привет")); + let utf16: Vec = [0xFF, 0xFE] + .into_iter() + .chain( + "
é
" + .encode_utf16() + .flat_map(u16::to_le_bytes), + ) + .collect(); + let wb = read(&utf16, &mut Budget::new(Limits::default())).unwrap(); + assert_eq!(value(&wb.sheets[0], 0, 0), &Value::text("é")); +} + +#[test] +fn refusals_and_limits() { + let b = || Budget::new(Limits::default()); + assert!(matches!( + read(b"just text, a < b", &mut b()), + Err(Error::NotFormat(_)) + )); + assert!(matches!( + read(b"

no table

", &mut b()), + Err(Error::NotFormat(_)) + )); + // A workbook's frameset: its sheets are other files, not read. + let frames = b""; + assert!(matches!(read(frames, &mut b()), Err(Error::Unsupported(_)))); + let few = Limits::default().with_max_cells(5); + assert_eq!( + read(EXCEL.as_bytes(), &mut Budget::new(few)).unwrap_err(), + Error::TooLarge("cells") + ); +} + +#[test] +fn hostile_pages() { + let b = EXCEL.as_bytes(); + for cut in (0..b.len()).step_by(37) { + let _ = read(&b[..cut], &mut Budget::new(Limits::default())); + } + let deep = "", + "
".repeat(300); + assert!(read(deep.as_bytes(), &mut Budget::new(Limits::default())).is_err()); + for page in [ + "
x
", + "
���&bogus;&
", + "
1e9991,2,3
", + "
x
x", + "vbscript:msgbox(1)", + "file:///etc/passwd", + "ftp://example.com/x", + "blob:https://example.com/1", + "about:blank", + "/relative/path", + "../up", + "page.html", + "#anchor", + "//evil.example/x", + "https://user:pass@example.com/", + "https://google.com@evil.example/", + "https://google.com\\@evil.example/", + "https://", + "https:///x", + "http://?x", + "mailto:", + "tel:+123", + "https://exa\u{202e}mple.com/", + "https://example.com/a\u{2028}b", + "https://example.com/a\u{85}b", + "https://example.com/a b", + "https://example.com/\u{7f}", + &long, + ]; + for target in refused { + // In angle brackets, which allow spaces, and bare, for a target with none. + let inside = target.replace(['<', '>', '\n'], " "); + let mut forms = vec![format!("[click me](<{inside}>)")]; + if !target + .contains(|c: char| c.is_whitespace() || c == '(' || c == ')' || c == '<' || c == '>') + { + forms.push(format!("[click me]({target})")); + } + for md in forms { + let d = doc(&md); + assert!(links(&d).is_empty(), "{md:?} -> {:?}", d.blocks); + assert!(paras(&d.blocks).concat().contains("click me"), "{md:?}"); + } + } +} + +#[test] +fn a_target_of_the_longest_size_is_kept_and_one_byte_more_is_not() { + let base = "https://example.com/"; + let fits = format!("{base}{}", "a".repeat(MAX_TARGET - base.len())); + assert_eq!(one(&format!("[x](<{fits}>)")).len(), 1); + assert!(one(&format!("[x](<{fits}a>)")).is_empty()); +} + +#[test] +fn an_image_is_its_alt_text_and_loads_nothing() { + let d = doc( + "![a diagram](https://example.com/d.png) ![](https://example.com/e.png) ![x *y* `z`](data:image/png;base64,AAAA)", + ); + let alts: Vec<_> = all_inlines(&d) + .into_iter() + .filter_map(|n| { + if let Inline::Image { alt } = n { + Some(alt) + } else { + None + } + }) + .collect(); + assert_eq!(alts, ["a diagram", "", "x y z"]); + assert!(links(&d).is_empty()); + let json = d.to_json(); + assert!( + !json.contains("example.com") && !json.contains("base64"), + "{json}" + ); +} + +#[test] +fn an_image_inside_a_link_keeps_the_link() { + let d = doc("[![logo](https://example.com/l.png)](https://example.com/home)"); + assert_eq!(links(&d), [pair("https://example.com/home", "logo")]); +} + +// ── raw HTML ───────────────────────────────────────────────────────────────── + +const HOSTILE_HTML: &[&str] = &[ + "", + "", + "", + "", + "x", + "", + "
", + "
", + "bold", + "", + "", + "", + "", + "

", +]; + +#[test] +fn raw_html_is_text_exactly_as_written() { + for html in HOSTILE_HTML { + let d = doc(html); + // A block of HTML, or a paragraph that has tags in it: the characters are all there is. + let shown = match d.blocks.as_slice() { + [Block::Raw(s)] => s.clone(), + [Block::Para(c)] => text(c), + other => panic!("{html}: {other:?}"), + }; + assert_eq!(shown, *html); + assert!( + all_inlines(&d).iter().all(|n| matches!(n, Inline::Text(_))), + "{html}" + ); + } +} + +#[test] +fn raw_html_is_text_inside_every_construct() { + let html = ""; + let wrapped = [ + format!("# {html}"), + format!("> {html}"), + format!("- {html}"), + format!("1. {html}"), + format!("*{html}*"), + format!("**{html}**"), + format!("[{html}](https://example.com)"), + format!("![{html}](https://example.com/i.png)"), + format!("| {html} |\n|---|\n| {html} |"), + format!("`{html}`"), + format!("```\n{html}\n```"), + format!(" {html}"), + format!("a {html} b"), + ]; + for md in wrapped { + let d = doc(&md); + // Whatever the shape, the tag's characters are somewhere as text, + // and nothing but the node kinds of this tree exists. + let json = d.to_json(); + assert!( + json.contains("") || json.contains(" { + if let Some(t) = m.get("t") { + assert!(KINDS.contains(&t.as_str().unwrap()), "{t}"); + assert!(m.keys().all(|k| KEYS.contains(&k.as_str())), "{m:?}"); + } + m.values().for_each(assert_kinds); + } + serde_json::Value::Array(a) => a.iter().for_each(assert_kinds), + _ => {} + } +} + +// ── the JSON the page reads ────────────────────────────────────────────────── + +#[test] +fn the_json_is_valid_and_has_the_shape_the_page_reads() { + let d = doc( + "# H\n\ntext **b** [l](https://example.com/\"q\")\n\n- a\n- b\n\n| x | y |\n|:-|-:|\n| 1 | 2 |\n\n> q\n\n```\ncode\n```\n\n---\n\n
raw
\n", + ); + let v: serde_json::Value = serde_json::from_str(&d.to_json()).unwrap(); + assert_eq!(v["truncated"], false); + let kinds: Vec<_> = v["blocks"] + .as_array() + .unwrap() + .iter() + .map(|b| b["t"].as_str().unwrap()) + .collect(); + assert_eq!( + kinds, + ["h", "p", "list", "table", "quote", "code", "hr", "raw"] + ); + assert_eq!( + v["blocks"][0], + serde_json::json!({"t": "h", "level": 1, "c": [{"t": "text", "s": "H"}]}) + ); + assert_eq!(v["blocks"][2]["tight"], true); + assert_eq!(v["blocks"][2]["start"], 1); + assert_eq!( + v["blocks"][3]["align"], + serde_json::json!(["left", "right"]) + ); + assert_eq!(v["blocks"][3]["head"][0][0]["s"], "x"); + // The quotes in the target are the link's own, not a break out of the string. + assert_eq!(v["blocks"][1]["c"][3]["url"], "https://example.com/\"q\""); + assert_eq!(v["blocks"][7]["s"], "
raw
"); + assert_kinds(&v); +} + +#[test] +fn strings_survive_the_json() { + let nasty = + "quote \" slash \\ tab \t nul \u{0} bell \u{7} sep \u{2028} emoji \u{1F600} close ]}"; + let d = doc(&format!("```\n{nasty}\n```")); + let v: serde_json::Value = serde_json::from_str(&d.to_json()).unwrap(); + assert_eq!(v["blocks"][0]["s"], nasty); +} + +// ── limits ─────────────────────────────────────────────────────────────────── + +fn depth(blocks: &[Block]) -> usize { + blocks + .iter() + .map(|b| match b { + Block::Quote(c) => 1 + depth(c), + Block::List { items, .. } => 1 + items.iter().map(|i| depth(i)).max().unwrap_or(0), + _ => 0, + }) + .max() + .unwrap_or(0) +} + +fn inline_depth(c: &[Inline]) -> usize { + c.iter() + .map(|n| match n { + Inline::Em(c) + | Inline::Strong(c) + | Inline::Del(c) + | Inline::Link { children: c, .. } => 1 + inline_depth(c), + _ => 0, + }) + .max() + .unwrap_or(0) +} + +fn raws(blocks: &[Block], out: &mut String) { + for b in blocks { + match b { + Block::Raw(s) => out.push_str(s), + Block::Quote(c) => raws(c, out), + Block::List { items, .. } => items.iter().for_each(|i| raws(i, out)), + _ => {} + } + } +} + +#[test] +fn quotes_stop_nesting_at_the_limit_and_the_rest_is_text() { + let d = doc(&format!("{}deep text", "> ".repeat(50))); + assert_eq!(depth(&d.blocks), MAX_BLOCK_DEPTH); + let mut raw = String::new(); + raws(&d.blocks, &mut raw); + assert!(raw.contains("deep text"), "{:?}", d.blocks); +} + +#[test] +fn lists_stop_nesting_at_the_limit_and_the_rest_is_text() { + let md: Vec = (0..50) + .map(|i| format!("{}- level{i}", " ".repeat(i))) + .collect(); + let d = doc(&md.join("\n")); + assert_eq!(depth(&d.blocks), MAX_BLOCK_DEPTH); + let mut raw = String::new(); + raws(&d.blocks, &mut raw); + assert!(raw.contains("level49"), "{raw:?}"); + assert!( + raw.contains("level10") && !raw.contains("level9"), + "{raw:?}" + ); +} + +#[test] +fn quotes_and_lists_count_toward_one_limit() { + let d = doc(&format!("{}x", "> - ".repeat(30))); + assert_eq!(depth(&d.blocks), MAX_BLOCK_DEPTH); +} + +#[test] +fn inlines_stop_nesting_at_the_limit_and_keep_their_text() { + let d = doc(&format!("{}x{}", "*a **b ".repeat(60), "** b* ".repeat(60))); + let Block::Para(c) = &d.blocks[0] else { + panic!("{:?}", d.blocks) + }; + assert!(inline_depth(c) <= MAX_INLINE_DEPTH, "{}", inline_depth(c)); + assert!(text(c).contains('x')); + // And links nested in emphasis nested in links. + let d = doc(&format!( + "{}x{}", + "*[a ".repeat(100), + "](https://example.com)*".repeat(100) + )); + let Block::Para(c) = &d.blocks[0] else { + panic!("{:?}", d.blocks) + }; + assert!(inline_depth(c) <= MAX_INLINE_DEPTH); +} + +#[test] +fn a_document_stops_at_the_node_limit_and_says_so() { + let d = doc(&"x *y*\n\n".repeat(150_000)); + assert!(d.truncated); + assert!(d.blocks.len() < 150_000 && d.blocks.len() > 1000); + assert!(d.to_json().ends_with("\"truncated\":true}")); + assert!(!doc("# small").truncated); +} + +#[test] +fn what_was_open_at_the_node_limit_is_kept() { + let d = doc(&"- a *b*\n".repeat(200_000)); + assert!(d.truncated); + let Block::List { items, .. } = &d.blocks[0] else { + panic!("{} blocks", d.blocks.len()) + }; + assert!( + items.len() > 1000 && items.len() < 200_000, + "{}", + items.len() + ); +} + +#[test] +fn the_node_limit_counts_table_cells_and_the_padding_of_short_rows() { + let head = format!( + "|{}\n|{}\n", + "a|".repeat(MAX_COLUMNS), + "-|".repeat(MAX_COLUMNS) + ); + let d = doc(&format!("{head}{}", "|1|\n".repeat(20_000))); + assert!(d.truncated); + let Block::Table { rows, .. } = &d.blocks[0] else { + panic!("{:?}", d.blocks.len()) + }; + assert!( + rows.len() * MAX_COLUMNS <= MAX_NODES + MAX_COLUMNS, + "{}", + rows.len() + ); +} + +#[test] +fn a_table_keeps_at_most_the_column_limit() { + let n = MAX_COLUMNS + 40; + let md = format!( + "|{}\n|{}\n|{}\n", + "a|".repeat(n), + "-|".repeat(n), + "b|".repeat(n) + ); + let d = doc(&md); + let Block::Table { align, head, rows } = &d.blocks[0] else { + panic!("{:?}", d.blocks) + }; + assert_eq!( + (align.len(), head.len(), rows[0].len()), + (MAX_COLUMNS, MAX_COLUMNS, MAX_COLUMNS) + ); +} + +#[test] +fn a_list_start_is_nine_digits_at_most() { + let d = doc("999999999. a\n\n1234567890. b"); + assert!(matches!( + d.blocks[0], + Block::List { + start: 999_999_999, + .. + } + )); + assert!( + d.blocks + .iter() + .all(|b| !matches!(b, Block::List { start, .. } if *start > 999_999_999)) + ); +} + +fn rep(n: usize, s: &str) -> String { + s.repeat(n) +} + +fn hostile() -> Vec<(&'static str, String)> { + let lines = + |n: usize, f: &dyn Fn(usize) -> String| (0..n).map(f).collect::>().join("\n"); + vec![ + ("100k stars", rep(100_000, "*")), + ("100k stars and letters", rep(50_000, "*a ")), + ("100k underscores", rep(100_000, "_")), + ("closers only", rep(40, &format!("{}\n", rep(3000, "a* ")))), + ( + "closers that fail the rule of three", + rep(40, &format!("{}\n", rep(1500, "a** b* "))), + ), + ( + "underscore closers", + rep(40, &format!("{}\n", rep(3000, "a_ "))), + ), + ("alternating emphasis", rep(50_000, "*a_b")), + ("100k open brackets", rep(100_000, "[")), + ("100k link openers", rep(100_000, "[](")), + ( + "link openers filling the limit", + rep(200, &format!("{}\n", rep(3333, "[]("))), + ), + ("100k image openers", rep(100_000, "![")), + ( + "brackets then parens", + rep(50_000, "[a]") + &rep(50_000, "("), + ), + ("nested links", rep(20_000, "[a](") + &rep(20_000, ")")), + ("100k backticks", rep(100_000, "`")), + ( + "ascending backtick runs", + (0..1500) + .map(|i| "`".repeat(i + 1)) + .collect::>() + .join(" "), + ), + ("100k angle brackets", rep(100_000, "<")), + ("autolink openers", rep(30_000, "")), + ("10k nested quotes", rep(10_000, "> ") + "deep"), + ( + "quote staircase", + lines(2000, &|i| format!("{}x", "> ".repeat(i % 40))), + ), + ("nested list on one line", rep(10_000, "- ") + "deep"), + ( + "list staircase", + lines(3000, &|i| format!("{}- item", " ".repeat(2 * (i % 60)))), + ), + ( + "ordered list staircase", + lines(3000, &|i| format!("{}1. item", " ".repeat(3 * (i % 60)))), + ), + ("20k tiny items", rep(20_000, "- a\n")), + ("20k blank-separated items", rep(20_000, "- a\n\n")), + ("50k headings", rep(50_000, "# h\n")), + ("50k hr", rep(50_000, "---\n")), + ( + "wide table", + format!( + "|{}\n|{}\n{}", + rep(5000, "a|"), + rep(5000, "-|"), + rep(2000, &format!("|{}\n", rep(100, "x|"))) + ), + ), + ( + "long table", + format!("|a|b|\n|-|-|\n{}", rep(50_000, "|1|2|\n")), + ), + ("fence storms", rep(30_000, "```\n")), + ("setext storms", rep(30_000, "a\n===\n")), + ("html storm", rep(30_000, "
+
+

Quarterly summary

+

Revenue grew by twelve percent in May & June.

+

Centered +and underlined

+

a web link, a script link, +a file link, a place and an anchor.

+
  • first
  • second
    1. nested one
+ +
Headwide
tallbc
de
+

not shown

+
line one
+  line two
+
"##; + +#[test] +fn a_word_page_is_read() { + let docx = run(WORD); + let d = body(&docx); + for shown in [ + "Quarterly", + "twelve percent", + "May", + "& June", + "a web link", + "nested one", + ] { + assert!(d.contains(shown), "{shown}: {d}"); + } + for hidden in [ + "not shown", + "script text", + "title is not text", + "WordDocument", + ] { + assert!(!d.contains(hidden), "{hidden}"); + } + // The bold run is the bold words; the page's style gave the colour. + let at = d.find("twelve percent").unwrap(); + assert!( + d[..at] + .rfind("") + .map(|s| d[s..at].contains("")) + .unwrap_or(false), + "{d}" + ); + let at = d.find(">May<").unwrap(); + assert!( + d[..at] + .rfind("") + .map(|s| d[s..at].contains("w:val=\"FF0000\"")) + .unwrap_or(false), + "{d}" + ); + // The heading, the centred paragraph, the page of the style sheet. + assert!(d.contains(""), "{d}"); + assert!(d.contains(""), "{d}"); + assert!(d.contains(""), "{d}"); + assert!( + d.contains("w:left=\"1080\"") && d.contains("w:top=\"1440\""), + "{d}" + ); + // A table with its spans, and a line break of `pre` that stays. + assert!( + d.contains("") + && d.contains("w:gridSpan w:val=\"2\"") + && d.contains(""), + "{d}" + ); + assert!( + d.matches("").count() + d.matches("").count() >= 1, + "{d}" + ); + assert!(d.contains("") && d.contains(" line two"), "{d}"); + // The list marks, in a paragraph of the item's own. + let t = text(WORD.as_bytes(), Format::Html, &Limits::default(), 1 << 20) + .unwrap() + .text; + assert!( + t.contains("\u{2022} first\n\u{2022} second\n1. nested one\n"), + "{t}" + ); + // No paragraph for the white space between blocks. + assert!(!d.contains(""), "{d}"); +} + +/// A link goes to a web, mail, phone or FTP address or to a place in the page, +/// and nowhere else: a script, a file on the author's computer or a data +/// address is text. +#[test] +fn only_safe_links_are_kept() { + let docx = run(WORD); + let d = body(&docx); + assert_eq!(d.matches("d m + v r t

"#, + ); + let m = body(&more); + assert_eq!(m.matches("

Caf=C3=A9 =\r\nau lait

\ +

3D\"logo\"

\ +

3D\"gone\"

\r\n\ + ------=_NextPart_01\r\nContent-Location: file:///C:/Temp/page_files/image001.png\r\n\ + Content-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{png}\r\n\ + ------=_NextPart_01--\r\n" + ); + let docx = convert(message.as_bytes(), Format::Html, &Limits::default()) + .unwrap() + .docx; + let d = body(&docx); + assert!(d.contains("Café au lait"), "{d}"); + assert!( + d.contains("") && d.contains("cx=\"304800\" cy=\"152400\""), + "{d}" + ); + assert!( + d.contains("[gone]"), + "the picture that is not there is told by its text: {d}" + ); + assert!( + !d.contains("MIME-Version") && !d.contains("NextPart"), + "{d}" + ); + assert!(has_part(&docx, "word/media/image1.png")); + let t = text( + message.as_bytes(), + Format::Html, + &Limits::default(), + 1 << 20, + ) + .unwrap(); + assert!(t.text.contains("Café au lait"), "{}", t.text); +} + +#[test] +fn a_picture_in_a_data_address_is_embedded() { + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=="; + let d = body(&run(&format!( + r#"

x

"# + ))); + assert!(d.contains(""), "{d}"); +} + +#[test] +fn what_is_not_html_is_refused_and_hostile_pages_are_bounded() { + let e = convert(b"just some text", Format::Html, &Limits::default()).unwrap_err(); + assert_eq!(e.kind(), "not-format"); + // Many paragraphs. + let many = "

x".repeat(200); + let e = convert( + many.as_bytes(), + Format::Html, + &Limits::default().with_max_paragraphs(5), + ) + .unwrap_err(); + assert_eq!(e, Error::TooLarge("paragraphs")); + // Elements nested far past what is kept, and tables in tables. + let deep = format!( + "{}text{}", + "

".repeat(50_000), + "
".repeat(50_000) + ); + assert!(body(&run(&deep)).contains("text")); + let tables = format!( + "{}cell{}", + "
".repeat(5_000), + "
".repeat(5_000) + ); + assert!(convert(tables.as_bytes(), Format::Html, &Limits::default()).is_ok()); + // Cells past the bound. + let cells = format!("{}
", "
c".repeat(100)); + let e = convert( + cells.as_bytes(), + Format::Html, + &Limits::default().with_max_cells(10), + ) + .unwrap_err(); + assert_eq!(e, Error::TooLarge("table cells")); +} + +/// The text a scanner or a search reads: what the page shows. +#[test] +fn the_text_of_a_page_is_what_it_shows() { + let t = text(WORD.as_bytes(), Format::Html, &Limits::default(), 1 << 20).unwrap(); + assert!(t.text.contains("Quarterly summary"), "{}", t.text); + assert!( + t.text + .contains("Revenue grew by twelve percent\u{a0}in May & June."), + "{}", + t.text + ); + assert!(!t.text.contains("script text") && !t.text.contains("not shown")); +} + +/// Tables left open and nested a hundred thousand deep are read to the +/// depth elements nest to; the model they make is not as deep as the page. +#[test] +fn tables_nested_without_end_do_not_exhaust_the_stack() { + let page = format!("{}cell", "
".repeat(100_000)); + let docx = convert(page.as_bytes(), Format::Html, &Limits::default()) + .unwrap() + .docx; + assert!(body(&docx).contains("cell")); +} + +/// The cells a row span continues into are cells of the model, and are +/// counted: 5,000 rows each spanning 1,000 rows made some 5 million of them. +#[test] +fn the_cells_a_row_span_continues_into_are_counted() { + let page = format!( + "{}
", + "
x".repeat(5_000) + ); + let limits = Limits::default().with_max_memory(256 << 20); + let e = convert(page.as_bytes(), Format::Html, &limits).unwrap_err(); + assert!(matches!(e, Error::TooLarge(_)), "{e:?}"); +} + +/// Raw text elements are found by their end tag without a copy of the rest of +/// the page for each: 200,000 of them are a page of a few MB, not a minute. +#[test] +fn many_raw_text_elements_are_linear() { + let page = format!( + "{}", + "".repeat(100_000) + ); + let start = std::time::Instant::now(); + assert!(convert(page.as_bytes(), Format::Html, &Limits::default()).is_ok()); + assert!(start.elapsed().as_secs() < 5, "{:?}", start.elapsed()); +} + +/// A link opened inside a link ends the first, as in HTML: links left open +/// by the thousand do not nest into a model as deep. +#[test] +fn links_left_open_do_not_nest() { + let page = format!("{}end", "l".repeat(50_000)); + let docx = convert(page.as_bytes(), Format::Html, &Limits::default()) + .unwrap() + .docx; + assert!(body(&docx).contains("end")); + // Every link is a link of its own, none within another. + let d = body(&docx); + assert!(d.matches("= 40_000); +} diff --git a/crates/exav-render/tests/office_memory.rs b/crates/exav-render/tests/office_memory.rs new file mode 100644 index 00000000..2328e38d --- /dev/null +++ b/crates/exav-render/tests/office_memory.rs @@ -0,0 +1,550 @@ +//! What a converter holds, against what its limits say it holds. +//! +//! `Limits::max_memory` is an estimate the converter keeps as it builds its +//! model: so much a paragraph, a shape, a cell, a tree node. An estimate that +//! drifts from what the model really takes turns the limit into a number that +//! protects nothing, and the scanner's own memory bound (the scan's budget is +//! handed to these limits) with it. Each test here makes a document of one +//! kind of unit, as many as the limit admits, and counts what the conversion +//! allocated at its peak. The peak has to be within a small factor of the +//! limit: the factor is the estimate's slack (the allocator's rounding, the +//! output beside the model), not an excuse for ten times. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Counting; + +static NOW: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let p = unsafe { System.alloc(layout) }; + if !p.is_null() { + let now = NOW.fetch_add(layout.size(), Ordering::Relaxed) + layout.size(); + PEAK.fetch_max(now, Ordering::Relaxed); + } + p + } + + unsafe fn dealloc(&self, p: *mut u8, layout: Layout) { + unsafe { System.dealloc(p, layout) }; + NOW.fetch_sub(layout.size(), Ordering::Relaxed); + } + + unsafe fn realloc(&self, p: *mut u8, layout: Layout, new: usize) -> *mut u8 { + let q = unsafe { System.realloc(p, layout, new) }; + if !q.is_null() { + if new >= layout.size() { + let now = + NOW.fetch_add(new - layout.size(), Ordering::Relaxed) + new - layout.size(); + PEAK.fetch_max(now, Ordering::Relaxed); + } else { + NOW.fetch_sub(layout.size() - new, Ordering::Relaxed); + } + } + q + } +} + +#[global_allocator] +static ALLOCATOR: Counting = Counting; + +/// The counters are the process's: one test measures at a time. +static ONE_AT_A_TIME: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// What `f` allocated at its peak, beyond what was held when it began. What it +/// returns is kept alive until the measure is taken. +fn peak_of(f: impl FnOnce() -> T) -> (usize, T) { + let before = NOW.load(Ordering::Relaxed); + PEAK.store(before, Ordering::Relaxed); + let out = f(); + (PEAK.load(Ordering::Relaxed).saturating_sub(before), out) +} + +/// The most the limit may be exceeded by in practice. +const SLACK: f64 = 2.0; + +/// Makes the document of `n` units, converts it under `limit` bytes of +/// memory, and says what the conversion allocated at its peak, or `None` when +/// it was refused. +type Probe = dyn Fn(usize, u64) -> Option; + +/// The largest `n` in `1..=max` the probe admits, by bisection (admitting is +/// monotone in `n`), and the peak of that conversion. +fn largest(probe: &Probe, max: usize, limit: u64) -> (usize, usize) { + let (mut lo, mut hi) = (1usize, max); + let mut best = (0, 0); + while lo <= hi { + let mid = lo + (hi - lo) / 2; + match probe(mid, limit) { + Some(peak) => { + best = (mid, peak); + lo = mid + 1; + } + None => hi = mid - 1, + } + } + best +} + +/// A conversion admitted at the limit must stay within [`SLACK`] times it. +fn within_the_limit(what: &str, probe: &Probe, max: usize) { + within_the_limit_of(what, probe, max, 32 << 20); +} + +/// As [`within_the_limit`], under `limit` bytes: for a unit so small that the +/// largest document the test can build is under the usual limit. +fn within_the_limit_of(what: &str, probe: &Probe, max: usize, limit: u64) { + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let (n, peak) = largest(probe, max, limit); + eprintln!( + "{what}: {n} units admitted at {} MiB, peak {:.1} MiB ({:.2}x)", + limit >> 20, + peak as f64 / (1 << 20) as f64, + peak as f64 / limit as f64 + ); + assert!(n > 0, "{what}: not even one unit was admitted"); + assert!( + (peak as f64) <= SLACK * limit as f64, + "{what}: the limit is {limit} bytes and the conversion held {peak} at {n} units" + ); +} + +mod docs { + use super::*; + use exav_render::docs::{Error, Format, Limits, convert}; + + fn at(input: &[u8], format: Format, limit: u64) -> Option { + let limits = Limits::default().with_max_memory(limit); + let (peak, out) = peak_of(|| convert(input, format, &limits)); + match out { + Ok(_) => Some(peak), + Err(Error::TooLarge(_)) => None, + Err(e) => panic!("the document was not read: {e}"), + } + } + + #[test] + fn a_flat_odt_of_paragraphs() { + within_the_limit( + "fodt paragraphs", + &|n, limit| { + let mut s = String::from( + "\ + ", + ); + for _ in 0..n { + s.push_str("a"); + } + s.push_str(""); + at(s.as_bytes(), Format::Fodt, limit) + }, + 2_000_000, + ); + } + + #[test] + fn a_flat_odt_of_text() { + within_the_limit( + "fodt text", + &|n, limit| { + let mut s = String::from( + "\ + ", + ); + let line = "a".repeat(20_000); + for _ in 0..n { + s.push_str(""); + s.push_str(&line); + s.push_str(""); + } + s.push_str(""); + at(s.as_bytes(), Format::Fodt, limit) + }, + 4_000, + ); + } + + #[test] + fn an_rtf_of_paragraphs() { + within_the_limit( + "rtf paragraphs", + &|n, limit| { + let mut s = String::from("{\\rtf1\\ansi "); + for _ in 0..n { + s.push_str("a\\par "); + } + s.push('}'); + at(s.as_bytes(), Format::Rtf, limit) + }, + 4_000_000, + ); + } + + #[test] + fn an_rtf_of_text() { + within_the_limit( + "rtf text", + &|n, limit| { + let line = "a".repeat(20_000); + let mut s = String::from("{\\rtf1\\ansi "); + for _ in 0..n { + s.push_str(&line); + s.push_str("\\par "); + } + s.push('}'); + at(s.as_bytes(), Format::Rtf, limit) + }, + 4_000, + ); + } + + #[test] + fn a_word_2003_xml_of_paragraphs() { + within_the_limit( + "wml paragraphs", + &|n, limit| { + let mut s = String::from( + "\ + ", + ); + for _ in 0..n { + s.push_str("a"); + } + s.push_str(""); + at(s.as_bytes(), Format::Wml2003, limit) + }, + 2_000_000, + ); + } + + #[test] + fn a_word_2003_xml_of_text() { + within_the_limit( + "wml text", + &|n, limit| { + let line = "a".repeat(20_000); + let mut s = String::from( + "\ + ", + ); + for _ in 0..n { + s.push_str(""); + s.push_str(&line); + s.push_str(""); + } + s.push_str(""); + at(s.as_bytes(), Format::Wml2003, limit) + }, + 4_000, + ); + } +} + +mod word6 { + use super::*; + use exav_render::docs::{Error, Format, Limits, convert}; + use exav_render::formats::office::test_util::build_cfb; + + /// A Word 6 document of `n` one-letter paragraphs, its text in one piece. + fn doc6(n: usize) -> Vec { + let text = b"X\r".repeat(n); + let mut wd = vec![0u8; 512]; + wd[0..2].copy_from_slice(&0xA5DBu16.to_le_bytes()); + wd[2..4].copy_from_slice(&0x0065u16.to_le_bytes()); + // Complex, not encrypted. + wd[10..12].copy_from_slice(&0x0004u16.to_le_bytes()); + wd[28..32].copy_from_slice(&(text.len() as u32).to_le_bytes()); + wd[52..56].copy_from_slice(&((n * 2) as i32).to_le_bytes()); + let mut plc = vec![0u8, 0]; + plc.extend(531u32.to_le_bytes()); + plc.extend([0u8, 0]); + let mut clx = vec![2u8]; + clx.extend((plc.len() as u16).to_le_bytes()); + clx.extend(plc); + wd[352..356].copy_from_slice(&512u32.to_le_bytes()); + wd[356..360].copy_from_slice(&(clx.len() as u32).to_le_bytes()); + wd.extend(clx); + wd.extend(text); + build_cfb(&[("WordDocument", &wd)]) + } + + #[test] + fn paragraphs() { + within_the_limit( + "doc (Word 6) paragraphs", + &|n, limit| { + let input = doc6(n); + let limits = Limits::default().with_max_memory(limit); + let (peak, out) = peak_of(|| convert(&input, Format::Doc, &limits)); + match out { + Ok(_) => Some(peak), + Err(Error::TooLarge(_)) => None, + Err(e) => panic!("the document was not read: {e}"), + } + }, + 400_000, + ); + } +} + +mod slides { + use super::*; + use exav_render::formats::office::test_util::build_cfb; + use exav_render::slides::{Error, Format, Limits, convert}; + + fn rec(typ: u32, flag: u32, id: u32, body: &[u8]) -> Vec { + let mut v = vec![]; + for x in [typ, flag, body.len() as u32, id] { + v.extend(x.to_le_bytes()); + } + v.extend(body); + v + } + + fn container(typ: u32, kids: &[Vec]) -> Vec { + rec(typ, 0xFFFF, 0, &kids.concat()) + } + + fn i32s(v: &[i32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() + } + + /// A PowerPoint 95 stream of one slide holding `n` filled hexagons. + fn stream(n: usize) -> Vec { + let mut look = vec![0u8; 56]; + look[12] = 0; + look[13] = 1; + let shape = container( + 0x0BC0, + &[ + rec(0x0BC1, 0, 0, &i32s(&[5, 0, 0, 0, 0, 100, 100, 0, 0, 0])), + rec(0x0BBD, 0, 0, &look), + ], + ); + let shapes: Vec> = (0..n).map(|_| shape.clone()).collect(); + let atom = rec(0x03E9, 0, 0, &i32s(&[5760, 4320])); + let slide = container(0x03EE, &[container(0x0BB8, &shapes)]); + let doc = container(0x03E8, &[atom, container(0x07D0, &[slide])]); + container(3, &[doc]) + } + + #[test] + fn shapes() { + // A shape is 150 bytes of file and well over 400 of memory, so the + // limit is one the largest file the builder writes (7 MB: past it the + // compound file needs a DIFAT) goes past. + within_the_limit_of( + "ppt (95) shapes", + &|n, limit| { + let input = build_cfb(&[("PowerPoint Document", &stream(n))]); + let limits = Limits::default().with_max_memory(limit); + let (peak, out) = peak_of(|| convert(&input, Format::Ppt, &limits)); + match out { + Ok(_) => Some(peak), + Err(Error::TooLarge(_)) => None, + Err(e) => panic!("the presentation was not read: {e}"), + } + }, + 45_000, + 8 << 20, + ); + } +} + +mod sheets { + use super::*; + use exav_render::sheets::{Error, Format, Limits, convert}; + + fn record(id: u16, body: &[u8]) -> Vec { + let mut v = id.to_le_bytes().to_vec(); + v.extend((body.len() as u16).to_le_bytes()); + v.extend(body); + v + } + + /// A BIFF8 workbook of one sheet of `n` shared-string cells. + fn workbook(n: usize) -> Vec { + let bof = |dt: u16| { + let mut b = vec![]; + b.extend(0x0600u16.to_le_bytes()); + b.extend(dt.to_le_bytes()); + b.extend([0u8; 12]); + record(0x0809, &b) + }; + let mut sst = vec![]; + sst.extend(1u32.to_le_bytes()); + sst.extend(1u32.to_le_bytes()); + sst.extend([1, 0, 0, b'a']); + // The globals, with the sheet's BOF where the BOUNDSHEET says: the + // length of the globals does not depend on the offset. + let globals = |at: usize| { + let mut out = bof(0x0005); + out.extend(record(0x00FC, &sst)); + let mut bound = vec![]; + bound.extend((at as u32).to_le_bytes()); + bound.extend([0, 0, 1, 0, b'S']); + out.extend(record(0x0085, &bound)); + out.extend(record(0x000A, &[])); + out + }; + let mut out = globals(globals(0).len()); + out.extend(bof(0x0010)); + for i in 0..n { + let mut c = vec![]; + c.extend(((i / 200) as u16).to_le_bytes()); + c.extend(((i % 200) as u16).to_le_bytes()); + c.extend(0u16.to_le_bytes()); + c.extend(0u32.to_le_bytes()); + out.extend(record(0x00FD, &c)); + } + out.extend(record(0x000A, &[])); + out + } + + /// The spreadsheet name of column `n` (1 is `A`, 27 is `AA`). + fn column_name(mut n: usize) -> String { + let mut name = Vec::new(); + while n > 0 { + n -= 1; + name.push(b'A' + (n % 26) as u8); + n /= 26; + } + name.reverse(); + String::from_utf8(name).unwrap() + } + + /// An ODS of 10,000 text cells and a chart of `n` series over all of them. + fn chart_ods(n: usize) -> Vec { + chart_ods_of(n, 20, 500) + } + + /// An ODS of `cols` by `rows` text cells and a chart of `n` series over + /// all of them. + fn chart_ods_of(n: usize, cols: usize, rows: usize) -> Vec { + use std::io::Write as _; + let range = format!("Sheet.A1:.{}{rows}", column_name(cols)); + let ns = "xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" \ + xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\" \ + xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" \ + xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\" \ + xmlns:chart=\"urn:oasis:names:tc:opendocument:xmlns:chart:1.0\" \ + xmlns:xlink=\"http://www.w3.org/1999/xlink\""; + let cell = format!( + "{}", + "A".repeat(40) + ); + let mut content = format!( + "\ + " + ); + for r in 0..rows { + content.push_str(""); + content.push_str(&cell.repeat(cols)); + if r == 0 { + content.push_str( + "\ + ", + ); + } + content.push_str(""); + } + content.push_str( + "", + ); + let series = format!( + "" + ) + .repeat(n); + let chart = format!( + "\ + \ + {series}\ + " + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + z.start_file("mimetype", stored).unwrap(); + z.write_all(b"application/vnd.oasis.opendocument.spreadsheet") + .unwrap(); + let deflated = zip::write::SimpleFileOptions::default(); + z.start_file("content.xml", deflated).unwrap(); + z.write_all(content.as_bytes()).unwrap(); + z.start_file("Object 1/content.xml", deflated).unwrap(); + z.write_all(chart.as_bytes()).unwrap(); + z.finish().unwrap().into_inner() + } + + /// Every series of a chart gets its own copy of the cells it draws. + #[test] + fn chart_series_over_the_same_cells() { + within_the_limit( + "ods chart series", + &|n, limit| { + let input = chart_ods(n); + let limits = Limits::default().with_max_memory(limit); + let (peak, out) = peak_of(|| convert(&input, Format::Ods, &limits)); + match out { + Ok(_) => Some(peak), + Err(Error::TooLarge(_)) => None, + Err(e) => panic!("the workbook was not read: {e}"), + } + }, + 256, + ); + } + + /// The same at a hundred thousand cells, where a copy of the cells per + /// series is megabytes a series. + #[test] + fn chart_series_over_a_large_range() { + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + for (series, limit) in [(256, 256u64 << 20), (1024, 256 << 20), (1024, 1 << 30)] { + let input = chart_ods_of(series, 100, 1000); + let limits = Limits::default().with_max_memory(limit); + let (peak, out) = peak_of(|| convert(&input, Format::Ods, &limits)); + let verdict = match &out { + Ok(_) => "converted", + Err(Error::TooLarge(_)) => "refused", + Err(e) => panic!("the workbook was not read: {e}"), + }; + eprintln!( + "{series} series over 100,000 cells, limit {} MiB: {verdict}, peak {:.1} MiB", + limit >> 20, + peak as f64 / (1 << 20) as f64 + ); + assert!( + peak as f64 <= SLACK * limit as f64, + "the limit is {limit} bytes and the conversion held {peak} at {series} series" + ); + } + } + + #[test] + fn cells() { + within_the_limit( + "xls (BIFF8) cells", + &|n, limit| { + let input = workbook(n); + let limits = Limits::default().with_max_memory(limit); + let (peak, out) = peak_of(|| convert(&input, Format::Xls, &limits)); + match out { + Ok(_) => Some(peak), + Err(Error::TooLarge(_)) => None, + Err(e) => panic!("the workbook was not read: {e}"), + } + }, + 1_000_000, + ); + } +} diff --git a/crates/exav-render/tests/office_sheets.rs b/crates/exav-render/tests/office_sheets.rs new file mode 100644 index 00000000..4e67f4b1 --- /dev/null +++ b/crates/exav-render/tests/office_sheets.rs @@ -0,0 +1,294 @@ +//! The fixtures converted through the public API, the XLSX read back. + +use exav_render::sheets::{Error, Format, Limits, convert}; +use std::io::Read; + +fn fixture(name: &str) -> Vec { + std::fs::read(format!( + "{}/tests/fixtures/office/sheets/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +/// The XLSX's parts as text. +struct Xlsx(zip::ZipArchive>>); + +impl Xlsx { + fn of(name: &str, format: Format) -> Xlsx { + let c = convert(&fixture(name), format, &Limits::default()).unwrap(); + Xlsx(zip::ZipArchive::new(std::io::Cursor::new(c.xlsx)).unwrap()) + } + + fn part(&mut self, name: &str) -> String { + let mut s = String::new(); + self.0 + .by_name(name) + .unwrap() + .read_to_string(&mut s) + .unwrap(); + s + } +} + +/// What every conversion of sample.fods shows, whatever it went through. +fn budget(x: &mut Xlsx) { + let wb = x.part("xl/workbook.xml"); + assert!( + wb.contains(r#"SUM(B2:B4)1635.49"), "{s1}"); + assert!(s1.contains(r#""#), "{s1}"); + // The amount, a number; the date, a serial. + assert!( + s1.contains("345.5") && s1.contains("45296"), + "{s1}" + ); + let st = x.part("xl/styles.xml"); + assert!(st.contains("FF4472C4"), "the header's fill: {st}"); + // #,##0.00, Excel's built-in format 4. + assert!( + st.contains(r#"1"), "{s2}"); +} + +#[test] +fn ods() { + budget(&mut Xlsx::of("sample.ods", Format::Ods)); +} + +#[test] +fn fods() { + let mut x = Xlsx::of("sample.fods", Format::Fods); + budget(&mut x); + assert!( + x.part("xl/worksheets/sheet2.xml") + .contains(r#"t="b">1"#) + ); +} + +#[test] +fn xls() { + budget(&mut Xlsx::of("sample.xls", Format::Xls)); +} + +#[test] +fn numbers() { + let mut x = Xlsx::of("sample.numbers", Format::Numbers); + assert!( + x.part("xl/workbook.xml") + .contains(r#""#), "{s1}"); + assert!(s1.contains(r#"showGridLines="0""#), "{s1}"); +} + +#[test] +fn numbers_09() { + let mut x = Xlsx::of("numbers09.numbers", Format::Numbers); + let wb = x.part("xl/workbook.xml"); + for name in [ + "Checking - Account Categories", + "Checking - Transactions", + "Second sheet", + ] { + assert!(wb.contains(&format!(r#"SUMIF('Checking - Transactions'!$D$3:$D$14,A2,'Checking - Transactions'!E$3:E$14)"), + "{s1}" + ); + let s2 = x.part("xl/worksheets/sheet2.xml"); + assert!(s2.contains(r#""#), "{s2}"); + assert!(s2.contains(r#"ySplit="2""#), "the frozen header rows: {s2}"); + // 2009-10-01. + assert!(s2.contains("40087"), "{s2}"); + let st = x.part("xl/styles.xml"); + assert!( + st.contains(r#"formatCode=""€"* #,##0.00;"€"* (#,##0.00)""#), + "the accounting format: {st}" + ); + assert!(st.contains(r#""#), "{st}"); +} + +/// What each conversion of the text formats' budget table shows. +fn text_budget(x: &mut Xlsx, merged: bool) { + let sst = x.part("xl/sharedStrings.xml"); + for s in ["Item", "Rent", "Travel", "Merged note across three columns"] { + assert!(sst.contains(s), "{s} in {sst}"); + } + let s1 = x.part("xl/worksheets/sheet1.xml"); + assert!(s1.contains("SUM(B2:B4)1635.49"), "{s1}"); + assert!( + s1.contains("345.5") && s1.contains("45297"), + "{s1}" + ); + assert_eq!(s1.contains(r#""#), merged, "{s1}"); + let st = x.part("xl/styles.xml"); + assert!( + st.contains(r#"1"#) + ); +} + +#[test] +fn html_table() { + let mut x = Xlsx::of("sample.html", Format::Html); + assert!( + x.part("xl/workbook.xml") + .contains(r#"{}", + "".repeat(100_000) + ); + let small = Limits::default().with_max_memory(8 << 20); + assert_eq!( + convert(x.as_bytes(), Format::SpreadsheetMl, &small).unwrap_err(), + Error::TooLarge("memory") + ); + // Within the default bound the tree is read; a workbook of no sheet is + // what is refused then. + let e = convert(x.as_bytes(), Format::SpreadsheetMl, &Limits::default()).unwrap_err(); + assert_ne!(e, Error::TooLarge("memory")); +} diff --git a/crates/exav-render/tests/office_slides.rs b/crates/exav-render/tests/office_slides.rs new file mode 100644 index 00000000..c9e0252b --- /dev/null +++ b/crates/exav-render/tests/office_slides.rs @@ -0,0 +1,543 @@ +//! The public API over the fixtures (see fixtures/office/slides/README.md). + +use exav_render::formats::office::test_util::Compound; +use exav_render::formats::office::test_util::build_cfb as build; +use exav_render::slides::{ + Error, Format, Limits, convert, convert_first, convert_first_from, convert_keynote_package, +}; +use std::io::Read; + +fn fixture(name: &str) -> Vec { + std::fs::read(format!( + "{}/tests/fixtures/office/slides/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +/// A part of a PPTX, as text. +fn part(pptx: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + let mut s = String::new(); + z.by_name(name).unwrap().read_to_string(&mut s).unwrap(); + s +} + +/// An ODP of one slide holding a bar chart object, written per ODF 1.3, +/// whose series takes its values from `table`. +fn chart_odp(table: &str) -> Vec { + use std::io::Write; + const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0""#; + let content = format!( + r#""# + ); + let cell = |v: &str| match v.parse::() { + Ok(_) => format!( + r#"{v}"# + ), + Err(_) => format!( + r#"{v}"# + ), + }; + let row = + |a: &str, b: &str| format!("{}{}", cell(a), cell(b)); + let chart = format!( + r#"{}{}{}"#, + row("", "Sales"), + row("Q1", "10"), + row("Q2", "20") + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for (name, data) in [ + ( + "mimetype", + "application/vnd.oasis.opendocument.presentation", + ), + ("content.xml", content.as_str()), + ("Object 1/content.xml", chart.as_str()), + ] { + z.start_file(name, opts).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +#[test] +fn chart_objects_become_charts() { + let c = convert(&chart_odp("local-table"), Format::Odp, &Limits::default()).unwrap(); + let s1 = part(&c.pptx, "ppt/slides/slide1.xml"); + assert!(s1.contains(""), "{chart}"); + assert!(chart.contains("Sales") && chart.contains("Q2") && chart.contains("20")); + assert!(part(&c.pptx, "[Content_Types].xml").contains("/ppt/charts/chart1.xml")); + // A range in another table: no reference, as there is no workbook for + // it to name. + let c = convert(&chart_odp("Table1"), Format::Odp, &Limits::default()).unwrap(); + let chart = part(&c.pptx, "ppt/charts/chart1.xml"); + assert!( + chart.contains("") && !chart.contains(""), + "{chart}" + ); +} + +fn names(pptx: &[u8]) -> Vec { + let z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + z.file_names() + .filter_map(|n| n.ok().map(|n| n.to_string())) + .collect() +} + +/// The PPTX's own structure: a presentation naming its slides, which name +/// their layouts, which name their masters. +fn check_package(pptx: &[u8], slides: usize) { + let n = names(pptx); + for p in [ + "[Content_Types].xml", + "_rels/.rels", + "ppt/presentation.xml", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/theme/theme1.xml", + ] { + assert!(n.iter().any(|x| x == p), "{p} missing"); + } + let pres = part(pptx, "ppt/presentation.xml"); + assert_eq!(pres.matches("")); + assert!(s1.contains("")); + assert!(s1.contains("prst=\"ellipse\"")); + // The page size: 28 by 15.75 cm. + assert!( + part(&c.pptx, "ppt/presentation.xml").contains("") + ); + assert!(part(&c.pptx, "ppt/slides/slide2.xml").contains("Second slide text")); +} + +#[test] +fn opendocument_package() { + let c = convert(&fixture("sample.odp"), Format::Odp, &Limits::default()).unwrap(); + assert_eq!(c.slides, 2); + check_package(&c.pptx, 2); + let s1 = part(&c.pptx, "ppt/slides/slide1.xml"); + assert!(s1.contains("Revenue up 12%") && s1.contains("Costs flat")); + assert!(s1.contains("2A6099")); +} + +#[test] +fn powerpoint_97() { + let c = convert(&fixture("sample.ppt"), Format::Ppt, &Limits::default()).unwrap(); + assert_eq!(c.slides, 2); + check_package(&c.pptx, 2); + let s1 = part(&c.pptx, "ppt/slides/slide1.xml"); + assert!(s1.contains("Quarterly budget")); + assert!(s1.contains("Revenue up 12%")); + assert!(s1.contains("2A6099"), "the box's fill"); + assert!(s1.contains("prst=\"ellipse\"")); + assert!(part(&c.pptx, "ppt/slides/slide2.xml").contains("Second slide text")); +} + +/// The chart of `chart.fodp`: a bar chart of three quarters' sales. +fn check_sales_chart(pptx: &[u8]) { + assert!(part(pptx, "ppt/slides/slide1.xml").contains(""), "{chart}"); + for v in ["Sales", "Q1", "Q3", "25"] { + assert!(chart.contains(v), "{v} in {chart}"); + } +} + +#[test] +fn charts_in_every_format_that_holds_them() { + for (name, format) in [ + ("chart.ppt", Format::Ppt), + ("chart.odp", Format::Odp), + ("chart.fodp", Format::Fodp), + ] { + let c = convert(&fixture(name), format, &Limits::default()).unwrap(); + check_sales_chart(&c.pptx); + } +} + +#[test] +fn keynote() { + let c = convert(&fixture("sample.key"), Format::Keynote, &Limits::default()).unwrap(); + assert_eq!(c.slides, 1); + check_package(&c.pptx, 1); + let s1 = part(&c.pptx, "ppt/slides/slide1.xml"); + assert!(s1.contains("Example Presentation")); + assert!(s1.contains("This is a subtitle.")); + // The same document as a package directory, given file by file. + let mut z = zip::ZipArchive::new(std::io::Cursor::new(fixture("sample.key"))).unwrap(); + let mut files = vec![]; + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.is_dir() { + continue; + } + let mut v = vec![]; + f.read_to_end(&mut v).unwrap(); + files.push((f.name().map(|n| n.to_string()).unwrap_or_default(), v)); + } + let refs: Vec<(&str, &[u8])> = files + .iter() + .map(|(n, d)| (n.as_str(), d.as_slice())) + .collect(); + let p = convert_keynote_package(&refs, &Limits::default()).unwrap(); + assert!(part(&p.pptx, "ppt/slides/slide1.xml").contains("Example Presentation")); +} + +#[test] +fn keynote_tables() { + let c = convert(&fixture("table.key"), Format::Keynote, &Limits::default()).unwrap(); + let s1 = part(&c.pptx, "ppt/slides/slide1.xml"); + // Four columns of 413.5 points, five rows of 160, and the style's + // banding on the body's every second row. + assert_eq!(s1.matches("").count(), 4, "{s1}"); + assert_eq!(s1.matches("").count(), 5, "{s1}"); + assert_eq!(s1.matches("E3E5E8").count(), 8, "{s1}"); + assert!(!c.warnings.iter().any(|w| w.contains("tables"))); +} + +#[test] +fn first_slides_only() { + for (name, format, total) in [ + ("sample.ppt", Format::Ppt, 2), + ("sample.odp", Format::Odp, 2), + ("sample.fodp", Format::Fodp, 2), + ("sample.key", Format::Keynote, 1), + ] { + let c = convert_first(&fixture(name), format, &Limits::default(), 1).unwrap(); + assert_eq!((c.slides, c.total_slides), (1, total), "{name}"); + check_package(&c.pptx, 1); + assert!(!names(&c.pptx).iter().any(|n| n == "ppt/slides/slide2.xml")); + let all = convert(&fixture(name), format, &Limits::default()).unwrap(); + assert_eq!((all.slides, all.total_slides), (total, total)); + } +} + +/// An ODP of two slides, the second showing a picture of `size` bytes. +fn odp_with_picture(size: usize) -> Vec { + use std::io::Write; + let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; + let content = format!( + r#""# + ); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.resize(size, 7); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + // Stored, as pictures are: the package is as large as they are. + let opts = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, data) in [ + ( + "mimetype", + &b"application/vnd.oasis.opendocument.presentation"[..], + ), + ("content.xml", content.as_bytes()), + ("Pictures/a.png", &png), + ] { + z.start_file(name, opts).unwrap(); + z.write_all(data).unwrap(); + } + z.finish().unwrap().into_inner() +} + +#[test] +fn pictures_are_read_when_a_slide_shows_them() { + let odp = odp_with_picture(2 << 20); + let all = convert(&odp, Format::Odp, &Limits::default()).unwrap(); + assert!(names(&all.pptx).iter().any(|n| n == "ppt/media/image1.png")); + // Within a memory bound the picture is past, the first slide converts + // alone: the picture is never read. + let small = Limits::default().with_max_memory(1 << 20); + assert_eq!( + convert(&odp, Format::Odp, &small).unwrap_err(), + Error::TooLarge("memory") + ); + let first = convert_first(&odp, Format::Odp, &small, 1).unwrap(); + assert!( + !names(&first.pptx) + .iter() + .any(|n| n.starts_with("ppt/media/")) + ); +} + +fn base64(d: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::new(); + for c in d.chunks(3) { + let n = c + .iter() + .enumerate() + .fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for k in 0..=c.len() { + s.push(A[(n >> (18 - 6 * k) & 63) as usize] as char); + } + } + while !s.len().is_multiple_of(4) { + s.push('='); + } + s +} + +#[test] +fn tiled_backgrounds_are_scaled_to_the_pictures_resolution() { + // A 100 by 50 PNG at 144 dpi (5669 dots per metre): two thirds of its 96 dpi size. + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend_from_slice(&13u32.to_be_bytes()); + png.extend_from_slice(b"IHDR"); + png.extend_from_slice(&100u32.to_be_bytes()); + png.extend_from_slice(&50u32.to_be_bytes()); + png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]); + png.extend_from_slice(&9u32.to_be_bytes()); + png.extend_from_slice(b"pHYs"); + png.extend_from_slice(&5669u32.to_be_bytes()); + png.extend_from_slice(&5669u32.to_be_bytes()); + png.push(1); + png.extend_from_slice(&[0; 4]); + png.extend_from_slice(&[0, 0, 0, 0]); + png.extend_from_slice(b"IDAT"); + let slide = |size: &str| { + let fodp = format!( + r#"{}"#, + base64(&png) + ); + let c = convert(fodp.as_bytes(), Format::Fodp, &Limits::default()).unwrap(); + part(&c.pptx, "ppt/slides/slide1.xml") + }; + let s1 = slide(""); + // Two thirds, within what the resolution's rounding gives. + let sx: u32 = s1 + .split_once(r#"") && !s1.contains(" { + c: std::io::Cursor<&'a [u8]>, + read: usize, +} + +impl Read for Counting<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.c.read(buf)?; + self.read += n; + Ok(n) + } +} + +impl std::io::Seek for Counting<'_> { + fn seek(&mut self, to: std::io::SeekFrom) -> std::io::Result { + self.c.seek(to) + } +} + +#[test] +fn packages_are_read_part_by_part_from_a_source() { + let odp = odp_with_picture(2 << 20); + let mut src = Counting { + c: std::io::Cursor::new(&odp), + read: 0, + }; + let c = convert_first_from(&mut src, Format::Odp, &Limits::default(), 1).unwrap(); + assert_eq!((c.slides, c.total_slides), (1, 2)); + assert!(src.read < 64 << 10, "{} bytes read", src.read); + // The whole document, through the same source: the picture is read. + let mut src = Counting { + c: std::io::Cursor::new(&odp), + read: 0, + }; + let c = convert_first_from(&mut src, Format::Odp, &Limits::default(), 2).unwrap(); + assert!(names(&c.pptx).iter().any(|n| n == "ppt/media/image1.png")); + assert!(src.read > 2 << 20); +} + +#[test] +fn wrong_formats_are_refused() { + let ppt = fixture("sample.ppt"); + assert!(matches!( + convert(&ppt, Format::Odp, &Limits::default()), + Err(Error::NotFormat(_)) + )); + assert!(matches!( + convert(b"hello", Format::Ppt, &Limits::default()), + Err(Error::NotFormat(_)) + )); + assert!(matches!( + convert(&fixture("sample.odp"), Format::Keynote, &Limits::default()), + Err(Error::NotFormat(_)) + )); + assert!(matches!( + convert(b"", Format::Fodp, &Limits::default()), + Err(Error::NotFormat(_)) + )); +} + +#[test] +fn limits_are_kept() { + let odp = fixture("sample.odp"); + let few = Limits::default().with_max_shapes(2); + assert_eq!( + convert(&odp, Format::Odp, &few).unwrap_err(), + Error::TooLarge("shapes") + ); + let small = Limits::default().with_max_memory(10_000); + assert_eq!( + convert(&odp, Format::Odp, &small).unwrap_err().kind(), + "too-large" + ); + let mut tiny = Limits::default(); + tiny.max_input = 100; + assert_eq!( + convert(&odp, Format::Odp, &tiny).unwrap_err(), + Error::TooLarge("input") + ); +} + +#[test] +fn damaged_files_do_not_panic() { + for (name, format) in [ + ("sample.ppt", Format::Ppt), + ("sample.odp", Format::Odp), + ("sample.fodp", Format::Fodp), + ("sample.key", Format::Keynote), + ] { + let data = fixture(name); + for cut in [10, data.len() / 3, data.len() / 2, data.len() - 1] { + let _ = convert(&data[..cut], format, &Limits::default()); + } + let mut flipped = data.clone(); + for i in (0..flipped.len()).step_by(97) { + flipped[i] ^= 0x5A; + } + let _ = convert(&flipped, format, &Limits::default()); + } + // The PPT's records damaged in a sound compound file, which damage to + // the file around them mostly hides. + let ppt = fixture("sample.ppt"); + let stream = Compound::open(&ppt) + .unwrap() + .stream(&["PowerPoint Document"]) + .unwrap(); + let cuts = (1..16).map(|k| stream.len() * k / 16); + for s in cuts + .map(|cut| stream[..cut].to_vec()) + .chain((0..8).map(|k| { + let mut s = stream.clone(); + for i in (k * 13..s.len()).step_by(211) { + s[i] ^= 0xA5; + } + s + })) + { + let _ = convert( + &build(&[("PowerPoint Document", &s)]), + Format::Ppt, + &Limits::default(), + ); + } +} + +/// The memory bound counts what a shape takes in the model, some 7 kB: +/// 20,000 text boxes hold more than 100 MB. +#[test] +fn shapes_are_counted_at_what_they_take() { + let fodp = format!( + r#"{}"#, + "x".repeat(20_000) + ); + let within = |mib: u64| { + let limits = Limits::default().with_max_memory(mib << 20); + convert(fodp.as_bytes(), Format::Fodp, &limits) + }; + assert_eq!(within(64).unwrap_err(), Error::TooLarge("memory")); + assert!(within(512).is_ok()); +} + +#[test] +fn a_tree_of_elements_nothing_reads_is_charged_for() { + let x = format!( + "{}", + "".repeat(100_000) + ); + let small = Limits::default().with_max_memory(8 << 20); + assert_eq!( + convert(x.as_bytes(), Format::Fodp, &small).unwrap_err(), + Error::TooLarge("memory") + ); +} diff --git a/crates/exav-render/tests/office_slides_draw.rs b/crates/exav-render/tests/office_slides_draw.rs new file mode 100644 index 00000000..1f97462f --- /dev/null +++ b/crates/exav-render/tests/office_slides_draw.rs @@ -0,0 +1,143 @@ +//! OpenDocument drawings (ODG, OTG, FODG) as slides: one slide to a page, +//! named as the page is, in the pages' largest size. + +use exav_render::slides::{Error, Format, Limits, convert}; +use std::io::Read; + +fn fixture(name: &str) -> Vec { + let p = format!( + "{}/tests/fixtures/office/draw/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read(&p).unwrap_or_else(|e| panic!("{p}: {e}")) +} + +fn part(pptx: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + let mut s = String::new(); + z.by_name(name).unwrap().read_to_string(&mut s).unwrap(); + s +} + +#[test] +fn each_page_is_a_slide_named_as_the_page_is() { + let c = convert(&fixture("sample.fodg"), Format::Fodg, &Limits::default()).unwrap(); + assert_eq!((c.slides, c.total_slides), (2, 2)); + assert!(part(&c.pptx, "ppt/slides/slide1.xml").contains(r#""#)); + assert!(part(&c.pptx, "ppt/slides/slide2.xml").contains(r#""#)); +} + +#[test] +fn the_presentation_has_the_largest_page_size() { + let c = convert(&fixture("sample.fodg"), Format::Fodg, &Limits::default()).unwrap(); + // 21 by 14.8 cm, the other page being 10 by 10. + assert!(part(&c.pptx, "ppt/presentation.xml").contains(r#"cx="7560000" cy="5328000""#)); +} + +#[test] +fn the_largest_page_is_found_wherever_it_is() { + // The first page now has the small master page, the second the large one. + let doc = String::from_utf8(fixture("sample.fodg")) + .unwrap() + .replace(r#"master-page-name="Large""#, "master-page-name=\"X\"") + .replace(r#"master-page-name="Small""#, r#"master-page-name="Large""#) + .replace("master-page-name=\"X\"", r#"master-page-name="Small""#); + let c = convert(doc.as_bytes(), Format::Fodg, &Limits::default()).unwrap(); + assert!(part(&c.pptx, "ppt/presentation.xml").contains(r#"cx="7560000" cy="5328000""#)); +} + +#[test] +fn a_layer_that_is_not_displayed_leaves_its_shapes_out() { + let c = convert(&fixture("sample.fodg"), Format::Fodg, &Limits::default()).unwrap(); + let one = part(&c.pptx, "ppt/slides/slide1.xml"); + assert!(one.contains("Red box")); + assert!(!one.contains("Hidden note")); + assert!(part(&c.pptx, "ppt/slides/slide2.xml").contains("Detail")); +} + +#[test] +fn the_page_background_and_master_shapes_are_kept() { + let c = convert(&fixture("sample.fodg"), Format::Fodg, &Limits::default()).unwrap(); + assert!(part(&c.pptx, "ppt/slides/slide1.xml").contains(r#""#)); + // The master page of the first drawing holds a frame. + let master = part(&c.pptx, "ppt/slideMasters/slideMaster1.xml"); + assert!(master.contains("")); +} + +#[test] +fn a_package_and_a_flat_file_give_the_same_pages() { + let flat = convert(&fixture("sample.fodg"), Format::Fodg, &Limits::default()).unwrap(); + let zipped = convert(&fixture("sample.odg"), Format::Odg, &Limits::default()).unwrap(); + assert_eq!(zipped.slides, flat.slides); + for n in 1..=2 { + let name = format!("ppt/slides/slide{n}.xml"); + let a = part(&flat.pptx, &name); + let b = part(&zipped.pptx, &name); + let title = |s: &str| { + s.split(""#)); +} + +#[test] +fn a_page_limit_applies_to_drawings_as_to_slides() { + let mut limits = Limits::default(); + limits.max_slides = 1; + assert!(matches!( + convert(&fixture("sample.fodg"), Format::Fodg, &limits), + Err(Error::TooLarge(_)) + )); +} diff --git a/crates/exav-render/tests/office_slides_keynote_xml.rs b/crates/exav-render/tests/office_slides_keynote_xml.rs new file mode 100644 index 00000000..9b02a4ec --- /dev/null +++ b/crates/exav-render/tests/office_slides_keynote_xml.rs @@ -0,0 +1,436 @@ +//! Keynote 1 to 5 documents (XML), over documents written here. + +use exav_render::slides::{Error, Format, Limits, convert, convert_keynote_package}; +use std::io::{Read, Write}; + +fn part(pptx: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + let mut s = String::new(); + z.by_name(name).unwrap().read_to_string(&mut s).unwrap(); + s +} + +fn zip_of(files: &[(&str, &[u8])]) -> Vec { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for (name, data) in files { + z.start_file(*name, opts).unwrap(); + z.write_all(data).unwrap(); + } + z.finish().unwrap().into_inner() +} + +fn gzip(d: &[u8]) -> Vec { + let mut g = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + g.write_all(d).unwrap(); + g.finish().unwrap() +} + +/// The header of a 2 by 2 pixel PNG: enough to be a picture. +const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x02\x08\x02\0\0\0"; + +fn color(r: f64, g: f64, b: f64) -> String { + format!(r#""#) +} + +fn geometry(x: f64, y: f64, w: f64, h: f64) -> String { + format!( + r#""# + ) +} + +/// A text frame holding one paragraph per entry: its style and its text. +fn frame(paras: &[(&str, &str)]) -> String { + let ps: String = paras + .iter() + .map(|(style, text)| format!(r#"{text}"#)) + .collect(); + format!( + r#"{ps}"# + ) +} + +const NS: &str = r#"xmlns:key="http://developer.apple.com/namespaces/keynote2" xmlns:sf="http://developer.apple.com/namespaces/sf" xmlns:sfa="http://developer.apple.com/namespaces/sfa" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance""#; + +/// A Keynote 2 to 5 document of two slides: a master with a shape of its +/// own, a title placeholder and a body, styles that inherit one another, +/// and on the first slide a shape with text, a cropped picture and a movie. +fn document() -> String { + let green = color(0.0, 1.0, 0.0); + let blue = color(0.0, 0.0, 1.0); + let red = color(1.0, 0.0, 0.0); + let grey = color(0.5, 0.5, 0.5); + let white = color(1.0, 1.0, 1.0); + let title = frame(&[("child", "Slide title")]); + let body_frame = frame(&[("child", "Body text")]); + let hello = frame(&[ + ( + "child", + "Helloagainspanned", + ), + ("plain", "Plain"), + ]); + let pos = geometry(100.0, 150.0, 300.0, 80.0); + let stub = r#""#; + let ph_geom = geometry(40.0, 20.0, 720.0, 90.0); + let frame_geom = geometry(300.0, 300.0, 200.0, 100.0); + let window = r#""#; + format!( + r#" + + + + + + + + {red} + + + + + + + + + + + + + {green} + {blue} + + {ph_geom} + + + + + + {grey} + + + + + {pos} + {stub}{title} + + + {geom} + + + + + + + + {white} + + + + + {title} + {body_frame} + + + {stub} + {pos}{hello} + {frame_geom}{window} + {frame_geom2} + + + + + {second} + + {stub} + + + +"#, + geom = geometry(0.0, 0.0, 10.0, 10.0), + frame_geom2 = geometry(500.0, 300.0, 100.0, 60.0), + second = frame(&[("child", "Second slide")]), + ) +} + +/// The properties of the run holding `text`, and the run itself up to it. +fn run_before<'a>(x: &'a str, text: &str) -> &'a str { + let to = &x[..x.find(text).unwrap_or_else(|| panic!("{text} in {x}"))]; + &to[to.rfind("").unwrap()..] +} + +/// The shape that holds `text`. +fn shape_of<'a>(x: &'a str, text: &str) -> &'a str { + let to = &x[..x.find(text).unwrap_or_else(|| panic!("{text} in {x}"))]; + &to[to.rfind("").unwrap()..] +} + +fn slide(pptx: &[u8], n: usize) -> String { + part(pptx, &format!("ppt/slides/slide{n}.xml")) +} + +#[test] +fn styles_inherit_and_shapes_have_their_fill_line_and_text() { + let c = convert( + &zip_of(&[("index.apxl", document().as_bytes()), ("Data/pic.png", PNG)]), + Format::Keynote, + &Limits::default(), + ) + .unwrap(); + assert_eq!((c.slides, c.total_slides), (2, 2)); + let x = slide(&c.pptx, 1); + // Slide size in points. + assert!(part(&c.pptx, "ppt/presentation.xml").contains(r#"cx="10160000" cy="7620000""#)); + // The shape: green fill, a blue 2 point round line, its path. + assert!(x.contains(r#""#), "{x}"); + assert!( + x.contains(r#"w="25400""#) && x.contains(r#""#), + "{x}" + ); + assert!( + x.contains(r#""#), + "{x}" + ); + assert!(x.contains(""), "{x}"); + // The text: the child style's 20 points over the parent's bold, red, + // centred text; a span of the parent's 30 points. + assert!(x.contains(">Hello<") && x.contains(">againspanned<"), "{x}"); + // A break inside a paragraph is a line break; the last one ends it. + assert_eq!(x.matches("").count(), 1, "{x}"); + // `sf:null` in a paragraph style unsets what its parents give: black. + // In a character style it sets nothing: the span keeps the paragraph's + // look and so joins its run (">againspanned<" above). + assert!(run_before(&x, ">Plain<").contains(r#""#)); + assert!( + x.contains(r#"sz="2000""#) && x.contains(r#"sz="3000""#), + "{x}" + ); + assert!( + x.contains(r#"b="1""#) && x.contains(r#""#), + "{x}" + ); + assert!(x.contains(r#"algn="ctr""#), "{x}"); + // The title is in the slide's headlines, placed by its placeholder's + // style; the body has no placeholder in the slide's layer: hidden. + assert!(x.contains(">title<") && x.contains(">Slide <"), "{x}"); + assert!( + x.contains(r#""#), + "{x}" + ); + assert!(!x.contains("Body text"), "{x}"); + // The slide's own background; the second slide has the master's. + assert!( + x.contains(r#""#), + "{x}" + ); + let x2 = slide(&c.pptx, 2); + assert!(!x2.contains(""), "{x2}"); + assert!(x2.contains("Second slide"), "{x2}"); + let m = part(&c.pptx, "ppt/slideMasters/slideMaster1.xml"); + assert!( + m.contains(r#""#), + "{m}" + ); +} + +#[test] +fn masters_show_their_objects_and_not_their_prototypes() { + let c = convert( + &zip_of(&[("index.apxl", document().as_bytes()), ("Data/pic.png", PNG)]), + Format::Keynote, + &Limits::default(), + ) + .unwrap(); + let m = part(&c.pptx, "ppt/slideMasters/slideMaster1.xml"); + // The master's shape is there once, as is its green; the prototype + // layer's 10 by 10 point shape (127000 EMU) is not, nor is its + // placeholder's text. + assert_eq!(m.matches("").count(), 1, "{m}"); + assert!(!m.contains(r#"cx="127000""#), "{m}"); + assert!(!m.contains("Slide "), "{m}"); +} + +#[test] +fn pictures_are_cropped_and_movies_are_boxes() { + let c = convert( + &zip_of(&[("index.apxl", document().as_bytes()), ("Data/pic.png", PNG)]), + Format::Keynote, + &Limits::default(), + ) + .unwrap(); + let x = slide(&c.pptx, 1); + // The window is the middle half of the frame each way. + assert!( + x.contains(r#""#), + "{x}" + ); + assert!( + x.contains(r#""#), + "{x}" + ); + // The movie: a dark box with a triangle. + assert!(x.contains(r#"prst="triangle""#), "{x}"); + assert!( + c.warnings.iter().any(|w| w.contains("videos and sounds")), + "{:?}", + c.warnings + ); + // Without the picture's file the shape is left out. + let c = convert( + &zip_of(&[("index.apxl", document().as_bytes())]), + Format::Keynote, + &Limits::default(), + ) + .unwrap(); + assert!(!slide(&c.pptx, 1).contains("srcRect")); +} + +#[test] +fn every_way_a_package_holds_the_xml() { + let doc = document(); + let want = |pptx: &[u8]| { + let x = slide(pptx, 1); + assert!(x.contains(">Hello<") && x.contains("srcRect"), "{x}"); + }; + let gz = gzip(doc.as_bytes()); + for files in [ + vec![("index.apxl", doc.as_bytes()), ("Data/pic.png", PNG)], + vec![("index.apxl.gz", &gz[..]), ("Data/pic.png", PNG)], + // In a package directory, one level down. + vec![ + ("Doc.key/index.apxl", doc.as_bytes()), + ("Doc.key/Data/pic.png", PNG), + ], + ] { + let c = convert(&zip_of(&files), Format::Keynote, &Limits::default()).unwrap(); + want(&c.pptx); + } + // As a directory's files. + let c = convert_keynote_package( + &[("index.apxl.gz", &gz[..]), ("Data/pic.png", PNG)], + &Limits::default(), + ) + .unwrap(); + want(&c.pptx); + // As the XML alone, plain or gzipped: no pictures. + let c = convert(doc.as_bytes(), Format::Keynote, &Limits::default()).unwrap(); + assert!(slide(&c.pptx, 1).contains(">Hello<")); + let c = convert(&gz, Format::Keynote, &Limits::default()).unwrap(); + assert!(slide(&c.pptx, 1).contains(">Hello<")); +} + +#[test] +fn headlines_are_the_text_of_slides_of_keynote_2_and_3() { + // The same document without the placeholder's own text: the slide's + // headlines give it, each at its depth, the title first. + let c = convert(document().as_bytes(), Format::Keynote, &Limits::default()).unwrap(); + assert!(slide(&c.pptx, 1).contains("Slide ")); + let no_headlines = document() + .replace("", ""); + let c = convert(no_headlines.as_bytes(), Format::Keynote, &Limits::default()).unwrap(); + assert!(!slide(&c.pptx, 1).contains("Slide ")); +} + +/// A Keynote 1 document: the theme's slide size and prototypes, one master +/// with title and body placeholders and a slide with a title, a bullet, a +/// text box and a rotated shape. +fn keynote1() -> String { + r#" + + + + + + + + + + + + <body size="700 400" location="50 130" bullet-indentation="0 30"/> + </drawables> + <prototype-bullets> + <bullet level="1" marker-type="character"><character-bullet-style size="1"><bullet-characters font-size="20">*</bullet-characters></character-bullet-style><content font-size="20"/></bullet> + </prototype-bullets> + </master-slide> + </master-slides> + </theme> + <slide-list> + <slide master-slide-id="m1"> + <bullets> + <bullet level="0"><content font-size="40" font-color="1 0 0">Title one</content></bullet> + <bullet level="1" marker-type="inherited"><content>First point</content></bullet> + </bullets> + <drawables> + <title/> + <body/> + <textbox size="200 50" transformation="1 0 0 1 100 500"><content>Free <span font-size="30">text</span></content></textbox> + <shape path="M 0 0 L 100 0 L 100 50 Z" transformation="0 1 -1 0 300 300"/> + </drawables> + </slide> + <slide master-slide-id="m1"> + <bullets><bullet level="0"><content>Second title</content></bullet></bullets> + <drawables><title visibility="hidden"/></drawables> + </slide> + </slide-list> +</presentation>"# + .to_string() +} + +#[test] +fn keynote_1_documents() { + let c = convert(keynote1().as_bytes(), Format::Keynote, &Limits::default()).unwrap(); + assert_eq!((c.slides, c.total_slides), (2, 2)); + let x = slide(&c.pptx, 1); + assert!(part(&c.pptx, "ppt/presentation.xml").contains(r#"cx="10160000" cy="7620000""#)); + // The title and the bullet fill the master's placeholders. + assert!(x.contains("Title one") && x.contains("First point"), "{x}"); + assert!(shape_of(&x, "Title one").contains(r#"<p:ph type="title"/>"#)); + assert!(shape_of(&x, "First point").contains(r#"<p:ph type="body""#)); + // A placeholder the slide hides shows nothing. + assert!(!slide(&c.pptx, 2).contains("Second title")); + assert!( + x.contains(r#"sz="4000""#) && x.contains(r#"<a:srgbClr val="FF0000"/>"#), + "{x}" + ); + assert!(x.contains(r#"<a:buChar char="*""#), "{x}"); + assert!(x.contains(r#"anchor="ctr""#), "{x}"); + // The text box takes its prototype's size and a span's own. + assert!(x.contains(">Free <") && x.contains(">text<"), "{x}"); + assert!( + x.contains(r#"sz="1800""#) && x.contains(r#"sz="3000""#), + "{x}" + ); + // The shape takes its prototype's green fill and 3 point blue line, and + // is turned a quarter. + assert!(x.contains(r#"<a:srgbClr val="00FF00"/>"#), "{x}"); + assert!(x.contains(r#"w="38100""#), "{x}"); + assert!(x.contains(r#"rot="5400000""#), "{x}"); + let m = part(&c.pptx, "ppt/slideMasters/slideMaster1.xml"); + assert!(m.contains(r#"<a:srgbClr val="808080"/>"#), "{m}"); +} + +#[test] +fn xml_that_is_not_a_keynote_document_is_refused() { + for xml in [ + "<other/>", + "<key:presentation xmlns:key=\"http://developer.apple.com/namespaces/keynote2\"/>", + "<presentation/>", + ] { + let e = convert(xml.as_bytes(), Format::Keynote, &Limits::default()).unwrap_err(); + assert!(matches!(e, Error::NotFormat(_)), "{xml}: {e:?}"); + } +} + +#[test] +fn a_damaged_document_does_not_panic() { + let doc = document(); + for n in (0..doc.len()).step_by(97) { + let _ = convert(&doc.as_bytes()[..n], Format::Keynote, &Limits::default()); + } + let one = keynote1(); + for n in (0..one.len()).step_by(31) { + let _ = convert(&one.as_bytes()[..n], Format::Keynote, &Limits::default()); + } +} diff --git a/crates/exav-render/tests/office_slides_odp_features.rs b/crates/exav-render/tests/office_slides_odp_features.rs new file mode 100644 index 00000000..1c271c05 --- /dev/null +++ b/crates/exav-render/tests/office_slides_odp_features.rs @@ -0,0 +1,267 @@ +//! OpenDocument features, over flat documents written here. + +use exav_render::slides::{Format, Limits, convert}; +use std::io::Read; + +fn part(pptx: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + let mut s = String::new(); + z.by_name(name).unwrap().read_to_string(&mut s).unwrap(); + s +} + +fn names(pptx: &[u8]) -> Vec<String> { + let z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + z.file_names() + .filter_map(|n| n.ok().map(|n| n.to_string())) + .collect() +} + +const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; + +/// A flat presentation with `styles` in its common styles, `auto` in its +/// automatic styles and one page holding `page`. +fn fodp(styles: &str, auto: &str, page: &str) -> String { + format!( + r#"<office:document {NS} office:mimetype="application/vnd.oasis.opendocument.presentation"><office:styles>{styles}</office:styles><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:presentation><draw:page>{page}</draw:page></office:presentation></office:body></office:document>"# + ) +} + +fn slide1(doc: &str) -> String { + let c = convert(doc.as_bytes(), Format::Fodp, &Limits::default()).unwrap(); + part(&c.pptx, "ppt/slides/slide1.xml") +} + +/// A custom shape of 10 by 10 cm with an enhanced path in a 100 by 100 +/// view box. +fn custom(path: &str, extra: &str) -> String { + format!( + r#"<draw:custom-shape svg:x="0cm" svg:y="0cm" svg:width="10cm" svg:height="10cm"><text:p>T</text:p><draw:enhanced-geometry svg:viewBox="0 0 100 100" draw:enhanced-path="{path}" {extra}/></draw:custom-shape>"# + ) +} + +fn base64(d: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::new(); + for c in d.chunks(3) { + let n = c + .iter() + .enumerate() + .fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for k in 0..=c.len() { + s.push(A[(n >> (18 - 6 * k) & 63) as usize] as char); + } + } + while !s.len().is_multiple_of(4) { + s.push('='); + } + s +} + +/// The header of a 2 by 2 pixel PNG: enough to be a picture. +const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x02\x08\x02\0\0\0"; + +#[test] +fn the_page_has_the_size_odf_defaults_to() { + // No page layout: 28 by 15.75 cm. + let c = convert( + fodp("", "", "").as_bytes(), + Format::Fodp, + &Limits::default(), + ) + .unwrap(); + assert!(part(&c.pptx, "ppt/presentation.xml").contains(r#"cx="10080000" cy="5670000""#)); +} + +#[test] +fn shapes_without_a_style_have_the_style_called_standard() { + let rect = r#"<draw:rect svg:x="0cm" svg:y="0cm" svg:width="2cm" svg:height="2cm"/>"#; + // Without it: filled blue. + let x = slide1(&fodp("", "", rect)); + assert!(x.contains(r#"<a:srgbClr val="729FCF"/>"#), "{x}"); + // With it: what it says, here nothing to draw. + let std = r#"<style:style style:name="standard" style:family="graphic"><style:graphic-properties draw:fill="none" draw:stroke="none"/></style:style>"#; + let x = slide1(&fodp(std, "", rect)); + assert!( + !x.contains("729FCF") && x.contains("<a:noFill/><a:ln><a:noFill/></a:ln>"), + "{x}" + ); +} + +#[test] +fn empty_placeholders_show_only_what_they_fill_and_draw() { + let auto = r##"<style:style style:name="red" style:family="graphic"><style:graphic-properties draw:fill="solid" draw:fill-color="#ff0000" draw:stroke="none"/></style:style><style:style style:name="none" style:family="graphic"><style:graphic-properties draw:fill="none" draw:stroke="none"/></style:style>"##; + let frame = |style: &str, x: u32| { + format!( + r#"<draw:frame draw:style-name="{style}" presentation:class="outline" presentation:placeholder="true" svg:x="{x}cm" svg:y="0cm" svg:width="4cm" svg:height="2cm"><draw:text-box/></draw:frame>"# + ) + }; + let x = slide1(&fodp("", auto, &(frame("red", 0) + &frame("none", 5)))); + assert_eq!(x.matches("<p:sp>").count(), 1, "{x}"); + assert!(x.contains(r#"<a:srgbClr val="FF0000"/>"#), "{x}"); +} + +/// The paragraph of `x` that holds `text`, up to the text. +fn paragraph<'a>(x: &'a str, text: &str) -> &'a str { + let to = x.find(text).unwrap_or_else(|| panic!("{text} in {x}")); + &x[x[..to].rfind("<a:p>").unwrap()..to] +} + +#[test] +fn list_headers_are_not_bulleted_and_items_are() { + let auto = r#"<text:list-style style:name="L1"><text:list-level-style-bullet text:level="1" text:bullet-char="*"/></text:list-style>"#; + let page = r#"<draw:frame svg:x="0cm" svg:y="0cm" svg:width="8cm" svg:height="4cm"><draw:text-box><text:list text:style-name="L1"><text:list-header><text:p>Head</text:p></text:list-header><text:list-item><text:p>Item</text:p></text:list-item></text:list></draw:text-box></draw:frame>"#; + let x = slide1(&fodp("", auto, page)); + assert!(!paragraph(&x, ">Head<").contains("buChar"), "{x}"); + assert!( + paragraph(&x, ">Item<").contains(r#"<a:buChar char="*""#), + "{x}" + ); +} + +#[test] +fn picture_bullets_are_pictures() { + let auto = format!( + r#"<text:list-style style:name="L1"><text:list-level-style-image text:level="1"><office:binary-data>{}</office:binary-data></text:list-level-style-image></text:list-style>"#, + base64(PNG) + ); + let page = r#"<draw:frame svg:x="0cm" svg:y="0cm" svg:width="8cm" svg:height="4cm"><draw:text-box><text:list text:style-name="L1"><text:list-item><text:p>Item</text:p></text:list-item></text:list></draw:text-box></draw:frame>"#; + let c = convert( + fodp("", &auto, page).as_bytes(), + Format::Fodp, + &Limits::default(), + ) + .unwrap(); + let x = part(&c.pptx, "ppt/slides/slide1.xml"); + assert!(x.contains("<a:buBlip>"), "{x}"); + assert!(names(&c.pptx).iter().any(|n| n == "ppt/media/image1.png")); + // A picture that cannot be read is a round bullet. + let broken = auto.replace(&base64(PNG), "AAAA"); + let x = slide1(&fodp("", &broken, page)); + assert!(!x.contains("buBlip") && x.contains("<a:buChar"), "{x}"); +} + +#[test] +fn custom_shapes_follow_their_enhanced_paths() { + // Even-odd: the inner square is drawn against the outer one's + // direction, so that it is a hole under the nonzero rule. + let x = slide1(&fodp( + "", + "", + &custom( + "M 0 0 L 100 0 100 100 0 100 Z M 20 20 L 80 20 80 80 20 80 Z N", + "", + ), + )); + assert!( + x.contains(r#"<a:close/><a:moveTo><a:pt x="20000" y="80000"/>"#), + "{x}" + ); + // A path that does not nest stays as it is. + let x = slide1(&fodp( + "", + "", + &custom( + "M 0 0 L 40 0 40 40 0 40 Z M 60 60 L 100 60 100 100 60 100 Z N", + "", + ), + )); + assert!( + x.contains(r#"<a:close/><a:moveTo><a:pt x="60000" y="60000"/>"#), + "{x}" + ); + // A line that comes first starts the path. + let x = slide1(&fodp("", "", &custom("L 10 10 90 10 90 90 Z N", ""))); + assert!( + x.contains(r#"<a:path w="100000" h="100000"><a:moveTo><a:pt x="10000" y="10000"/>"#), + "{x}" + ); + // An arc that ends before it starts turns the long way round: from the + // bottom of the circle clockwise to its right, three quarters. + let x = slide1(&fodp("", "", &custom("M 50 100 T 50 50 50 50 90 0 N", ""))); + assert_eq!(x.matches("<a:cubicBezTo>").count(), 3, "{x}"); + assert!( + x.contains(r#"<a:pt x="100000" y="50000"/></a:cubicBezTo>"#), + "{x}" + ); + // An arc: from the right of the circle a quarter turn to its bottom. + let x = slide1(&fodp("", "", &custom("M 100 50 T 50 50 50 50 0 90 N", ""))); + assert!( + x.contains(r#"<a:cubicBezTo><a:pt x="100000" y="77614"/><a:pt x="77614" y="100000"/><a:pt x="50000" y="100000"/></a:cubicBezTo>"#), + "{x}" + ); +} + +#[test] +fn paths_with_no_fill_or_no_stroke_become_shapes_of_their_own() { + let x = slide1(&fodp( + "", + "", + &custom("M 0 0 L 100 0 100 100 Z F N M 0 0 L 100 100 S N", ""), + )); + assert_eq!(x.matches("<p:sp>").count(), 2, "{x}"); + // The first is stroked and not filled, the second filled and not stroked. + let (a, b) = x.split_once("</p:sp>").unwrap(); + assert!(a.contains("<a:noFill/><a:ln w=\"9525\">"), "{a}"); + assert!( + b.contains("<a:ln><a:noFill/></a:ln>") && b.contains("729FCF"), + "{b}" + ); +} + +#[test] +fn text_stays_in_the_shapes_text_area() { + let x = slide1(&fodp( + "", + "", + &custom( + "M 0 0 L 100 0 100 100 0 100 Z N", + r#"draw:text-areas="10 20 70 90""#, + ), + )); + // 10, 20, 30 and 10 percent of the 10 cm (3600000 EMU) box. + assert!( + x.contains(r#"lIns="360000" tIns="720000" rIns="1080000" bIns="360000""#), + "{x}" + ); +} + +#[test] +fn videos_and_sounds_are_boxes() { + let plugin = |href: &str| { + format!( + r#"<draw:frame svg:x="0cm" svg:y="0cm" svg:width="8cm" svg:height="6cm"><draw:plugin xlink:href="{href}"/></draw:frame>"# + ) + }; + let c = convert( + fodp("", "", &plugin("movie.mp4")).as_bytes(), + Format::Fodp, + &Limits::default(), + ) + .unwrap(); + let x = part(&c.pptx, "ppt/slides/slide1.xml"); + assert!( + x.contains(r#"<a:srgbClr val="202020"/>"#) && x.contains("triangle"), + "{x}" + ); + assert!(c.warnings.iter().any(|w| w.contains("videos and sounds"))); + let x = slide1(&fodp("", "", &plugin("song.mp3"))); + assert!(x.contains(r#"<a:srgbClr val="E8E8E8"/>"#), "{x}"); +} + +#[test] +fn a_link_after_text_of_the_same_style_keeps_its_link() { + let rect = r#"<draw:rect svg:x="0cm" svg:y="0cm" svg:width="8cm" svg:height="2cm"><text:p>go <text:a xlink:href="https://example.test/x">here</text:a> now</text:p></draw:rect>"#; + let c = convert( + fodp("", "", rect).as_bytes(), + Format::Fodp, + &Limits::default(), + ) + .unwrap(); + let x = part(&c.pptx, "ppt/slides/slide1.xml"); + // Three runs, the middle one a link to the address. + assert_eq!(x.matches("<a:hlinkClick").count(), 1, "{x}"); + assert!(part(&c.pptx, "ppt/slides/_rels/slide1.xml.rels").contains("https://example.test/x")); + let i = x.find("<a:hlinkClick").unwrap(); + assert!(x[i..].contains(">here</a:t>"), "{x}"); +} diff --git a/crates/exav-render/tests/office_slides_old_ppt.rs b/crates/exav-render/tests/office_slides_old_ppt.rs new file mode 100644 index 00000000..afe92ece --- /dev/null +++ b/crates/exav-render/tests/office_slides_old_ppt.rs @@ -0,0 +1,289 @@ +//! PowerPoint 95 and the older versions, over documents built here: a +//! PowerPoint 95 stream in a compound file, and the headers of versions 2 +//! to 4. + +use exav_render::formats::office::test_util::build_cfb as build; +use exav_render::slides::{Error, Format, Limits, convert}; +use std::io::Read; + +fn part(pptx: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + let mut s = String::new(); + z.by_name(name).unwrap().read_to_string(&mut s).unwrap(); + s +} + +/// A PowerPoint 95 record: type, flag (0xFFFF for a container), length and +/// identifier, then the body. +fn rec(typ: u32, flag: u32, id: u32, body: &[u8]) -> Vec<u8> { + let mut v = vec![]; + for x in [typ, flag, body.len() as u32, id] { + v.extend(x.to_le_bytes()); + } + v.extend(body); + v +} + +fn container(typ: u32, kids: &[Vec<u8>]) -> Vec<u8> { + rec(typ, 0xFFFF, 0, &kids.concat()) +} + +fn i32s(v: &[i32]) -> Vec<u8> { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// A colour as the format has it: red, green, blue and 0xFE for "this RGB". +const fn rgb(r: u8, g: u8, b: u8) -> [u8; 4] { + [r, g, b, 0xFE] +} + +/// A shape's look (0x0BBD, 56 bytes): the line and the fill. +fn look(line: Option<([u8; 4], u8)>, fill: Option<[u8; 4]>) -> Vec<u8> { + let mut d = vec![0u8; 56]; + match line { + Some((c, width)) => { + d[3] = width; + d[4..8].copy_from_slice(&c); + } + None => d[0] = 0xFF, + } + match fill { + Some(c) => { + d[13] = 1; + d[20..24].copy_from_slice(&c); + } + None => d[12] = 0xFF, + } + d +} + +/// A shape's bounds (0x0BC1, 40 bytes), the origin at the slide's centre. +fn bounds(code: i32, l: i32, t: i32, r: i32, b: i32, rot: i32) -> Vec<u8> { + i32s(&[code, 0, 0, l, t, r, b, rot, 0, 0]) +} + +fn shape(b: Vec<u8>, look: Vec<u8>, extra: &[Vec<u8>]) -> Vec<u8> { + let mut kids = vec![rec(0x0BC1, 0, 0, &b), rec(0x0BBD, 0, 0, &look)]; + kids.extend_from_slice(extra); + container(0x0BC0, &kids) +} + +/// A run: characters it covers, its kind, and its properties. +fn run(count: u32, typ: u32, props: &[u8]) -> Vec<u8> { + let mut v = vec![]; + for x in [count, typ, 0, props.len() as u32, 0] { + v.extend(x.to_le_bytes()); + } + v.extend(props); + v +} + +/// Character properties: font index, size, flags, colour. +fn char_props(font: u16, size: u16, flags: u16, color: [u8; 4]) -> Vec<u8> { + let mut p = vec![0u8; 24]; + p[0..2].copy_from_slice(&font.to_le_bytes()); + p[4..6].copy_from_slice(&size.to_le_bytes()); + p[8..10].copy_from_slice(&flags.to_le_bytes()); + p[10..14].copy_from_slice(&color); + p +} + +/// Paragraph properties: bullet flags, bullet character, alignment and the +/// bullet's size in percent. +fn para_props(bullet: bool, ch: u8, align: u32, size: u32) -> Vec<u8> { + let mut p = vec![0u8; 44]; + p[0] = u8::from(bullet); + p[6] = ch; + p[12..16].copy_from_slice(&size.to_le_bytes()); + p[28..32].copy_from_slice(&align.to_le_bytes()); + p +} + +/// A text object: the characters (in Windows 1252) and runs of each kind. +fn text(chars: &[u8], char_runs: &[Vec<u8>], para_runs: &[Vec<u8>]) -> Vec<u8> { + let runs = + |typ: u32, rs: &[Vec<u8>]| rec(0x07EC, 0xFFFF, 0, &rec(0x07EE, 0, typ, &rs.concat())); + let body = container( + 0x0FE0, + &[ + rec(0x07EE, 0, 53, chars), + runs(0x0FE2, char_runs), + runs(0x0FE3, para_runs), + ], + ); + container(0x0FA1, &[container(0x0FA2, &[body])]) +} + +/// A PowerPoint 95 stream of one slide holding `shapes` on a 10 by 7.5 +/// inch page, and one font, "Arial". +fn stream(shapes: &[Vec<u8>]) -> Vec<u8> { + let atom = rec(0x03E9, 0, 0, &i32s(&[5760, 4320])); + let mut font = vec![0u8; 60]; + font[28..33].copy_from_slice(b"Arial"); + let env = container(0x03F2, &[rec(0x0FB7, 0, 0, &font)]); + let slide = container(0x03EE, &[container(0x0BB8, shapes)]); + let doc = container(0x03E8, &[atom, env, container(0x07D0, &[slide])]); + container(3, &[doc]) +} + +fn convert95(stream: &[u8]) -> Result<exav_render::slides::Converted, Error> { + let cf = build(&[("PowerPoint Document", stream)]); + convert(&cf, Format::Ppt, &Limits::default()) +} + +fn slide1(stream: &[u8]) -> String { + let c = convert95(stream).unwrap(); + assert_eq!((c.slides, c.total_slides), (1, 1)); + part(&c.pptx, "ppt/slides/slide1.xml") +} + +#[test] +fn shapes_have_their_geometry_fill_and_line() { + let s = stream(&[ + // A rectangle 1 inch wide and 0.5 high at the page's centre, filled + // red, with a blue line. + shape( + bounds(-3, 0, 0, 576, 288, 0), + look(Some((rgb(0, 0, 255), 0)), Some(rgb(255, 0, 0))), + &[], + ), + // An ellipse without a line, filled green, rotated. + shape( + bounds(-1, -576, -288, -288, 0, 1344), + look(None, Some(rgb(0, 255, 0))), + &[], + ), + // A shape without a line or a fill is not drawn. + shape(bounds(-3, 0, 0, 10, 10, 0), look(None, None), &[]), + ]); + let x = slide1(&s); + assert_eq!(x.matches("<p:sp>").count(), 2, "{x}"); + assert!(x.contains(r#"<a:srgbClr val="FF0000"/>"#), "{x}"); + assert!(x.contains(r#"<a:srgbClr val="0000FF"/>"#), "{x}"); + assert!(x.contains(r#"<a:srgbClr val="00FF00"/>"#), "{x}"); + assert!(x.contains(r#"prst="ellipse""#), "{x}"); + // The centre of a 10 by 7.5 inch page is at 5 by 3.75 inches; the + // rectangle is 1 by 0.5 inch from there. + assert!( + x.contains(r#"<a:off x="4572000" y="3429000"/><a:ext cx="914400" cy="457200"/>"#), + "{x}" + ); + // 1344 of 5376 is a quarter turn. + assert!(x.contains(r#"rot="5400000""#), "{x}"); +} + +#[test] +fn lines_have_arrows() { + let l = container( + 0x0BC6, + &[ + rec(0x0BC7, 0, 0, &i32s(&[-288, -144, 288, 144])), + rec(0x0BBD, 0, 0, &look(Some((rgb(0, 0, 0), 1)), None)), + rec(0x0BBF, 0, 0, &[0, 1, 0, 0]), + ], + ); + let x = slide1(&stream(&[l])); + assert!(x.contains("<a:tailEnd type=\"triangle\""), "{x}"); + assert!(!x.contains("<a:headEnd type"), "{x}"); + // 2 points wide. + assert!(x.contains(r#"w="25400""#), "{x}"); +} + +#[test] +fn text_has_its_runs_paragraphs_and_fonts() { + let chars = b"Hello\rWorld caf\xe9"; + let t = text( + chars, + &[ + run(5, 0x0FE2, &char_props(0, 24, 1, rgb(255, 0, 0))), + run(12, 0x0FE2, &char_props(0, 12, 2, rgb(0, 0, 0))), + ], + &[ + run(6, 0x0FE3, ¶_props(false, 0, 1, 0)), + run(11, 0x0FE3, ¶_props(true, b'*', 0, 80)), + ], + ); + let x = slide1(&stream(&[shape( + bounds(-3, -576, -288, 576, 288, 0), + look(None, Some(rgb(255, 255, 255))), + &[t], + )])); + assert!(x.contains(">Hello<"), "{x}"); + assert!(x.contains(">World caf\u{e9}<"), "{x}"); + assert!(x.contains(r#"sz="2400""#) && x.contains(r#"b="1""#), "{x}"); + assert!(x.contains(r#"sz="1200""#) && x.contains(r#"i="1""#), "{x}"); + assert!(x.contains(r#"typeface="Arial""#), "{x}"); + assert!(x.contains(r#"algn="ctr""#), "{x}"); + assert!(x.contains(r#"<a:buChar char="*""#), "{x}"); + assert!(x.contains(r#"<a:buSzPct val="80000"/>"#), "{x}"); +} + +/// The bullet's size, a percent, is kept from 25 to 400: past it there is none, +/// and a size too big to take a thousand times is not an overflow. +#[test] +fn a_bullet_size_out_of_range_is_dropped() { + let slide = |size: u32| { + let t = text( + b"abc", + &[run(3, 0x0FE2, &char_props(0, 12, 0, rgb(0, 0, 0)))], + &[run(3, 0x0FE3, ¶_props(true, b'*', 0, size))], + ); + slide1(&stream(&[shape( + bounds(-3, -576, -288, 576, 288, 0), + look(None, Some(rgb(255, 255, 255))), + &[t], + )])) + }; + assert!(slide(400).contains(r#"<a:buSzPct val="400000"/>"#)); + for size in [24, 401, 4_294_968, u32::MAX] { + let x = slide(size); + assert!(x.contains("<a:buChar"), "{size}: {x}"); + assert!(!x.contains("buSzPct"), "{size}: {x}"); + } +} + +#[test] +fn a_damaged_stream_does_not_panic() { + let t = text( + b"abc", + &[run(3, 0x0FE2, &char_props(0, 12, 0, rgb(0, 0, 0)))], + &[run(3, 0x0FE3, ¶_props(false, 0, 0, 0))], + ); + let s = stream(&[shape( + bounds(-3, -576, -288, 576, 288, 0), + look(Some((rgb(0, 0, 0), 0)), Some(rgb(1, 2, 3))), + &[t], + )]); + for n in 0..s.len() { + let _ = convert95(&s[..n]); + } + let mut s = s; + for i in (0..s.len()).step_by(7) { + let keep = s[i]; + s[i] = 0xFF; + let _ = convert95(&s); + s[i] = keep; + } +} + +#[test] +fn old_versions_are_recognised_and_refused_as_unsupported() { + let le = |v: u8| [0xED, 0xDE, 0xAD, 0x0B, v, 0, 0, 0, 1, 2, 3, 4]; + for (bytes, what) in [ + (le(2).to_vec(), "PowerPoint 2"), + (le(3).to_vec(), "PowerPoint 3"), + ( + vec![0x0B, 0xAD, 0xDE, 0xED, 0, 0, 0, 3, 1, 2, 3, 4], + "PowerPoint 3 for Macintosh", + ), + // Version 4 is a stream of a compound file. + (build(&[("PP40", &le(4))]), "PowerPoint 4"), + ] { + let e = convert(&bytes, Format::Ppt, &Limits::default()).unwrap_err(); + assert_eq!(e.kind(), "unsupported", "{what}"); + assert!(e.to_string().contains(what), "{what}: {e}"); + } + // A version they never had. + let e = convert(&le(9), Format::Ppt, &Limits::default()).unwrap_err(); + assert_eq!(e.kind(), "not-format"); +} diff --git a/crates/exav-render/tests/office_slides_vsdx.rs b/crates/exav-render/tests/office_slides_vsdx.rs new file mode 100644 index 00000000..afd7f1ed --- /dev/null +++ b/crates/exav-render/tests/office_slides_vsdx.rs @@ -0,0 +1,833 @@ +//! Visio drawings (VSDX) as slides: one slide to a page, named as the page +//! is, in the pages' largest size; shapes with their geometry, formats and +//! text; masters, styles and themes resolved. + +use exav_render::formats::office::test_util::{PackageWriter, build_cfb}; +use exav_render::slides::{Error, Format, Limits, convert, convert_first, convert_first_from}; +use std::io::{Cursor, Read}; + +const NS: &str = "xmlns='http://schemas.microsoft.com/office/visio/2012/main' xmlns:r='http://schemas.openxmlformats.org/officeDocument/2006/relationships'"; +const REL: &str = "http://schemas.microsoft.com/visio/2010/relationships"; +const OPC: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; + +fn fixture(name: &str) -> Vec<u8> { + let p = format!( + "{}/tests/fixtures/office/draw/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read(&p).unwrap_or_else(|e| panic!("{p}: {e}")) +} + +fn part(pptx: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(Cursor::new(pptx)).unwrap(); + let mut s = String::new(); + z.by_name(name) + .unwrap_or_else(|_| panic!("no part {name}")) + .read_to_string(&mut s) + .unwrap(); + s +} + +fn has_part(pptx: &[u8], name: &str) -> bool { + zip::ZipArchive::new(Cursor::new(pptx)) + .unwrap() + .by_name(name) + .is_ok() +} + +fn rels(items: &[(&str, &str, &str)]) -> String { + let body: String = items + .iter() + .map(|(i, t, g)| format!(r#"<Relationship Id="{i}" Type="{t}" Target="{g}"/>"#)) + .collect(); + format!(r#"<Relationships xmlns="{OPC}">{body}</Relationships>"#) +} + +/// A package of `parts`, each deflated. +fn package(parts: &[(&str, String)]) -> Vec<u8> { + let mut w = PackageWriter::new(1 << 24); + for (name, data) in parts { + w.put(name, data.as_bytes(), true).unwrap(); + } + w.finish().unwrap() +} + +fn cell(n: &str, v: &str) -> String { + format!("<Cell N='{n}' V='{v}'/>") +} + +fn rect(w: f64, h: f64) -> String { + format!( + "<Section N='Geometry' IX='0'>\ + <Row T='MoveTo' IX='1'>{}{}</Row><Row T='LineTo' IX='2'>{}{}</Row>\ + <Row T='LineTo' IX='3'>{}{}</Row><Row T='LineTo' IX='4'>{}{}</Row>\ + <Row T='LineTo' IX='5'>{}{}</Row></Section>", + cell("X", "0"), + cell("Y", "0"), + cell("X", &w.to_string()), + cell("Y", "0"), + cell("X", &w.to_string()), + cell("Y", &h.to_string()), + cell("X", "0"), + cell("Y", &h.to_string()), + cell("X", "0"), + cell("Y", "0"), + ) +} + +/// A shape `w` by `h` inches centred on (`x`, `y`) with `extra` cells and +/// children, and a rectangle for its geometry. +fn boxed(id: u32, x: f64, y: f64, w: f64, h: f64, extra: &str) -> String { + format!( + "<Shape ID='{id}' Type='Shape'>{}{}{}{}{}{}{extra}{}</Shape>", + cell("PinX", &x.to_string()), + cell("PinY", &y.to_string()), + cell("Width", &w.to_string()), + cell("Height", &h.to_string()), + cell("LocPinX", &(w / 2.0).to_string()), + cell("LocPinY", &(h / 2.0).to_string()), + rect(w, h) + ) +} + +struct Page<'a> { + name: &'a str, + /// Cells of the page sheet. + sheet: String, + shapes: String, +} + +fn page<'a>(name: &'a str, w: f64, h: f64, shapes: &str) -> Page<'a> { + Page { + name, + sheet: cell("PageWidth", &w.to_string()) + &cell("PageHeight", &h.to_string()), + shapes: shapes.to_string(), + } +} + +/// A drawing of `pages`, `document` being what goes in the VisioDocument. +fn drawing(pages: &[Page], document: &str) -> Vec<u8> { + let mut parts: Vec<(&str, String)> = vec![]; + let names: Vec<String> = (0..pages.len()) + .map(|i| format!("visio/pages/page{}.xml", i + 1)) + .collect(); + let mut page_els = String::new(); + let mut page_rels = vec![]; + let ids: Vec<String> = (0..pages.len()).map(|i| format!("rId{}", i + 1)).collect(); + for (i, p) in pages.iter().enumerate() { + page_els.push_str(&format!( + "<Page ID='{i}' Name='{0}' NameU='{0}'><PageSheet>{1}</PageSheet><Rel r:id='{2}'/></Page>", + p.name, p.sheet, ids[i] + )); + page_rels.push(( + ids[i].as_str(), + format!("{REL}/page"), + format!("page{}.xml", i + 1), + )); + } + parts.push(( + "_rels/.rels", + rels(&[("rId1", &format!("{REL}/document"), "visio/document.xml")]), + )); + parts.push(( + "visio/document.xml", + format!("<VisioDocument {NS}>{document}</VisioDocument>"), + )); + parts.push(( + "visio/_rels/document.xml.rels", + rels(&[("rId2", &format!("{REL}/pages"), "pages/pages.xml")]), + )); + parts.push(( + "visio/pages/pages.xml", + format!("<Pages {NS}>{page_els}</Pages>"), + )); + let pr: Vec<(&str, &str, &str)> = page_rels + .iter() + .map(|(i, t, g)| (*i, t.as_str(), g.as_str())) + .collect(); + parts.push(("visio/pages/_rels/pages.xml.rels", rels(&pr))); + for (i, p) in pages.iter().enumerate() { + parts.push(( + names[i].as_str(), + format!( + "<PageContents {NS}><Shapes>{}</Shapes></PageContents>", + p.shapes + ), + )); + } + package(&parts) +} + +fn one(shapes: &str) -> Vec<u8> { + drawing(&[page("Page-1", 6.0, 4.0, shapes)], "") +} + +fn slide(d: &[u8]) -> String { + let c = convert(d, Format::Vsdx, &Limits::default()).unwrap(); + part(&c.pptx, "ppt/slides/slide1.xml") +} + +#[test] +fn each_page_is_a_slide_named_as_the_page_is() { + let c = convert(&fixture("sample.vsdx"), Format::Vsdx, &Limits::default()).unwrap(); + // The background page is a master, not a slide. + assert_eq!((c.slides, c.total_slides), (2, 2)); + assert!(part(&c.pptx, "ppt/slides/slide1.xml").contains(r#"<p:cSld name="Plan">"#)); + assert!(part(&c.pptx, "ppt/slides/slide2.xml").contains(r#"<p:cSld name="Detail">"#)); +} + +#[test] +fn the_presentation_has_the_largest_page_size() { + let c = convert(&fixture("sample.vsdx"), Format::Vsdx, &Limits::default()).unwrap(); + // 11 by 8.5 inches, the other page being 5.8 by 8.2. + assert!(part(&c.pptx, "ppt/presentation.xml").contains(r#"cx="10058400" cy="7772400""#)); + // The largest side and the other come from different pages. + let d = drawing(&[page("A", 10.0, 2.0, ""), page("B", 3.0, 6.0, "")], ""); + let c = convert(&d, Format::Vsdx, &Limits::default()).unwrap(); + assert!(part(&c.pptx, "ppt/presentation.xml").contains(r#"cx="9144000" cy="5486400""#)); +} + +#[test] +fn a_page_scale_gives_paper_inches_and_a_page_too_big_is_made_to_fit() { + // 12 by 8 inches of drawing to 1 of paper for 2: a 6 by 4 inch slide, + // and the shape's coordinates in paper inches too. + let sheet = cell("PageWidth", "12") + + &cell("PageHeight", "8") + + &cell("PageScale", "1") + + &cell("DrawingScale", "2"); + let p = Page { + name: "Scaled", + sheet, + shapes: boxed(1, 6.0, 4.0, 2.0, 2.0, ""), + }; + let c = convert(&drawing(&[p], ""), Format::Vsdx, &Limits::default()).unwrap(); + assert!(part(&c.pptx, "ppt/presentation.xml").contains(r#"cx="5486400" cy="3657600""#)); + let s = part(&c.pptx, "ppt/slides/slide1.xml"); + // 2 inches of drawing are 1 inch of paper, centred on (3, 2). + assert!( + s.contains(r#"<a:off x="2286000" y="1371600"/><a:ext cx="914400" cy="914400"/>"#), + "{s}" + ); + // A page of 1000 inches is 55 inches at most, and its shapes with it. + let d = drawing( + &[page( + "Huge", + 1000.0, + 500.0, + &boxed(1, 500.0, 250.0, 100.0, 50.0, ""), + )], + "", + ); + let c = convert(&d, Format::Vsdx, &Limits::default()).unwrap(); + assert!(part(&c.pptx, "ppt/presentation.xml").contains(r#"cx="50292000" cy="25146000""#)); + assert!( + part(&c.pptx, "ppt/slides/slide1.xml").contains(r#"<a:ext cx="5029200" cy="2514600"/>"#) + ); +} + +#[test] +fn a_shape_is_placed_from_the_top_left_corner_of_the_page() { + let s = slide(&one(&boxed(1, 2.0, 3.0, 2.0, 1.0, ""))); + // The page is 4 inches high and the box's top is at 3.5: 0.5 from the top. + assert!( + s.contains(r#"<a:off x="914400" y="457200"/><a:ext cx="1828800" cy="914400"/>"#), + "{s}" + ); + // The path starts at the lower left of the box, y pointing down. + assert!( + s.contains(r#"<a:path w="1828800" h="914400"><a:moveTo><a:pt x="0" y="914400"/>"#), + "{s}" + ); + assert!(s.contains( + r#"<a:lnTo><a:pt x="1828800" y="914400"/></a:lnTo><a:lnTo><a:pt x="1828800" y="0"/>"# + )); + assert!(s.contains("<a:close/>")); +} + +#[test] +fn rotation_is_counterclockwise_and_mirrors_stay_on_the_pin() { + // A quarter turn counterclockwise is three quarters clockwise. + let s = slide(&one(&boxed( + 1, + 3.0, + 2.0, + 2.0, + 1.0, + &cell("Angle", "1.5707963267949"), + ))); + assert!(s.contains(r#"<a:xfrm rot="16200000">"#), "{s}"); + let s = slide(&one(&boxed( + 1, + 3.0, + 2.0, + 2.0, + 1.0, + &(cell("FlipX", "1") + &cell("FlipY", "1")), + ))); + assert!(s.contains(r#"flipH="1" flipV="1""#), "{s}"); + // A pin at a corner: the box turns about it. Turned a quarter about its + // lower left corner at (2, 1), a 2 by 1 box covers x 1 to 2, y 1 to 3. + let extra = cell("Angle", "1.5707963267949") + &cell("LocPinX", "0") + &cell("LocPinY", "0"); + let s = slide(&one(&boxed(1, 2.0, 1.0, 2.0, 1.0, &extra))); + // Its unturned box is centred on (1.5, 2): 2 wide, 1 high. + assert!( + s.contains(r#"<a:off x="457200" y="1371600"/><a:ext cx="1828800" cy="914400"/>"#), + "{s}" + ); +} + +#[test] +fn fill_line_and_dash_come_from_the_cells_and_the_colour_table() { + let doc = "<Colors><ColorEntry IX='24' RGB='#336699'/></Colors>"; + let extra = cell("FillPattern", "1") + + &cell("FillForegnd", "24") + + &cell("FillForegndTrans", "0.5") + + &cell("LinePattern", "2") + + &cell("LineWeight", "0.0208333333") + + &cell("LineColor", "#FF0000") + + &cell("EndArrow", "4"); + let d = drawing( + &[page("P", 6.0, 4.0, &boxed(1, 3.0, 2.0, 2.0, 1.0, &extra))], + doc, + ); + let s = slide(&d); + assert!(s.contains(r#"<a:solidFill><a:srgbClr val="336699"><a:alpha val="50000"/></a:srgbClr></a:solidFill>"#), "{s}"); + assert!(s.contains(r#"<a:ln w="19050">"#), "{s}"); + assert!(s.contains(r#"<a:srgbClr val="FF0000"/>"#)); + assert!(s.contains(r#"<a:prstDash val="dash"/>"#)); + assert!(s.contains(r#"<a:tailEnd type="triangle""#)); + assert!(!s.contains("headEnd")); + // Without a pattern a shape is neither filled nor stroked. + let s = slide(&one(&boxed(1, 3.0, 2.0, 2.0, 1.0, ""))); + assert!(s.contains("<a:noFill/><a:ln><a:noFill/></a:ln>"), "{s}"); +} + +#[test] +fn a_style_sheet_gives_what_the_shape_leaves_out_through_its_parents() { + let doc = "<StyleSheets>\ + <StyleSheet ID='0'><Cell N='FillPattern' V='1'/><Cell N='FillForegnd' V='#111111'/><Cell N='LinePattern' V='1'/><Cell N='LineColor' V='#222222'/><Cell N='LineWeight' V='0.01'/></StyleSheet>\ + <StyleSheet ID='1' FillStyle='0' LineStyle='0'><Cell N='FillForegnd' V='#AABBCC'/></StyleSheet>\ + </StyleSheets>"; + let sh = "<Shape ID='1' FillStyle='1' LineStyle='1'><Cell N='PinX' V='3'/><Cell N='PinY' V='2'/><Cell N='Width' V='1'/><Cell N='Height' V='1'/><Cell N='LocPinX' V='0.5'/><Cell N='LocPinY' V='0.5'/>".to_string() + + &rect(1.0, 1.0) + + "</Shape>"; + let d = drawing(&[page("P", 6.0, 4.0, &sh)], doc); + let s = slide(&d); + // The fill colour of the style, its pattern and the line of its parent. + assert!( + s.contains(r#"<a:solidFill><a:srgbClr val="AABBCC"/></a:solidFill>"#), + "{s}" + ); + assert!( + s.contains(r#"<a:ln w="9144"><a:solidFill><a:srgbClr val="222222"/>"#), + "{s}" + ); +} + +fn master_package(instance: &str) -> Vec<u8> { + let master = "<Shape ID='1' Type='Shape'><Cell N='PinX' V='1'/><Cell N='PinY' V='0.5'/>\ + <Cell N='Width' V='2'/><Cell N='Height' V='1'/><Cell N='LocPinX' V='1' F='Width*0.5'/><Cell N='LocPinY' V='0.5' F='Height*0.5'/>\ + <Cell N='FillPattern' V='1'/><Cell N='FillForegnd' V='#00FF00'/>\ + <Section N='Geometry' IX='0'>\ + <Row T='MoveTo' IX='1'><Cell N='X' V='0' F='Width*0'/><Cell N='Y' V='0' F='Height*0'/></Row>\ + <Row T='LineTo' IX='2'><Cell N='X' V='2' F='Width*1'/><Cell N='Y' V='0' F='Height*0'/></Row>\ + <Row T='LineTo' IX='3'><Cell N='X' V='2' F='Width*1'/><Cell N='Y' V='1' F='Height*1'/></Row>\ + </Section></Shape>"; + package(&[ + ( + "_rels/.rels", + rels(&[("rId1", &format!("{REL}/document"), "visio/document.xml")]), + ), + ("visio/document.xml", format!("<VisioDocument {NS}/>")), + ( + "visio/_rels/document.xml.rels", + rels(&[ + ("rId1", &format!("{REL}/masters"), "masters/masters.xml"), + ("rId2", &format!("{REL}/pages"), "pages/pages.xml"), + ]), + ), + ( + "visio/masters/masters.xml", + format!("<Masters {NS}><Master ID='7'><Rel r:id='rId1'/></Master></Masters>"), + ), + ( + "visio/masters/_rels/masters.xml.rels", + rels(&[("rId1", &format!("{REL}/master"), "master1.xml")]), + ), + ( + "visio/masters/master1.xml", + format!("<MasterContents {NS}><Shapes>{master}</Shapes></MasterContents>"), + ), + ( + "visio/pages/pages.xml", + format!( + "<Pages {NS}><Page ID='0' Name='P'><PageSheet><Cell N='PageWidth' V='6'/><Cell N='PageHeight' V='4'/></PageSheet><Rel r:id='rId1'/></Page></Pages>" + ), + ), + ( + "visio/pages/_rels/pages.xml.rels", + rels(&[("rId1", &format!("{REL}/page"), "page1.xml")]), + ), + ( + "visio/pages/page1.xml", + format!("<PageContents {NS}><Shapes>{instance}</Shapes></PageContents>"), + ), + ]) +} + +#[test] +fn an_instance_inherits_its_master_and_the_master_formulas_follow_its_size() { + // Twice as wide as the master and one cell of its own. + let inst = "<Shape ID='3' Type='Shape' Master='7'><Cell N='PinX' V='3'/><Cell N='PinY' V='2'/><Cell N='Width' V='4'/>\ + <Cell N='FillForegnd' V='#0000FF'/></Shape>"; + let s = slide(&master_package(inst)); + // LocPinX = Width*0.5 gives a box from x = 1 to 5. + assert!( + s.contains(r#"<a:off x="914400" y="1371600"/><a:ext cx="3657600" cy="914400"/>"#), + "{s}" + ); + // The path follows Width*1 (4 inches), and Height*0. + assert!( + s.contains( + r#"<a:lnTo><a:pt x="3657600" y="914400"/></a:lnTo><a:lnTo><a:pt x="3657600" y="0"/>"# + ), + "{s}" + ); + // The instance's colour, not the master's. + assert!( + s.contains(r#"<a:srgbClr val="0000FF"/>"#) && !s.contains("00FF00"), + "{s}" + ); + // Without the colour, the master's. + let inst = "<Shape ID='3' Type='Shape' Master='7'><Cell N='PinX' V='3'/><Cell N='PinY' V='2'/></Shape>"; + let s = slide(&master_package(inst)); + assert!(s.contains(r#"<a:srgbClr val="00FF00"/>"#), "{s}"); + assert!( + s.contains(r#"<a:off x="1828800" y="1371600"/><a:ext cx="1828800" cy="914400"/>"#), + "{s}" + ); +} + +#[test] +fn a_group_has_its_members_in_its_own_coordinates() { + let group = format!( + "<Shape ID='1' Type='Group'>{}{}{}{}{}{}{}<Shapes>{}</Shapes></Shape>", + cell("PinX", "3"), + cell("PinY", "2"), + cell("Width", "2"), + cell("Height", "1"), + cell("LocPinX", "1"), + cell("LocPinY", "0.5"), + cell("Angle", "0.5235987756"), + // A member at the group's lower left, a unit square. + boxed(2, 0.5, 0.5, 1.0, 1.0, "") + ); + let s = slide(&one(&group)); + assert!(s.contains("<p:grpSp>"), "{s}"); + // The group's box, turned 30 degrees counterclockwise, and the space its + // members are in: its own size, y pointing down. + assert!(s.contains(r#"<a:xfrm rot="19800000"><a:off x="1828800" y="1371600"/><a:ext cx="1828800" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="1828800" cy="914400"/></a:xfrm>"#), "{s}"); + // The member fills the left half of it. + assert!( + s.contains(r#"<a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm>"#), + "{s}" + ); +} + +#[test] +fn text_has_runs_paragraphs_and_a_block_where_the_cells_put_it() { + let extra = "<Section N='Character'><Row IX='0'>\ + <Cell N='Size' V='0.25'/><Cell N='Style' V='1'/><Cell N='Color' V='#FF0000'/><Cell N='Font' V='Arial'/></Row></Section>\ + <Section N='Paragraph'><Row IX='0'><Cell N='HorzAlign' V='2'/></Row></Section>\ + <Cell N='VerticalAlign' V='2'/>\ + <Text><cp IX='0'/>Bold red<cp IX='0'/> and more\nSecond</Text>"; + let s = slide(&one(&boxed(1, 3.0, 2.0, 2.0, 1.0, extra))); + assert_eq!(s.matches("<a:p>").count(), 2, "{s}"); + assert!(s.contains(r#"sz="1800" b="1""#), "{s}"); + assert!(s.contains(r#"<a:srgbClr val="FF0000"/>"#), "{s}"); + assert!(s.contains(r#"<a:latin typeface="Arial"/>"#), "{s}"); + assert!( + s.contains("<a:t>Bold red</a:t>") && s.contains("<a:t>Second</a:t>"), + "{s}" + ); + assert!(s.contains(r#"algn="r""#), "{s}"); + assert!(s.contains(r#"anchor="b""#), "{s}"); + // A text block that is a part of the box: the insets are what is left. + let extra = "<Cell N='TxtPinX' V='1.5'/><Cell N='TxtPinY' V='0.5'/><Cell N='TxtWidth' V='1'/><Cell N='TxtHeight' V='0.5'/>\ + <Cell N='TxtLocPinX' V='0.5'/><Cell N='TxtLocPinY' V='0.25'/><Text>Right part</Text>"; + let s = slide(&one(&boxed(1, 3.0, 2.0, 2.0, 1.0, extra))); + // The block starts at x = 1 of 2 and is 0.5 of 1 high, centred. + assert!( + s.contains(r#"lIns="914400" tIns="228600" rIns="0" bIns="228600""#), + "{s}" + ); + // A block turned or outside the box is a shape of its own. + let extra = "<Cell N='TxtPinX' V='1'/><Cell N='TxtPinY' V='0.5'/><Cell N='TxtWidth' V='1'/><Cell N='TxtHeight' V='0.5'/>\ + <Cell N='TxtLocPinX' V='0.5'/><Cell N='TxtLocPinY' V='0.25'/><Cell N='TxtAngle' V='1.5707963267949'/><Text>Turned</Text>"; + let s = slide(&one(&boxed(1, 3.0, 2.0, 2.0, 1.0, extra))); + assert_eq!(s.matches("<p:sp>").count(), 2, "{s}"); + assert!(s.contains(r#"<a:xfrm rot="16200000">"#), "{s}"); +} + +#[test] +fn what_is_hidden_deleted_or_a_guide_is_left_out() { + let layers = "<Section N='Layer'><Row IX='0'><Cell N='Name' V='A'/><Cell N='Visible' V='1'/></Row>\ + <Row IX='1'><Cell N='Name' V='B'/><Cell N='Visible' V='0'/></Row></Section>"; + let shapes = [ + boxed(1, 1.0, 1.0, 1.0, 1.0, &(cell("LayerMember", "0") + "<Text>shown</Text>")), + boxed(2, 2.0, 1.0, 1.0, 1.0, &(cell("LayerMember", "0;1") + "<Text>on a hidden layer</Text>")), + boxed(3, 3.0, 1.0, 1.0, 1.0, "<Text>no layer</Text>"), + "<Shape ID='4' Type='Guide'><Cell N='PinX' V='1'/><Text>guide</Text></Shape>".to_string(), + boxed(5, 4.0, 1.0, 1.0, 1.0, "<Text>on page</Text>").replace("Type='Shape'", "Type='Shape' Del='1'"), + // A path that does not show leaves nothing but the text. + format!( + "<Shape ID='6' Type='Shape'>{}{}{}{}<Section N='Geometry' IX='0'><Cell N='NoShow' V='1'/><Row T='MoveTo' IX='1'><Cell N='X' V='0'/><Cell N='Y' V='0'/></Row><Row T='LineTo' IX='2'><Cell N='X' V='1'/><Cell N='Y' V='1'/></Row></Section></Shape>", + cell("PinX", "5"), cell("PinY", "2"), cell("Width", "1"), cell("Height", "1") + ), + ] + .concat(); + let p = Page { + name: "P", + sheet: cell("PageWidth", "6") + &cell("PageHeight", "4") + layers, + shapes, + }; + let s = slide(&drawing(&[p], "")); + assert!(s.contains("shown") && s.contains("no layer"), "{s}"); + for gone in ["on a hidden layer", "guide", "on page"] { + assert!(!s.contains(gone), "{gone}"); + } + // Two shapes with a path, and the one whose only path does not show + // has none. + assert_eq!(s.matches("<a:custGeom>").count(), 2, "{s}"); +} + +#[test] +fn a_picture_and_the_links_of_the_sample_drawing() { + let c = convert(&fixture("sample.vsdx"), Format::Vsdx, &Limits::default()).unwrap(); + // The PNG of the second page is a picture of the PPTX. + assert!(has_part(&c.pptx, "ppt/media/image1.png")); + assert!(part(&c.pptx, "ppt/slides/slide2.xml").contains("<p:pic>")); + // A link to an address, and one to the first page. + let r1 = part(&c.pptx, "ppt/slides/_rels/slide1.xml.rels"); + assert!( + r1.contains(r#"Target="https://example.org/" TargetMode="External""#), + "{r1}" + ); + let s2 = part(&c.pptx, "ppt/slides/slide2.xml"); + assert!(s2.contains(r#"action="ppaction://hlinksldjump""#), "{s2}"); + assert!(part(&c.pptx, "ppt/slides/_rels/slide2.xml.rels").contains("slide1.xml")); + // Its hidden layer's shape is not in the first slide, the rest is. + let s1 = part(&c.pptx, "ppt/slides/slide1.xml"); + assert!( + !s1.contains("Hidden note") && s1.contains("Wide box"), + "{s1}" + ); + assert!(s1.contains(r#"<p:grpSp>"#) && s1.contains("First line")); + // The master's own formulas, the instance's colour and the turn. + assert!(s1.contains(r#"flipH="1""#) && s1.contains(r#"val="00FF00""#)); + // The first instance gets the master's fill through its style and the + // colour table (24 is 336699). + assert!(s1.contains(r#"val="336699""#), "{s1}"); +} + +#[test] +fn a_background_page_is_the_master_of_the_pages_that_use_it() { + let c = convert(&fixture("sample.vsdx"), Format::Vsdx, &Limits::default()).unwrap(); + // The first master is the blank one, the second the page "Back". + let m = part(&c.pptx, "ppt/slideMasters/slideMaster2.xml"); + assert!(m.contains("CCCCCC"), "{m}"); + assert!(part(&c.pptx, "ppt/slides/_rels/slide1.xml.rels").contains("slideLayout2.xml")); + assert!(part(&c.pptx, "ppt/slides/_rels/slide2.xml.rels").contains("slideLayout1.xml")); +} + +#[test] +fn the_theme_gives_what_a_themed_cell_leaves_to_it() { + let c = convert(&fixture("themed.vsdx"), Format::Vsdx, &Limits::default()).unwrap(); + let s = part(&c.pptx, "ppt/slides/slide1.xml"); + let fills: Vec<&str> = s + .split("<a:solidFill><a:srgbClr val=\"") + .skip(1) + .map(|t| &t[..6]) + .collect(); + // For each box the fill and then the line. The first has no quick style: + // the root style's white and black; then accent 1 and the line in + // accent 2; a variation's second colour; the first variation, as the + // index 65534 is not one; the background colour. + assert_eq!( + fills, + [ + "FFFFFF", "000000", "4472C4", "ED7D31", "00AA00", "000000", "222222", "000000", + "F0F0F0", "000000" + ], + "{s}" + ); + // The theme's body font for text that names none. + assert!(s.contains(r#"typeface="Verdana""#), "{s}"); +} + +#[test] +fn an_effect_that_is_not_drawn_is_a_warning() { + let d = one(&boxed(1, 3.0, 2.0, 1.0, 1.0, &cell("ShapeShdwType", "1"))); + let c = convert(&d, Format::Vsdx, &Limits::default()).unwrap(); + assert!( + c.warnings.iter().any(|w| w.contains("shadows")), + "{:?}", + c.warnings + ); + let c = convert( + &one(&boxed(1, 3.0, 2.0, 1.0, 1.0, "")), + Format::Vsdx, + &Limits::default(), + ) + .unwrap(); + assert!(c.warnings.is_empty(), "{:?}", c.warnings); + // No page is said, too. + let c = convert(&drawing(&[], ""), Format::Vsdx, &Limits::default()).unwrap(); + assert_eq!(c.slides, 0); + assert!(c.warnings.iter().any(|w| w.contains("no pages"))); +} + +#[test] +fn the_first_pages_alone_and_a_source_read_part_by_part() { + let d = fixture("sample.vsdx"); + let c = convert_first(&d, Format::Vsdx, &Limits::default(), 1).unwrap(); + assert_eq!((c.slides, c.total_slides), (1, 2)); + assert!(!has_part(&c.pptx, "ppt/media/image1.png")); + let whole = convert(&d, Format::Vsdx, &Limits::default()).unwrap(); + let from = + convert_first_from(Cursor::new(d), Format::Vsdx, &Limits::default(), usize::MAX).unwrap(); + assert_eq!(from.slides, whole.slides); + assert_eq!( + part(&from.pptx, "ppt/slides/slide1.xml"), + part(&whole.pptx, "ppt/slides/slide1.xml") + ); +} + +#[test] +fn the_extensions_name_the_format() { + for e in ["vsdx", "VSDM", "vstx", "vstm"] { + assert_eq!(Format::from_extension(e), Some(Format::Vsdx), "{e}"); + } + assert_eq!(Format::Vsdx.name(), "vsdx"); + assert_eq!(Format::from_extension("vsd"), None); + assert_eq!(Format::from_extension("vdx"), None); +} + +#[test] +fn what_is_not_a_drawing_is_refused_by_kind() { + let l = Limits::default(); + let kind = |d: &[u8]| convert(d, Format::Vsdx, &l).unwrap_err().kind(); + assert_eq!(kind(b"not a zip at all"), "not-format"); + assert_eq!(kind(b""), "not-format"); + // A package without the Visio parts, and another document's. + let other = package(&[("word/document.xml", "<w:document/>".into())]); + assert_eq!(kind(&other), "not-format"); + let odp = std::fs::read(format!( + "{}/tests/fixtures/office/slides/sample.odp", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + assert_eq!(kind(&odp), "not-format"); + // A document part that is not a Visio document. + let wrong = package(&[ + ( + "_rels/.rels", + rels(&[("rId1", &format!("{REL}/document"), "visio/document.xml")]), + ), + ("visio/document.xml", "<Other/>".into()), + ]); + assert_eq!(kind(&wrong), "not-format"); + // A binary file (a compound file) is not a package; a password protected + // package is a compound file with an encrypted stream. + let cfb = build_cfb(&[("WordDocument", &[1u8; 600][..])]); + assert_eq!(kind(&cfb), "not-format"); + let enc = build_cfb(&[ + ("EncryptedPackage", &[1u8; 600][..]), + ("EncryptionInfo", &[2u8; 100][..]), + ]); + assert_eq!(kind(&enc), "encrypted"); + assert_eq!( + convert_first_from(Cursor::new(enc), Format::Vsdx, &l, 1).unwrap_err(), + Error::Encrypted + ); + // A member of the package that is encrypted. + let mut e = fixture("sample.vsdx"); + for i in 0..e.len().saturating_sub(10) { + if e[i..].starts_with(b"PK\x03\x04") { + e[i + 6] |= 1; + } else if e[i..].starts_with(b"PK\x01\x02") { + e[i + 8] |= 1; + } + } + assert_eq!(kind(&e), "encrypted"); +} + +#[test] +fn damaged_xml_is_malformed_and_limits_are_too_large() { + let l = Limits::default(); + let mut parts: Vec<(&str, String)> = vec![ + ( + "_rels/.rels", + rels(&[("rId1", &format!("{REL}/document"), "visio/document.xml")]), + ), + ("visio/document.xml", format!("<VisioDocument {NS}")), + ]; + let e = convert(&package(&parts), Format::Vsdx, &l).unwrap_err(); + assert_eq!(e.kind(), "malformed", "{e}"); + parts[1].1 = format!("<VisioDocument {NS}></Wrong></VisioDocument>"); + parts.push(( + "visio/_rels/document.xml.rels", + rels(&[("rId2", &format!("{REL}/pages"), "pages/pages.xml")]), + )); + parts.push(( + "visio/pages/pages.xml", + format!("<Pages {NS}><Page ID='0' Name='P'><Rel r:id='rId1'/></Page></Pages>"), + )); + parts.push(( + "visio/pages/_rels/pages.xml.rels", + rels(&[("rId1", &format!("{REL}/page"), "p.xml")]), + )); + parts.push(("visio/pages/p.xml", "<PageContents ".into())); + assert_eq!( + convert(&package(&parts), Format::Vsdx, &l) + .unwrap_err() + .kind(), + "malformed" + ); + + let many: String = (0..50).map(|i| boxed(i, 1.0, 1.0, 1.0, 1.0, "")).collect(); + let d = one(&many); + assert_eq!( + convert(&d, Format::Vsdx, &Limits::default().with_max_shapes(10)).unwrap_err(), + Error::TooLarge("shapes") + ); + let mut small = Limits::default(); + small.max_slides = 1; + assert_eq!( + convert(&fixture("sample.vsdx"), Format::Vsdx, &small).unwrap_err(), + Error::TooLarge("slides") + ); + let mut small = Limits::default(); + small.max_part = 300; + assert!(matches!( + convert(&fixture("sample.vsdx"), Format::Vsdx, &small), + Err(Error::TooLarge(_)) + )); + assert!(matches!( + convert(&d, Format::Vsdx, &Limits::default().with_max_memory(10_000)), + Err(Error::TooLarge(_)) + )); + let mut small = Limits::default(); + small.max_input = 100; + assert_eq!( + convert(&d, Format::Vsdx, &small).unwrap_err(), + Error::TooLarge("input") + ); +} + +#[test] +fn a_shape_with_too_many_path_segments_is_too_large() { + let mut rows = + String::from("<Row T='MoveTo' IX='1'><Cell N='X' V='0'/><Cell N='Y' V='0'/></Row>"); + for i in 2..40_000 { + rows.push_str(&format!( + "<Row T='LineTo' IX='{i}'><Cell N='X' V='1'/><Cell N='Y' V='1'/></Row>" + )); + } + let shape = format!( + "<Shape ID='1'>{}<Section N='Geometry' IX='0'>{rows}</Section></Shape>", + cell("Width", "1") + &cell("Height", "1") + ); + // Within the bound of a shape. + assert!(convert(&one(&shape), Format::Vsdx, &Limits::default()).is_ok()); + // Nine such sections in one shape are past it. + let many = format!( + "<Shape ID='1'>{}{}</Shape>", + cell("Width", "1") + &cell("Height", "1"), + (0..6) + .map(|k| format!("<Section N='Geometry' IX='{k}'>{rows}</Section>")) + .collect::<String>() + ); + assert_eq!( + convert(&one(&many), Format::Vsdx, &Limits::default()).unwrap_err(), + Error::TooLarge("geometry segments") + ); +} + +#[test] +fn hostile_structure_does_not_loop_or_panic() { + // A page whose part is the pages part, and a master that instantiates + // itself. + let cyc = package(&[ + ( + "_rels/.rels", + rels(&[("rId1", &format!("{REL}/document"), "visio/document.xml")]), + ), + ("visio/document.xml", format!("<VisioDocument {NS}/>")), + ( + "visio/_rels/document.xml.rels", + rels(&[("rId2", &format!("{REL}/pages"), "pages/pages.xml")]), + ), + ( + "visio/pages/pages.xml", + format!("<Pages {NS}><Page ID='0' Name='P'><Rel r:id='rId1'/></Page></Pages>"), + ), + ( + "visio/pages/_rels/pages.xml.rels", + rels(&[("rId1", &format!("{REL}/page"), "pages.xml")]), + ), + ]); + let c = convert(&cyc, Format::Vsdx, &Limits::default()).unwrap(); + assert_eq!(c.slides, 1); + let inst = "<Shape ID='1' Type='Shape' Master='7'><Cell N='PinX' V='3'/></Shape>"; + let mut d = master_package(inst); + // The master holds an instance of itself. + let looping = + "<Shape ID='1' Type='Group' Master='7'><Shapes><Shape ID='2' Master='7'/></Shapes></Shape>"; + let doc = package(&[ + ( + "_rels/.rels", + rels(&[("rId1", &format!("{REL}/document"), "visio/document.xml")]), + ), + ("visio/document.xml", format!("<VisioDocument {NS}/>")), + ( + "visio/_rels/document.xml.rels", + rels(&[ + ("rId1", &format!("{REL}/masters"), "masters/masters.xml"), + ("rId2", &format!("{REL}/pages"), "pages/pages.xml"), + ]), + ), + ( + "visio/masters/masters.xml", + format!("<Masters {NS}><Master ID='7'><Rel r:id='rId1'/></Master></Masters>"), + ), + ( + "visio/masters/_rels/masters.xml.rels", + rels(&[("rId1", &format!("{REL}/master"), "m.xml")]), + ), + ( + "visio/masters/m.xml", + format!("<MasterContents {NS}><Shapes>{looping}</Shapes></MasterContents>"), + ), + ( + "visio/pages/pages.xml", + format!("<Pages {NS}><Page ID='0' Name='P'><Rel r:id='rId1'/></Page></Pages>"), + ), + ( + "visio/pages/_rels/pages.xml.rels", + rels(&[("rId1", &format!("{REL}/page"), "p.xml")]), + ), + ( + "visio/pages/p.xml", + format!("<PageContents {NS}><Shapes>{looping}</Shapes></PageContents>"), + ), + ]); + let r = convert( + &doc, + Format::Vsdx, + &Limits::default().with_max_shapes(10_000), + ); + assert!(r.is_ok() || matches!(r, Err(Error::TooLarge(_))), "{r:?}"); + d.truncate(d.len() / 2); + let _ = convert(&d, Format::Vsdx, &Limits::default()); +} diff --git a/crates/exav-render/tests/office_text_docs.rs b/crates/exav-render/tests/office_text_docs.rs new file mode 100644 index 00000000..db6306fc --- /dev/null +++ b/crates/exav-render/tests/office_text_docs.rs @@ -0,0 +1,1088 @@ +//! The text of every format, taken from what its reader makes: the fixtures, +//! and short documents with one of each thing a reader sees (a table, a +//! header, a footnote, a comment, links, hidden text). + +#[path = "office_common/mod.rs"] +mod common; + +use common::*; +use exav_render::docs::{Error, Format, Limits, convert, text}; +use exav_render::formats::office::test_util::{PackageWriter, W, Zip, build_cfb, literal_block}; +use exav_render::formats::office::text::Text; + +const CAP: usize = 1 << 20; + +fn fixture(name: &str) -> Vec<u8> { + let p = format!( + "{}/tests/fixtures/office/docs/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read(&p).unwrap_or_else(|e| panic!("{p}: {e}")) +} + +fn extract(input: &[u8], f: Format) -> Text { + text(input, f, &Limits::default(), CAP).unwrap_or_else(|e| panic!("{f:?}: {e}")) +} + +/// The text of the section called `label`. +fn section<'a>(t: &'a Text, label: &str) -> &'a str { + let s = t + .sections + .iter() + .find(|s| s.label == label) + .unwrap_or_else(|| panic!("no section {label} in {:?}", t.sections)); + &t.text[s.range.clone()] +} + +/// The labels of the sections, in order. +fn labels(t: &Text) -> Vec<&str> { + t.sections.iter().map(|s| s.label.as_str()).collect() +} + +// ---- the words of a DOCX, to compare with ---- + +fn unescape(s: &str) -> String { + let mut out = String::new(); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let end = rest.find(';').expect("an entity ends"); + let e = &rest[1..end]; + match e { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + _ => { + let n = match e.strip_prefix("#x") { + Some(h) => u32::from_str_radix(h, 16), + None => e.trim_start_matches('#').parse(), + }; + out.push(char::from_u32(n.expect("a character reference")).expect("a character")); + } + } + rest = &rest[end + 1..]; + } + out.push_str(rest); + out +} + +/// The `w:t` text of a part: runs, but not hidden ones, not field +/// instructions and not the result of a page number field. A tab or a break +/// is a space, a paragraph and a cell end with one. +fn docx_text(xml: &str) -> String { + struct Field { + instr: String, + result: bool, + skip: bool, + } + let mut out = String::new(); + // The runs open, with whether each is hidden: a text box holds runs + // inside a run. + let mut runs: Vec<(String, bool)> = vec![]; + let mut fields: Vec<Field> = vec![]; + let mut rest = xml; + while let Some(lt) = rest.find('<') { + rest = &rest[lt + 1..]; + let gt = rest.find('>').expect("a tag ends"); + let tag = &rest[..gt]; + rest = &rest[gt + 1..]; + let closing = tag.starts_with('/'); + let name = tag + .trim_start_matches('/') + .split(|c: char| c.is_whitespace() || c == '/') + .next() + .unwrap_or(""); + match (name, closing) { + ("w:r", false) => runs.push((String::new(), false)), + ("w:r", true) => { + let (run, hidden) = runs.pop().expect("a run is open"); + if !hidden { + out.push_str(&run); + } + } + ("w:vanish", false) => { + if let Some(r) = runs.last_mut() { + r.1 = !tag.contains("w:val=\"0\""); + } + } + ("w:t", false) if !tag.ends_with('/') => { + let end = rest.find("</w:t>").expect("w:t ends"); + // Not an instruction's, nor a page number's. + if fields.iter().all(|f| f.result && !f.skip) + && let Some(r) = runs.last_mut() + { + r.0.push_str(&unescape(&rest[..end])); + } + rest = &rest[end..]; + } + ("w:instrText", false) => { + let end = rest.find("</w:instrText>").expect("w:instrText ends"); + if let Some(f) = fields.last_mut() { + f.instr.push_str(&unescape(&rest[..end])); + } + rest = &rest[end..]; + } + ("w:fldChar", false) => { + if tag.contains("\"begin\"") { + fields.push(Field { + instr: String::new(), + result: false, + skip: false, + }); + } else if tag.contains("\"separate\"") { + if let Some(f) = fields.last_mut() { + f.result = true; + let word = f.instr.split_whitespace().next().unwrap_or(""); + f.skip = word.eq_ignore_ascii_case("PAGE") + || word.eq_ignore_ascii_case("NUMPAGES"); + } + } else { + fields.pop(); + } + } + ("w:tab" | "w:br" | "w:cr", false) => { + if let Some(r) = runs.last_mut() { + r.0.push(' '); + } + } + ("w:noBreakHyphen", false) => { + if let Some(r) = runs.last_mut() { + r.0.push('-'); + } + } + ("w:p" | "w:tc", true) => out.push(' '), + _ => {} + } + } + out +} + +/// The words of every text part of a DOCX, sorted. +fn docx_words(docx: &[u8]) -> Vec<String> { + let names = Zip::open(docx).expect("a ZIP package").names(); + let mut all = String::new(); + for n in names { + let text_part = [ + "document", + "header", + "footer", + "footnotes", + "endnotes", + "comments", + ] + .iter() + .any(|p| n.starts_with(&format!("word/{p}")) && n.ends_with(".xml")); + if text_part { + all.push(' '); + all.push_str(&docx_text(&part(docx, &n))); + } + } + let mut w: Vec<String> = all.split_whitespace().map(str::to_string).collect(); + w.sort(); + w +} + +/// The words of a text, sorted, without the targets written after links. +fn text_words(t: &Text) -> Vec<String> { + let mut s = t.text.clone(); + for (_, target) in &t.links { + s = s.replace(&format!(" ({target})"), ""); + } + let mut w: Vec<String> = s.split_whitespace().map(str::to_string).collect(); + w.sort(); + w +} + +/// The text and the DOCX the converter writes have the same words. +fn same_words_as_the_converter(name: &str, input: &[u8], f: Format) { + let t = extract(input, f); + let c = convert(input, f, &Limits::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + let (a, b) = (text_words(&t), docx_words(&c.docx)); + assert!(!a.is_empty(), "{name}: no words"); + assert_eq!(a, b, "{name}"); +} + +// ---- documents of each format with the same things in them ---- + +const ODF_NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/""#; + +const ODF_AUTOMATIC: &str = r#"<office:automatic-styles> +<style:page-layout style:name="L1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout> +<style:style style:name="Hid" style:family="text"><style:text-properties text:display="none"/></style:style> +<style:style style:name="Strike" style:family="text"><style:text-properties style:text-line-through-style="solid"/></style:style> +</office:automatic-styles>"#; + +const ODF_MASTER: &str = r#"<office:master-styles><style:master-page style:name="Standard" style:page-layout-name="L1"><style:header><text:p>HEADTEXT</text:p></style:header><style:footer><text:p>FOOTTEXT</text:p></style:footer></style:master-page></office:master-styles>"#; + +const ODF_BODY: &str = r#"<text:p>Alpha beta gamma</text:p> +<text:p>Visit <text:a xlink:href="https://example.org/x">the site</text:a> today <text:a xlink:href="https://example.org/">https://example.org/</text:a></text:p> +<text:p>before <text:span text:style-name="Hid">SECRET</text:span>after</text:p> +<text:p><text:span text:style-name="Strike">struck</text:span> kept</text:p> +<text:p>Page <text:page-number>7</text:page-number></text:p> +<text:p>note<text:note text:note-class="footnote"><text:note-citation>1</text:note-citation><text:note-body><text:p>NOTETEXT</text:p></text:note-body></text:note></text:p> +<text:p>end<text:note text:note-class="endnote"><text:note-citation>i</text:note-citation><text:note-body><text:p>ENDTEXT</text:p></text:note-body></text:note></text:p> +<text:p>word<office:annotation><dc:creator>Ann Lee</dc:creator><text:p>COMMENTTEXT</text:p></office:annotation></text:p> +<table:table><table:table-column table:number-columns-repeated="2"/> +<table:table-row><table:table-cell><text:p>r1c1</text:p></table:table-cell><table:table-cell><text:p>r1c2</text:p></table:table-cell></table:table-row> +<table:table-row><table:table-cell><text:p>r2c1</text:p></table:table-cell><table:table-cell><text:p>r2c2</text:p></table:table-cell></table:table-row> +</table:table> +<text:p>Boxed <draw:frame text:anchor-type="as-char" svg:width="2cm" svg:height="1cm"><draw:text-box><text:p>BOXTEXT</text:p></draw:text-box></draw:frame> text</text:p> +<text:p>Last</text:p>"#; + +fn fodt() -> Vec<u8> { + format!( + r#"<?xml version="1.0"?><office:document {ODF_NS} office:mimetype="application/vnd.oasis.opendocument.text">{ODF_AUTOMATIC}{ODF_MASTER}<office:body><office:text>{ODF_BODY}</office:text></office:body></office:document>"# + ) + .into_bytes() +} + +fn odt_with(content: &str, styles: &str, manifest: Option<&str>) -> Vec<u8> { + let mut z = PackageWriter::new(1 << 22); + z.put( + "mimetype", + b"application/vnd.oasis.opendocument.text", + false, + ) + .unwrap(); + z.put("content.xml", content.as_bytes(), true).unwrap(); + z.put("styles.xml", styles.as_bytes(), true).unwrap(); + if let Some(m) = manifest { + z.put("META-INF/manifest.xml", m.as_bytes(), true).unwrap(); + } + z.finish().unwrap() +} + +fn odt() -> Vec<u8> { + odt_with( + &format!( + r#"<?xml version="1.0"?><office:document-content {ODF_NS}>{ODF_AUTOMATIC}<office:body><office:text>{ODF_BODY}</office:text></office:body></office:document-content>"# + ), + &format!( + r#"<?xml version="1.0"?><office:document-styles {ODF_NS}>{ODF_AUTOMATIC}{ODF_MASTER}</office:document-styles>"# + ), + None, + ) +} + +fn rtf() -> Vec<u8> { + br#"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}} +{\header\pard HEADTEXT\par}{\footer\pard FOOTTEXT\par} +\pard Alpha beta gamma\par +Visit {\field{\*\fldinst HYPERLINK "https://example.org/x"}{\fldrslt the site}} today {\field{\*\fldinst HYPERLINK "https://example.org/"}{\fldrslt https://example.org/}}\par +before {\v SECRET}after\par +{\strike struck} kept\par +Page {\field{\*\fldinst PAGE}{\fldrslt 7}}\par +note{\super\chftn{\footnote\pard\plain\chftn NOTETEXT}}\par +\trowd\trgaph108\cellx2000\cellx4000 +\pard\intbl r1c1\cell r1c2\cell\row +\trowd\trgaph108\cellx2000\cellx4000 +\pard\intbl r2c1\cell r2c2\cell\row +\pard Last\par} +"# + .to_vec() +} + +const WML_NS: &str = r#"xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml" xmlns:wx="http://schemas.microsoft.com/office/word/2003/auxHint" xmlns:aml="http://schemas.microsoft.com/aml/2001/core" xmlns:v="urn:schemas-microsoft-com:vml""#; + +fn wml() -> Vec<u8> { + format!( + r#"<?xml version="1.0"?><w:wordDocument {WML_NS}><w:body><wx:sect><wx:sub-section> +<w:p><w:r><w:t>Alpha beta gamma</w:t></w:r></w:p> +<w:p><w:r><w:t>Visit </w:t></w:r><w:hlink w:dest="https://example.org/x"><w:r><w:t>the site</w:t></w:r></w:hlink><w:r><w:t> today </w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText> HYPERLINK "https://example.org/" </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>https://example.org/</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p> +<w:p><w:r><w:t>before </w:t></w:r><w:r><w:rPr><w:vanish/></w:rPr><w:t>SECRET</w:t></w:r><w:r><w:t>after</w:t></w:r></w:p> +<w:p><w:r><w:rPr><w:strike/></w:rPr><w:t>struck</w:t></w:r><w:r><w:t> kept</w:t></w:r></w:p> +<w:p><w:r><w:t>Page </w:t></w:r><w:fldSimple w:instr=" PAGE "><w:r><w:t>7</w:t></w:r></w:fldSimple></w:p> +<w:p><w:r><w:t>note</w:t></w:r><w:r><w:footnote><w:p><w:r><w:footnoteRef/><w:t> NOTETEXT</w:t></w:r></w:p></w:footnote></w:r></w:p> +<w:p><w:r><w:t>word</w:t></w:r><aml:annotation aml:id="0" w:type="Word.Comment" aml:author="Ann Lee"><aml:content><w:p><w:r><w:t>COMMENTTEXT</w:t></w:r></w:p></aml:content></aml:annotation></w:p> +<w:tbl><w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid> +<w:tr><w:tc><w:p><w:r><w:t>r1c1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>r1c2</w:t></w:r></w:p></w:tc></w:tr> +<w:tr><w:tc><w:p><w:r><w:t>r2c1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>r2c2</w:t></w:r></w:p></w:tc></w:tr></w:tbl> +<w:p><w:r><w:t>Last</w:t></w:r></w:p> +<w:sectPr><w:hdr w:type="odd"><w:p><w:r><w:t>HEADTEXT</w:t></w:r></w:p></w:hdr><w:ftr w:type="odd"><w:p><w:r><w:t>FOOTTEXT</w:t></w:r></w:p></w:ftr></w:sectPr> +</wx:sub-section></wx:sect></w:body></w:wordDocument>"# + ) + .into_bytes() +} + +fn para(t: &str, runs: Vec<(usize, usize, Vec<u8>)>, grpprl: Vec<u8>) -> Para { + Para { + text: t.as_bytes().to_vec(), + runs, + grpprl, + } +} + +/// A Word 97 document with the same things in it, as the file a writer would +/// make. +fn doc97() -> Vec<u8> { + let spec = vec![0x55, 0x08, 1]; + let cell = vec![0x16, 0x24, 1]; + let mut row = vec![0x16, 0x24, 1, 0x17, 0x24, 1]; + // sprmTDefTable: two cells, edges 0, 2000, 4000, then a TC80 each. + let mut v = vec![2u8]; + for e in [0i16, 2000, 4000] { + v.extend(e.to_le_bytes()); + } + v.extend([0u8; 40]); + row.extend([0x08, 0xD6]); + row.extend((v.len() as u16 + 1).to_le_bytes()); + row.extend(v); + let paras = vec![ + para("Alpha beta gamma\r", vec![], vec![]), + para( + "Visit \x13 HYPERLINK \"https://example.org/x\" \x14the site\x15 today \x13 HYPERLINK \"https://example.org/\" \x14https://example.org/\x15\r", + vec![], + vec![], + ), + // sprmCFVanish on "SECRET". + para( + "before SECRETafter\r", + vec![(7, 13, vec![0x3C, 0x08, 1])], + vec![], + ), + // sprmCFStrike on "struck". + para("struck kept\r", vec![(0, 6, vec![0x37, 0x08, 1])], vec![]), + para("Page \x13 PAGE \x147\x15\r", vec![], vec![]), + para("note\x02\r", vec![(4, 5, spec.clone())], vec![]), + para("word\x05\r", vec![(4, 5, spec)], vec![]), + para("r1c1\x07", vec![], cell.clone()), + para("r1c2\x07", vec![], cell.clone()), + para("\x07", vec![], row.clone()), + para("r2c1\x07", vec![], cell.clone()), + para("r2c2\x07", vec![], cell), + para("\x07", vec![], row), + para("Last\r", vec![], vec![]), + ]; + let mark_at = |mark: u8| { + let mut cp = 0u32; + for p in ¶s { + if let Some(i) = p.text.iter().position(|c| *c == mark) { + return cp + i as u32; + } + cp += p.text.len() as u32; + } + panic!("no mark"); + }; + let head = b"HEADTEXT\r\r".to_vec(); + let foot = b"FOOTTEXT\r\r".to_vec(); + let (h, f) = (head.len() as u32, foot.len() as u32); + doc( + ¶s, + &Options { + // The default header of the first section is the story 7, the + // default footer the story 9. + hdd: [vec![0; 8], vec![h, h, h + f, h + f, h + f, h + f]].concat(), + header: [head, foot].concat(), + footnotes: vec![b"\x02 NOTETEXT\r".to_vec()], + footnote_refs: vec![mark_at(2)], + comments: vec![b"COMMENTTEXT\r".to_vec()], + comment_refs: vec![mark_at(5)], + ..Options::default() + }, + ) +} + +fn six(p: &[Para], header: Option<&[u8]>) -> Vec<u8> { + doc6( + p, + &Six { + header: header.map(<[u8]>::to_vec), + ..Six::default() + }, + ) +} + +/// A Word 6 document: words, a tab, a break, hidden and struck text, a header. +fn doc6_sample() -> Vec<u8> { + six( + &[ + para("Plain bold words\r", vec![(6, 10, vec![85, 1])], vec![]), + para("Second\ttab and\x0Bbreak\r", vec![], vec![]), + // sprmCFVanish, sprmCFStrike. + para( + "before SECRETafter\rstruck kept\r", + vec![(7, 13, vec![92, 1]), (19, 25, vec![87, 1])], + vec![], + ), + ], + Some(b"HEADTEXT\r\r"), + ) +} + +fn table(entries: &[(u64, Option<u64>)]) -> W { + let mut t = W::default(); + for (i, o) in entries { + let mut e = W::default().uint(1, *i); + if let Some(o) = o { + e = e.reference(2, *o); + } + t = t.msg(1, e); + } + t +} + +fn iwa(objects: &[(u64, u32, W)]) -> Vec<u8> { + let mut raw = vec![]; + for (id, ty, m) in objects { + let info = W::default().uint(1, *id).msg( + 2, + W::default() + .uint(1, u64::from(*ty)) + .uint(3, m.0.len() as u64), + ); + raw.extend(W::varint_len(info.0.len() as u64)); + raw.extend(&info.0); + raw.extend(&m.0); + } + let block = literal_block(&raw); + let mut out = vec![ + 0, + block.len() as u8, + (block.len() >> 8) as u8, + (block.len() >> 16) as u8, + ]; + out.extend(block); + out +} + +/// A Pages document of the paragraphs `body` (joined by line feeds). +fn pages_of(body: &str) -> Vec<u8> { + let storage = W::default() + .bytes(3, body.as_bytes()) + .msg(5, table(&[(0, Some(3))])) + .msg(8, table(&[(0, Some(4))])); + let objects = [ + (1, 10000, W::default().reference(4, 2)), + (2, 2001, storage), + (3, 2022, W::default().msg(11, W::default().float(3, 12.0))), + (4, 2021, W::default().msg(11, W::default())), + ]; + let mut z = PackageWriter::new(1 << 20); + z.put("Index/Document.iwa", &iwa(&objects), true).unwrap(); + z.finish().unwrap() +} + +fn every_document() -> Vec<(&'static str, Vec<u8>, Format)> { + vec![ + ("sample.doc", fixture("sample.doc"), Format::Doc), + ("sample.odt", fixture("sample.odt"), Format::Odt), + ("sample.fodt", fixture("sample.fodt"), Format::Fodt), + ("sample.rtf", fixture("sample.rtf"), Format::Rtf), + ( + "sample-2003.xml", + fixture("sample-2003.xml"), + Format::Wml2003, + ), + ("doc97", doc97(), Format::Doc), + ("doc6", doc6_sample(), Format::Doc), + ("fodt", fodt(), Format::Fodt), + ("odt", odt(), Format::Odt), + ("rtf", rtf(), Format::Rtf), + ("wml", wml(), Format::Wml2003), + ( + "pages", + pages_of("Title\nBody words here\nA third paragraph"), + Format::Pages, + ), + ] +} + +/// The text is `body`, then the sections, each with its label and text. +fn assert_text(t: &Text, body: &str, sections: &[(&str, &str)]) { + let all: String = std::iter::once(body) + .chain(sections.iter().map(|s| s.1)) + .collect(); + assert_eq!(t.text, all); + assert_eq!(labels(t), sections.iter().map(|s| s.0).collect::<Vec<_>>()); + for (label, s) in sections { + assert_eq!(section(t, label), *s); + } + assert_eq!( + body.len(), + t.text.len() - sections.iter().map(|s| s.1.len()).sum::<usize>() + ); + assert!(!t.truncated); +} + +/// The link whose label is not its target; the other one, whose label is its +/// target, is written once and has no pair. +fn assert_links(t: &Text) { + assert_eq!( + t.links, + [("the site".to_string(), "https://example.org/x".to_string())] + ); +} + +const START: &str = "Alpha beta gamma\nVisit the site (https://example.org/x) today https://example.org/\nbefore after\nstruck kept\nPage\nnote\n"; + +const TABLE: &str = "r1c1\tr1c2\nr2c1\tr2c2\n"; + +#[test] +fn the_fixtures_have_their_words() { + let plain = "Quarterly summary\nRevenue grew by twelve percent over the quarter, while costs stayed flat.\nFirst item\nSecond item\nRegion\tSales\nNorth\t120\nEnd of the summary.\n"; + for (name, f) in [ + ("sample.doc", Format::Doc), + ("sample.odt", Format::Odt), + ("sample.fodt", Format::Fodt), + ] { + let t = extract(&fixture(name), f); + assert_text(&t, plain, &[]); + assert!(t.links.is_empty(), "{name}"); + } + // The RTF fixture writes its bullets as text. + let t = extract(&fixture("sample.rtf"), Format::Rtf); + assert_text( + &t, + &plain + .replace("First", "\u{2022}\tFirst") + .replace("Second", "\u{2022}\tSecond"), + &[], + ); + // A link whose label is not its target. + let t = extract(&fixture("sample-2003.xml"), Format::Wml2003); + assert_text( + &t, + "Title\nblue italic link (https://example.org/)\nc1\tc2\n", + &[], + ); + assert_eq!( + t.links, + [("link".to_string(), "https://example.org/".to_string())] + ); +} + +#[test] +fn every_document_has_the_words_of_the_docx_the_converter_writes() { + for (name, input, f) in every_document() { + same_words_as_the_converter(name, &input, f); + } +} + +#[test] +fn a_word_97_document_has_everything_a_reader_sees() { + let t = extract(&doc97(), Format::Doc); + assert_text( + &t, + &format!("{START}word\n{TABLE}Last\n"), + &[ + ("headers and footers", "HEADTEXT\nFOOTTEXT\n"), + ("footnotes", "NOTETEXT\n"), + ("comments", "COMMENTTEXT\n"), + ], + ); + assert_links(&t); +} + +#[test] +fn a_word_6_document_has_its_words_and_its_header() { + let t = extract(&doc6_sample(), Format::Doc); + assert_text( + &t, + "Plain bold words\nSecond\ttab and\nbreak\nbefore after\nstruck kept\n", + &[("headers and footers", "HEADTEXT\n")], + ); +} + +#[test] +fn tracked_changes_are_text() { + let p = Para { + text: b"gone new kept\r".to_vec(), + // sprmCFRMarkDel on "gone", sprmCFRMark (inserted) on "new". + runs: vec![(0, 4, vec![0x00, 0x08, 1]), (5, 8, vec![0x01, 0x08, 1])], + grpprl: vec![], + }; + let t = extract(&doc(&[p], &Options::default()), Format::Doc); + assert_text(&t, "gone new kept\n", &[]); +} + +#[test] +fn an_open_document_text_has_everything_a_reader_sees() { + let all = |t: &Text| { + assert_text( + t, + &format!("{START}end\nword\n{TABLE}Boxed text\nBOXTEXT\nLast\n"), + &[ + ("headers and footers", "HEADTEXT\nFOOTTEXT\n"), + ("footnotes", "NOTETEXT\n"), + ("endnotes", "ENDTEXT\n"), + ("comments", "COMMENTTEXT\n"), + ], + ); + assert_links(t); + }; + all(&extract(&fodt(), Format::Fodt)); + all(&extract(&odt(), Format::Odt)); +} + +#[test] +fn an_rtf_has_everything_a_reader_sees_but_comments() { + let t = extract(&rtf(), Format::Rtf); + assert_text( + &t, + &format!("{START}{TABLE}Last\n"), + &[ + ("headers and footers", "HEADTEXT\nFOOTTEXT\n"), + ("footnotes", "NOTETEXT\n"), + ], + ); + assert_links(&t); +} + +/// A `}` too many closes the document's group; what follows is still the +/// document (html2rtf writes such files, and unrtf reads them whole). +#[test] +fn an_rtf_with_a_brace_too_many_goes_on() { + let t = extract( + br"{\rtf1\ansi{\b First}\par}} Second\par Third\par}", + Format::Rtf, + ); + let words: Vec<&str> = t.text.split_whitespace().collect(); + assert_eq!(words, ["First", "Second", "Third"], "{:?}", t.text); +} + +/// A note in a list four levels deep is still a note (a bibliography's +/// endnote in a nested bullet list lost its text), and notes in notes stop +/// after a few levels. +#[test] +fn a_note_in_a_deeply_nested_list_keeps_its_text() { + let note = |body: &str| { + format!( + r#"<text:note text:note-class="endnote"><text:note-citation>1</text:note-citation><text:note-body>{body}</text:note-body></text:note>"# + ) + }; + let mut body = format!("<text:p>item{}</text:p>", note("<text:p>NOTETEXT</text:p>")); + for _ in 0..4 { + body = format!("<text:list><text:list-item>{body}</text:list-item></text:list>"); + } + let fodt = format!( + r#"<?xml version="1.0"?><office:document {ODF_NS} office:mimetype="application/vnd.oasis.opendocument.text"><office:body><office:text>{body}</office:text></office:body></office:document>"# + ); + let t = extract(fodt.as_bytes(), Format::Fodt); + assert_eq!(section(&t, "endnotes"), "NOTETEXT\n"); + + // Notes inside notes: the first levels are read, a long chain is cut. + let mut chain = "<text:p>LEAF</text:p>".to_string(); + for level in (1..=6).rev() { + chain = format!("<text:p>L{level}{}</text:p>", note(&chain)); + } + let fodt = format!( + r#"<?xml version="1.0"?><office:document {ODF_NS} office:mimetype="application/vnd.oasis.opendocument.text"><office:body><office:text>{chain}</office:text></office:body></office:document>"# + ); + let t = extract(fodt.as_bytes(), Format::Fodt); + let notes = section(&t, "endnotes"); + assert!(notes.contains("L2") && !notes.contains("LEAF"), "{notes:?}"); +} + +#[test] +fn a_word_2003_xml_has_everything_a_reader_sees() { + let t = extract(&wml(), Format::Wml2003); + assert_text( + &t, + &format!("{START}word\n{TABLE}Last\n"), + &[ + ("headers and footers", "HEADTEXT\nFOOTTEXT\n"), + ("footnotes", "NOTETEXT\n"), + ("comments", "COMMENTTEXT\n"), + ], + ); + assert_links(&t); +} + +#[test] +fn a_header_the_sections_share_is_read_once() { + let sect = |footer: &str| { + format!( + r#"<w:sectPr><w:hdr w:type="odd"><w:p><w:r><w:t>SAME</w:t></w:r></w:p></w:hdr><w:ftr w:type="odd"><w:p><w:r><w:t>{footer}</w:t></w:r></w:p></w:ftr></w:sectPr>"# + ) + }; + let x = format!( + r#"<?xml version="1.0"?><w:wordDocument {WML_NS}><w:body><wx:sect><wx:sub-section><w:p><w:r><w:t>one</w:t></w:r></w:p>{}</wx:sub-section><wx:sub-section><w:p><w:r><w:t>two</w:t></w:r></w:p>{}</wx:sub-section></wx:sect></w:body></w:wordDocument>"#, + sect("F1"), + sect("F2") + ); + let t = extract(x.as_bytes(), Format::Wml2003); + assert_text( + &t, + "one\ntwo\n", + &[("headers and footers", "SAME\nF1\nF2\n")], + ); + // The converter writes a header part for each of the two sections. + let c = convert(x.as_bytes(), Format::Wml2003, &Limits::default()).unwrap(); + assert_eq!( + docx_words(&c.docx).iter().filter(|w| *w == "SAME").count(), + 2 + ); +} + +#[test] +fn word_2003_xml_fields_make_links_and_page_numbers_are_left_out() { + let x = format!( + r#"<?xml version="1.0"?><w:wordDocument {WML_NS}><w:body> +<w:p><w:fldSimple w:instr=" HYPERLINK "https://example.org/simple" "><w:r><w:t>simple link</w:t></w:r></w:fldSimple><w:r><w:t> and </w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText> HYPERLINK "https://example.org/complex" \o "tip" </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>complex link</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p> +<w:p><w:r><w:t>page </w:t></w:r><w:fldSimple w:instr=" PAGE \* MERGEFORMAT "><w:r><w:t>3</w:t></w:r></w:fldSimple><w:r><w:t> of </w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText> NUMPAGES </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>9</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:r><w:t> date </w:t></w:r><w:fldSimple w:instr=" DATE "><w:r><w:t>today</w:t></w:r></w:fldSimple></w:p> +</w:body></w:wordDocument>"# + ); + let t = extract(x.as_bytes(), Format::Wml2003); + assert_text( + &t, + "simple link (https://example.org/simple) and complex link (https://example.org/complex)\npage of date today\n", + &[], + ); + assert_eq!( + t.links, + [ + ( + "simple link".to_string(), + "https://example.org/simple".to_string() + ), + ( + "complex link".to_string(), + "https://example.org/complex".to_string() + ) + ] + ); + same_words_as_the_converter("fields", x.as_bytes(), Format::Wml2003); +} + +#[test] +fn a_pages_document_has_its_paragraphs() { + let t = extract(&pages_of("Title\nBody words here"), Format::Pages); + assert_text(&t, "Title\nBody words here\n", &[]); +} + +#[test] +fn hidden_text_is_left_out_of_every_format_and_struck_text_is_not() { + for (name, input, f) in every_document() { + let t = extract(&input, f); + assert!(!t.text.contains("SECRET"), "{name}"); + assert!( + !t.text.contains("PAGE") && !t.text.contains("HYPERLINK"), + "{name}: a field instruction" + ); + if ["doc97", "fodt", "odt", "rtf", "wml"].contains(&name) { + assert!(t.text.contains("struck kept"), "{name}"); + assert!(t.text.contains("before after"), "{name}"); + // The page number field is left out, its label is not. + assert!(!t.text.contains('7') && t.text.contains("Page\n"), "{name}"); + } + } +} + +#[test] +fn a_style_can_hide_a_paragraph() { + let styles = r#"<office:automatic-styles><style:style style:name="HidP" style:family="paragraph"><style:text-properties text:display="none"/></style:style><style:style style:name="HidC" style:family="text"><style:text-properties text:display="none"/></style:style></office:automatic-styles>"#; + let body = r#"<text:p>one</text:p><text:p text:style-name="HidP">HIDDENPARA</text:p><text:p>two <text:span text:style-name="HidC">HIDDENSPAN</text:span>three</text:p>"#; + let flat = format!( + r#"<office:document {ODF_NS}>{styles}<office:body><office:text>{body}</office:text></office:body></office:document>"# + ); + let t = extract(flat.as_bytes(), Format::Fodt); + assert_text(&t, "one\ntwo three\n", &[]); + // And through a style of a Word 2003 XML document. + let x = format!( + r#"<?xml version="1.0"?><w:wordDocument {WML_NS}><w:styles><w:style w:type="paragraph" w:styleId="Hid"><w:name w:val="Hid"/><w:rPr><w:vanish/></w:rPr></w:style><w:style w:type="character" w:styleId="HidC"><w:name w:val="HidC"/><w:rPr><w:vanish/></w:rPr></w:style></w:styles><w:body><w:p><w:r><w:t>one</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Hid"/></w:pPr><w:r><w:t>HIDDENPARA</w:t></w:r></w:p><w:p><w:r><w:t>two </w:t></w:r><w:r><w:rPr><w:rStyle w:val="HidC"/></w:rPr><w:t>HIDDENSPAN</w:t></w:r><w:r><w:t>three</w:t></w:r></w:p></w:body></w:wordDocument>"# + ); + let t = extract(x.as_bytes(), Format::Wml2003); + assert_text(&t, "one\ntwo three\n", &[]); +} + +#[test] +fn a_picture_has_its_alt_text_and_a_text_box_its_text() { + let b64 = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA="; + let body = format!( + r#"<text:p>Look <draw:frame text:anchor-type="as-char" svg:width="2cm" svg:height="3cm"><draw:image><office:binary-data>{b64}</office:binary-data></draw:image><svg:desc>A red kite</svg:desc></draw:frame>here</text:p><text:p>Next</text:p>"# + ); + let flat = format!( + r#"<office:document {ODF_NS}><office:body><office:text>{body}</office:text></office:body></office:document>"# + ); + let t = extract(flat.as_bytes(), Format::Fodt); + assert_text(&t, "Look here\nA red kite\nNext\n", &[]); +} + +#[test] +fn the_text_of_shapes_in_a_group_is_kept() { + let body = r#"<text:p>before <draw:g text:anchor-type="paragraph"><draw:frame svg:width="5cm" svg:height="2cm"><draw:text-box><text:p>BOXTEXT</text:p></draw:text-box></draw:frame><draw:g><draw:rect svg:width="3cm" svg:height="1cm"><text:p>RECTTEXT</text:p></draw:rect></draw:g></draw:g> after</text:p>"#; + let flat = format!( + r#"<office:document {ODF_NS}><office:body><office:text>{body}</office:text></office:body></office:document>"# + ); + let c = convert(flat.as_bytes(), Format::Fodt, &Limits::default()).unwrap(); + let words = docx_words(&c.docx); + for w in ["BOXTEXT", "RECTTEXT", "before", "after"] { + assert!(words.iter().any(|x| x == w), "{w}: {words:?}"); + } + same_words_as_the_converter("group", flat.as_bytes(), Format::Fodt); +} + +#[test] +fn spanned_empty_and_nested_cells_keep_a_row_on_a_line() { + let c = r#"<table:table><table:table-column table:number-columns-repeated="3"/> +<table:table-row><table:table-cell table:number-columns-spanned="2"><text:p>A</text:p></table:table-cell><table:covered-table-cell/><table:table-cell><text:p>B</text:p></table:table-cell></table:table-row> +<table:table-row><table:table-cell><text:p>x</text:p><text:p>y</text:p></table:table-cell><table:table-cell/><table:table-cell><text:p>C</text:p></table:table-cell></table:table-row> +<table:table-row><table:table-cell><table:table><table:table-column table:number-columns-repeated="2"/><table:table-row><table:table-cell><text:p>n1</text:p></table:table-cell><table:table-cell><text:p>n2</text:p></table:table-cell></table:table-row></table:table></table:table-cell><table:table-cell><text:p>D</text:p></table:table-cell><table:table-cell/></table:table-row> +</table:table><text:p>after</text:p>"#; + let flat = format!( + r#"<office:document {ODF_NS}><office:body><office:text>{c}</office:text></office:body></office:document>"# + ); + let t = extract(flat.as_bytes(), Format::Fodt); + assert_text(&t, "A\tB\nx y\t\tC\nn1\tn2\n\tD\nafter\n", &[]); + same_words_as_the_converter("spans", flat.as_bytes(), Format::Fodt); + + let x = format!( + r#"<?xml version="1.0"?><w:wordDocument {WML_NS}><w:body><w:tbl><w:tr><w:tc><w:p><w:r><w:t>one</w:t></w:r></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>in1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>in2</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:t>two</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>side</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:wordDocument>"# + ); + let t = extract(x.as_bytes(), Format::Wml2003); + assert_text(&t, "one\nin1\tin2\ntwo\tside\n", &[]); + same_words_as_the_converter("nested", x.as_bytes(), Format::Wml2003); +} + +#[test] +fn the_cap_cuts_the_text_of_every_format() { + for (name, input, f) in every_document() { + let full = extract(&input, f); + assert!(!full.truncated, "{name}"); + // A cap that holds all of it, and one byte less. + let t = text(&input, f, &Limits::default(), full.text.len()).unwrap(); + assert_eq!((&t.text, t.truncated), (&full.text, false), "{name}"); + let t = text(&input, f, &Limits::default(), full.text.len() - 1).unwrap(); + assert!(t.truncated, "{name}"); + assert!(full.text.starts_with(&t.text), "{name}"); + // Cut in the middle, and at nothing. + for cap in [0, 1, 7, 20, full.text.len() / 2] { + let t = text(&input, f, &Limits::default(), cap).unwrap(); + assert!(t.truncated, "{name} at {cap}"); + assert!(t.text.len() <= cap, "{name} at {cap}"); + assert!(full.text.starts_with(&t.text), "{name} at {cap}"); + for s in &t.sections { + assert!(s.range.end <= t.text.len(), "{name} at {cap}"); + } + } + } +} + +#[test] +fn the_cap_leaves_the_later_sections_out() { + let t = text(&doc97(), Format::Doc, &Limits::default(), 40).unwrap(); + assert!(t.truncated); + assert!(t.sections.is_empty()); + assert!(!t.text.contains("HEADTEXT")); + // Room for the body and part of the footnotes. + let full = extract(&doc97(), Format::Doc); + let at = full.sections[1].range.start + 3; + let t = text(&doc97(), Format::Doc, &Limits::default(), at).unwrap(); + assert!(t.truncated); + assert_eq!(labels(&t), ["headers and footers", "footnotes"]); + assert_eq!(section(&t, "footnotes"), "NOT"); +} + +// ---- refusals ---- + +/// Documents that are encrypted. +fn encrypted() -> Vec<(&'static str, Vec<u8>, Format)> { + let p = || para("x\r", vec![], vec![]); + let manifest = "<m><manifest:encryption-data xmlns:manifest=\"x\"/></m>"; + let storage = W::default().bytes(3, b"text").msg(5, table(&[(0, None)])); + let mut z = PackageWriter::new(1 << 20); + z.put("Index/Document.iwa", &iwa(&[(2, 2001, storage)]), true) + .unwrap(); + vec![ + ( + "doc97", + doc( + &[p()], + &Options { + encrypted: true, + ..Options::default() + }, + ), + Format::Doc, + ), + ( + "doc6", + doc6( + &[p()], + &Six { + flags: 0x0100, + ..Six::default() + }, + ), + Format::Doc, + ), + ("odt", odt_with("<a/>", "<a/>", Some(manifest)), Format::Odt), + ("pages", z.finish().unwrap(), Format::Pages), + ] +} + +#[test] +fn an_encrypted_document_is_refused_like_the_converter_refuses_it() { + for (name, input, f) in encrypted() { + assert_eq!( + text(&input, f, &Limits::default(), CAP).unwrap_err(), + Error::Encrypted, + "{name}" + ); + assert_eq!( + convert(&input, f, &Limits::default()).unwrap_err(), + Error::Encrypted, + "{name}" + ); + } +} + +/// Documents that are of the format and damaged, and what is said. +fn damaged() -> Vec<(&'static str, Vec<u8>, Format)> { + let wd = vec![0u8; 100]; + let deep = format!("{{\\rtf1 {}x{}}}", "{".repeat(500), "}".repeat(500)); + vec![ + ( + "doc: the WordDocument stream ends too soon", + build_cfb(&[("WordDocument", &wd)]), + Format::Doc, + ), + ( + "odt: the content is cut", + odt_with( + "<office:document-content><office:body><office:text><text:p", + "<a/>", + None, + ), + Format::Odt, + ), + ( + "fodt: the document is cut", + fodt()[..200].to_vec(), + Format::Fodt, + ), + ( + "rtf: groups nested past the bound", + deep.into_bytes(), + Format::Rtf, + ), + ( + "wml: the document is cut", + wml()[..300].to_vec(), + Format::Wml2003, + ), + ] +} + +#[test] +fn a_damaged_document_is_malformed_and_never_a_panic() { + for (name, input, f) in damaged() { + let e = text(&input, f, &Limits::default(), CAP).unwrap_err(); + assert_eq!(e.kind(), "malformed", "{name}: {e}"); + assert_eq!( + convert(&input, f, &Limits::default()).unwrap_err(), + e, + "{name}" + ); + } +} + +#[test] +fn what_is_not_of_the_format_is_refused_as_such() { + for f in [ + Format::Doc, + Format::Odt, + Format::Fodt, + Format::Rtf, + Format::Wml2003, + Format::Pages, + ] { + for input in [ + &b""[..], + b"plain text, no markup", + b"<a/>", + b"\x00\x01\x02\x03", + ] { + let e = text(input, f, &Limits::default(), CAP).unwrap_err(); + assert_eq!( + convert(input, f, &Limits::default()).unwrap_err(), + e, + "{f:?} {input:?}" + ); + } + } + let e = text(b"plain text", Format::Rtf, &Limits::default(), CAP).unwrap_err(); + assert_eq!(e.kind(), "not-format"); + // A Word 95 file is a document of the format this reader does not take. + let old = doc( + &[para("x\r", vec![], vec![])], + &Options { + fib_version: 0x0065, + ..Options::default() + }, + ); + let e = text(&old, Format::Doc, &Limits::default(), CAP).unwrap_err(); + assert_eq!(e.kind(), "unsupported"); +} + +#[test] +fn cut_and_flipped_documents_are_refused_as_the_converter_refuses_them() { + let limits = Limits::default().with_max_memory(64 << 20); + let (mut ok, mut refused) = (0, 0); + for (name, input, f) in every_document() { + let mut check = |d: &[u8]| match text(d, f, &limits, CAP) { + Ok(_) => ok += 1, + Err(e) => { + refused += 1; + assert_eq!(convert(d, f, &limits).unwrap_err(), e, "{name}"); + } + }; + let step = (input.len() / 60).max(1); + for cut in (0..input.len()).step_by(step) { + check(&input[..cut]); + } + let mut m = input.clone(); + let step = (input.len() / 200).max(1); + for i in (0..m.len()).step_by(step) { + m[i] ^= 0xFF; + check(&m); + m[i] ^= 0xFF; + } + } + assert!(ok > 100 && refused > 100, "{ok} read, {refused} refused"); +} + +#[test] +fn the_limits_bound_the_text_as_they_bound_the_conversion() { + let multi: Vec<_> = every_document() + .into_iter() + .filter(|d| d.0 != "sample-2003.xml") + .collect(); + for (name, input, f) in &multi { + let tight = [ + (Limits::default().with_max_paragraphs(1), "paragraphs"), + (Limits::default().with_max_memory(100), "memory"), + ]; + for (limits, what) in tight { + let e = text(input, *f, &limits, CAP).unwrap_err(); + assert_eq!(e.kind(), "too-large", "{name}: {e}"); + assert_eq!( + convert(input, *f, &limits).unwrap_err(), + e, + "{name}: {what}" + ); + } + let mut small = Limits::default(); + small.max_input = 10; + assert_eq!( + text(input, *f, &small, CAP).unwrap_err(), + Error::TooLarge("input"), + "{name}" + ); + // The same document within the limits. + text(input, *f, &Limits::default(), CAP).unwrap(); + } + // Table cells. + for name in ["doc97", "fodt", "odt", "rtf", "wml"] { + let (_, input, f) = every_document().into_iter().find(|d| d.0 == name).unwrap(); + let limits = Limits::default().with_max_cells(1); + assert_eq!( + text(&input, f, &limits, CAP).unwrap_err(), + Error::TooLarge("table cells"), + "{name}" + ); + } +} diff --git a/crates/exav-render/tests/office_text_sheets.rs b/crates/exav-render/tests/office_text_sheets.rs new file mode 100644 index 00000000..7975b969 --- /dev/null +++ b/crates/exav-render/tests/office_text_sheets.rs @@ -0,0 +1,819 @@ +//! The text of spreadsheets, taken from what the readers parse. + +use exav_render::formats::office::text::Text; +use exav_render::sheets::{Error, Format, Limits, convert, text}; +use std::io::{Read, Write}; + +const ALL: [(&str, Format); 8] = [ + ("sample.xls", Format::Xls), + ("sample.ods", Format::Ods), + ("sample.fods", Format::Fods), + ("sample.numbers", Format::Numbers), + ("numbers09.numbers", Format::Numbers), + ("sample-2003.xml", Format::SpreadsheetMl), + ("sample.html", Format::Html), + ("sample.slk", Format::Sylk), +]; + +fn fixture(name: &str) -> Vec<u8> { + std::fs::read(format!( + "{}/tests/fixtures/office/sheets/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +const CAP: usize = 1 << 20; + +fn of(data: &[u8], format: Format) -> Text { + text(data, format, &Limits::default(), CAP).unwrap() +} + +fn labels(t: &Text) -> Vec<&str> { + t.sections.iter().map(|s| s.label.as_str()).collect() +} + +fn zip(files: &[(&str, &[u8])]) -> Vec<u8> { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(vec![])); + for (name, data) in files { + z.start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + z.write_all(data).unwrap(); + } + z.finish().unwrap().into_inner() +} + +// Fixtures. + +const BUDGET: &str = "Budget\nItem\tAmount\tDate\nRent\t1200\t45296\nFood\t345.5\t45303\n\ +Travel\t89.99\t45323\nTotal\t1635.49\nMerged note across three columns\n"; + +#[test] +fn the_cells_of_the_fixtures() { + // The workbooks keep the date as a serial and the number without its + // format: the model holds no displayed text. + let want = format!("{BUDGET}Notes\nSecond sheet\t1\n"); + for name in [("sample.xls", Format::Xls), ("sample.ods", Format::Ods)] { + let t = of(&fixture(name.0), name.1); + assert_eq!(t.text, want, "{}", name.0); + assert_eq!(labels(&t), ["sheet \"Budget\"", "sheet \"Notes\""]); + assert!(!t.truncated && t.links.is_empty()); + } + let t = of(&fixture("sample.fods"), Format::Fods); + assert_eq!(t.text, format!("{BUDGET}Notes\nSecond sheet\tTRUE\n")); + // The text formats: the same table, one day later, and no date or + // amount where the table has none. + let t = of(&fixture("sample-2003.xml"), Format::SpreadsheetMl); + assert_eq!( + t.text, + "Budget\nItem\tAmount\tDate\nRent\t1200\t45297\nFood\t345.5\nTravel\t89.99\n\ + Total\t1635.49\nMerged note across three columns\nNotes\nSecond sheet\tTRUE\n" + ); + assert_eq!(labels(&t), ["sheet \"Budget\"", "sheet \"Notes\""]); + for (name, format) in [("sample.html", Format::Html), ("sample.slk", Format::Sylk)] { + let t = of(&fixture(name), format); + assert!( + t.text.contains( + "Item\tAmount\tDate\nRent\t1200\t45297\nFood\t345.5\nTravel\t89.99\n\ + Total\t1635.49\nMerged note across three columns\n" + ), + "{name}: {}", + t.text + ); + assert_eq!(t.sections.len(), 1); + } +} + +#[test] +fn numbers_documents() { + let t = of(&fixture("sample.numbers"), Format::Numbers); + assert_eq!(labels(&t), ["sheet \"Sheet 1\""]); + assert!( + t.text.starts_with("Sheet 1\nSheetJS\tB1\tC1\tD1\tE1\n"), + "{}", + t.text + ); + assert!(t.text.contains("A3\tB3:D3\n"), "{}", t.text); + let t = of(&fixture("numbers09.numbers"), Format::Numbers); + assert_eq!( + labels(&t), + [ + "sheet \"Checking - Account Categories\"", + "sheet \"Checking - Transactions\"", + "sheet \"Second sheet\"" + ] + ); + for s in [ + "Category\tAmount\nHome\t-872.4\n", + "Total\t-2575.9\n", + "Beginning Balance\t4650\n", + // A formula's cached value. + "101\t40087\tRent\tHome\t-775\t3875\n", + "Debit Card\t40102\tFill up SUV for camping trip\tGas\t-75\t3702.6\n", + "Second sheet\nTest\n0.5\t0.1\n0.6\n", + ] { + assert!(t.text.contains(s), "{s:?} in {}", t.text); + } +} + +// The XLSX the converter writes says the same. + +/// The unescaped text of an XML fragment's `<t>` elements, joined. +fn t_elements(xml: &str) -> String { + let mut out = String::new(); + let mut rest = xml; + while let Some(i) = rest.find("<t") { + rest = &rest[i + 2..]; + let Some(close) = rest.find('>') else { break }; + if !(rest.starts_with('>') || rest.starts_with(' ')) || rest[..close].ends_with('/') { + continue; + } + let body = &rest[close + 1..]; + let end = body.find("</t>").unwrap(); + out.push_str(&unescape(&body[..end])); + rest = &body[end..]; + } + out +} + +fn unescape(s: &str) -> String { + s.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +/// The blocks `<tag>...</tag>` of an XML part. +fn blocks<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> { + let (open, close) = (format!("<{tag}>"), format!("</{tag}>")); + let mut out = vec![]; + let mut rest = xml; + while let Some(i) = rest.find(&open) { + let body = &rest[i + open.len()..]; + let end = body.find(&close).unwrap(); + out.push(&body[..end]); + rest = &body[end + close.len()..]; + } + out +} + +/// The strings of an XLSX's shared string table and inline strings, and its +/// sheets' names. +fn xlsx_strings(xlsx: &[u8]) -> (Vec<String>, Vec<String>) { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(xlsx)).unwrap(); + let names: Vec<String> = z + .file_names() + .filter_map(|n| n.ok().map(|n| n.to_string())) + .collect(); + let mut strings = vec![]; + let mut sheets = vec![]; + for n in names { + let mut xml = String::new(); + z.by_name(&n).unwrap().read_to_string(&mut xml).unwrap(); + if n == "xl/sharedStrings.xml" { + strings.extend(blocks(&xml, "si").into_iter().map(t_elements)); + } else if n.starts_with("xl/worksheets/") && n.ends_with(".xml") { + strings.extend(blocks(&xml, "is").into_iter().map(t_elements)); + } else if n == "xl/workbook.xml" { + for part in xml.split("<sheet ").skip(1) { + let name = part.split("name=\"").nth(1).unwrap(); + sheets.push(unescape(name.split('"').next().unwrap())); + } + } + } + (strings, sheets) +} + +#[test] +fn the_text_has_the_cell_texts_of_the_xlsx() { + for (name, format) in ALL { + let data = fixture(name); + let t = of(&data, format); + let xlsx = convert(&data, format, &Limits::default()).unwrap().xlsx; + let (strings, sheets) = xlsx_strings(&xlsx); + assert!(!strings.is_empty() && !sheets.is_empty(), "{name}"); + let lines: Vec<&str> = t.text.lines().collect(); + // Every string of the XLSX is in the text, line by line. + for s in &strings { + for line in s.lines().map(str::trim).filter(|l| !l.is_empty()) { + assert!( + lines + .iter() + .any(|l| l.split('\t').any(|c| c.trim() == line)), + "{name}: {line:?} of the XLSX is not in {}", + t.text + ); + } + } + // And every word of the text is a number, a sheet name or one of them. + for line in &lines { + for cell in line.split('\t').map(str::trim).filter(|c| !c.is_empty()) { + let known = cell.parse::<f64>().is_ok() + || cell == "TRUE" + || cell == "FALSE" + || sheets.iter().any(|s| s == cell) + || strings.iter().any(|s| s.lines().any(|l| l.trim() == cell)); + assert!(known, "{name}: {cell:?} of the text is not in the XLSX"); + } + } + for s in &sheets { + assert!(t.text.contains(s.as_str()), "{name}: sheet {s}"); + } + } +} + +// A flat ODF spreadsheet with a hidden sheet, a comment, links, a formula, +// a boolean, an error, a validation and a shape. + +const ODF_NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0""#; + +/// Styles, automatic then body, as `office:document` and +/// `office:document-content` both hold them. +const ODF_BODY: &str = r##" + <office:automatic-styles> + <number:number-style style:name="N2"><number:number number:decimal-places="2" number:grouping="true"/></number:number-style> + <style:style style:name="money" style:family="table-cell" style:data-style-name="N2"/> + <style:style style:name="ta-hidden" style:family="table"><style:table-properties table:display="false"/></style:style> + </office:automatic-styles> + <office:body><office:spreadsheet> + <table:content-validations> + <table:content-validation table:name="v1" table:condition="of:cell-content-is-whole-number() and cell-content-is-between(1,10)" table:allow-empty-cell="true"> + <table:help-message table:title="Pick" table:display="true"><text:p>Enter a whole number</text:p></table:help-message> + <table:error-message table:message-type="stop" table:display="true" table:title="Oops"><text:p>Not a number</text:p></table:error-message> + </table:content-validation> + </table:content-validations> + <table:table table:name="Open"> + <table:table-row> + <table:table-cell office:value-type="string"><office:annotation><dc:creator>Eve</dc:creator><text:p>Note one</text:p><text:p>Note two</text:p></office:annotation><text:p>Plain</text:p></table:table-cell> + <table:table-cell office:value-type="string"><text:p><text:a xlink:href="https://example.com/x">Site</text:a></text:p></table:table-cell> + <table:table-cell office:value-type="string"><text:p><text:a xlink:href="https://example.com/">https://example.com/</text:a></text:p></table:table-cell> + </table:table-row> + <table:table-row> + <table:table-cell table:formula="of:=1+2" office:value-type="float" office:value="3"><text:p>3,00</text:p></table:table-cell> + <table:table-cell office:value-type="boolean" office:boolean-value="true"><text:p>TRUE</text:p></table:table-cell> + <table:table-cell table:formula="of:=1/0" office:value-type="string" office:string-value="" calcext:value-type="error"><text:p>#DIV/0!</text:p></table:table-cell> + </table:table-row> + <table:table-row> + <table:table-cell table:style-name="money" office:value-type="float" office:value="1200"><text:p>1,200.00</text:p></table:table-cell> + <table:table-cell table:content-validation-name="v1" office:value-type="float" office:value="5"><text:p>5</text:p></table:table-cell> + </table:table-row> + <table:shapes> + <draw:custom-shape svg:x="1cm" svg:y="1cm" svg:width="3cm" svg:height="2cm"><text:p>Words in a shape</text:p><draw:enhanced-geometry draw:type="rectangle"/></draw:custom-shape> + </table:shapes> + </table:table> + <table:table table:name="Stash" table:style-name="ta-hidden"> + <table:table-row> + <table:table-cell office:value-type="string"><text:p>hidden words</text:p></table:table-cell> + </table:table-row> + </table:table> + </office:spreadsheet></office:body>"##; + +fn fods() -> Vec<u8> { + format!( + r#"<?xml version="1.0" encoding="UTF-8"?> +<office:document {ODF_NS} office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">{ODF_BODY} +</office:document>"# + ) + .into_bytes() +} + +fn ods(manifest: Option<&str>) -> Vec<u8> { + let content = format!( + r#"<?xml version="1.0" encoding="UTF-8"?> +<office:document-content {ODF_NS} office:version="1.3">{ODF_BODY} +</office:document-content>"# + ); + let mut files = vec![ + ( + "mimetype", + &b"application/vnd.oasis.opendocument.spreadsheet"[..], + ), + ("content.xml", content.as_bytes()), + ]; + if let Some(m) = manifest { + files.push(("META-INF/manifest.xml", m.as_bytes())); + } + zip(&files) +} + +/// What both forms of the ODF document read as. +fn check_odf(t: &Text) { + assert_eq!( + &t.text[t.sections[0].range.clone()], + "Open\nPlain\tSite (https://example.com/x)\thttps://example.com/\n\ + 3\tTRUE\t#DIV/0!\n1200\t5\n" + ); + assert_eq!( + labels(t), + [ + "sheet \"Open\"", + "sheet \"Open\" comments", + "sheet \"Open\" drawings", + "hidden sheet \"Stash\"" + ], + "{}", + t.text + ); + let comments = &t.text[t.sections[1].range.clone()]; + assert_eq!( + comments, + "Note one\nNote two\nPick\nEnter a whole number\nOops\nNot a number\n" + ); + let drawings = &t.text[t.sections[2].range.clone()]; + assert_eq!(drawings, "Words in a shape\n"); + assert_eq!( + &t.text[t.sections[3].range.clone()], + "Stash\nhidden words\n" + ); + assert_eq!( + t.links, + [("Site".to_string(), "https://example.com/x".to_string())] + ); +} + +#[test] +fn flat_odf_features() { + check_odf(&of(&fods(), Format::Fods)); +} + +#[test] +fn odf_package_features() { + check_odf(&of(&ods(None), Format::Ods)); +} + +#[test] +fn an_encrypted_ods_is_refused() { + let manifest = r#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"><manifest:encryption-data manifest:checksum-type="SHA1/1K"/></manifest:file-entry></manifest:manifest>"#; + let e = text(&ods(Some(manifest)), Format::Ods, &Limits::default(), CAP).unwrap_err(); + assert_eq!(e, Error::Encrypted); +} + +#[test] +fn an_encrypted_numbers_document_is_refused() { + let enc = zip(&[("Index/Document.iwa", b"x"), (".iwpv2", b"x")]); + let e = text(&enc, Format::Numbers, &Limits::default(), CAP).unwrap_err(); + assert_eq!(e, Error::Encrypted); +} + +// XML Spreadsheet 2003. + +const XMLSS: &str = r##"<?xml version="1.0"?> +<?mso-application progid="Excel.Sheet"?> +<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" + xmlns:o="urn:schemas-microsoft-com:office:office" + xmlns:x="urn:schemas-microsoft-com:office:excel" + xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"> + <Styles><Style ss:ID="money"><NumberFormat ss:Format="#,##0.00"/></Style></Styles> + <Worksheet ss:Name="Shown"> + <Table> + <Row> + <Cell ss:HRef="https://example.com/x"><Data ss:Type="String">Site</Data></Cell> + <Cell ss:HRef="mailto:a@b.test"><Data ss:Type="String">a@b.test</Data></Cell> + <Cell ss:HRef="#Other!A1"><Data ss:Type="String">Jump</Data></Cell> + </Row> + <Row> + <Cell ss:Formula="=SUM(R[-1]C:R[-1]C)+1000"><Data ss:Type="Number">1005</Data></Cell> + <Cell><Data ss:Type="Boolean">0</Data></Cell> + <Cell><Data ss:Type="Error">#N/A</Data></Cell> + <Cell ss:StyleID="money"><Data ss:Type="Number">1200</Data></Cell> + </Row> + <Row> + <Cell><Data ss:Type="String">Commented</Data><Comment ss:Author="Eve"><Data>Be careful here</Data></Comment></Cell> + </Row> + </Table> + </Worksheet> + <Worksheet ss:Name="Tucked away"> + <Table><Row><Cell><Data ss:Type="String">hidden words</Data></Cell></Row></Table> + <WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"><Visible>SheetHidden</Visible></WorksheetOptions> + </Worksheet> + <Worksheet ss:Name="Deeper"> + <Table><Row><Cell><Data ss:Type="String">very hidden words</Data></Cell></Row></Table> + <WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"><Visible>SheetVeryHidden</Visible></WorksheetOptions> + </Worksheet> +</Workbook>"##; + +#[test] +fn xml_spreadsheet_features() { + let t = of(XMLSS.as_bytes(), Format::SpreadsheetMl); + assert_eq!( + t.text, + "Shown\nSite (https://example.com/x)\ta@b.test\tJump (#Other!A1)\n\ + 1005\tFALSE\t#N/A\t1200\nCommented\n\ + Be careful here\n\ + Tucked away\nhidden words\nDeeper\nvery hidden words\n" + ); + assert_eq!( + labels(&t), + [ + "sheet \"Shown\"", + "sheet \"Shown\" comments", + "hidden sheet \"Tucked away\"", + "hidden sheet \"Deeper\"" + ] + ); + // The formula's source is not text. + assert!(!t.text.contains("SUM") && !t.text.contains("1000")); + // A mailto link whose label is its address is not a pair. + assert_eq!( + t.links, + [ + ("Site".to_string(), "https://example.com/x".to_string()), + ("Jump".to_string(), "#Other!A1".to_string()), + ] + ); +} + +// HTML and SYLK. + +#[test] +fn html_links() { + let html = br#"<html><body><table> +<tr><td><a href="https://example.com/x">Site</a></td><td><a href="https://example.com/">https://example.com/</a></td></tr> +<tr><td>plain</td><td><a href="javascript:alert(1)">trap</a></td></tr> +</table></body></html>"#; + let t = of(html, Format::Html); + assert!( + t.text + .contains("Site (https://example.com/x)\thttps://example.com/\nplain\ttrap\n"), + "{}", + t.text + ); + assert_eq!( + t.links, + [("Site".to_string(), "https://example.com/x".to_string())] + ); +} + +#[test] +fn sylk_values() { + let slk = "ID;PWXL;N;E\r\nC;Y1;X1;K\"Text\"\r\nC;X2;KTRUE;Anote\r\nC;X3;K#N/A\r\n\ +C;Y2;X1;K3;E1+2\r\nC;X2;K0.5\r\nE\r\n"; + let t = of(slk.as_bytes(), Format::Sylk); + assert_eq!( + labels(&t), + ["sheet \"Sheet1\"", "sheet \"Sheet1\" comments"] + ); + assert_eq!(t.text, "Sheet1\nText\tTRUE\t#N/A\n3\t0.5\nnote\n"); +} + +// Excel 2 to 2003 workbooks, built record by record. + +fn rec(id: u16, body: &[u8]) -> Vec<u8> { + let mut r = id.to_le_bytes().to_vec(); + r.extend((body.len() as u16).to_le_bytes()); + r.extend(body); + r +} + +fn bof(biff8: bool, dt: u16) -> Vec<u8> { + let v = if biff8 { 0x0600u16 } else { 0x0500 }; + let mut b = v.to_le_bytes().to_vec(); + b.extend(dt.to_le_bytes()); + b.extend([0u8; 4]); + if biff8 { + b.extend([0u8; 8]); + } + rec(0x0809, &b) +} + +fn label(biff8: bool, row: u16, col: u16, s: &str) -> Vec<u8> { + let mut b = [ + row.to_le_bytes(), + col.to_le_bytes(), + 0x000Fu16.to_le_bytes(), + ] + .concat(); + b.extend((s.len() as u16).to_le_bytes()); + if biff8 { + b.push(0); + } + b.extend(s.as_bytes()); + rec(0x0204, &b) +} + +fn number(row: u16, col: u16, v: f64) -> Vec<u8> { + let mut b = [ + row.to_le_bytes(), + col.to_le_bytes(), + 0x000Fu16.to_le_bytes(), + ] + .concat(); + b.extend(v.to_le_bytes()); + rec(0x0203, &b) +} + +fn bool_err(row: u16, col: u16, v: u8, is_err: bool) -> Vec<u8> { + let mut b = [ + row.to_le_bytes(), + col.to_le_bytes(), + 0x000Fu16.to_le_bytes(), + ] + .concat(); + b.extend([v, is_err as u8]); + rec(0x0205, &b) +} + +/// A formula with a string result: the FORMULA record, then its STRING. +fn formula_string(biff8: bool, row: u16, col: u16, source: &str, cached: &str) -> Vec<u8> { + let mut rgce = vec![0x17, source.len() as u8]; + if biff8 { + rgce.push(0); + } + rgce.extend(source.as_bytes()); + let mut b = [ + row.to_le_bytes(), + col.to_le_bytes(), + 0x000Fu16.to_le_bytes(), + ] + .concat(); + b.extend([0, 0, 0, 0, 0, 0, 0xFF, 0xFF]); + b.extend([0u8; 6]); + b.extend((rgce.len() as u16).to_le_bytes()); + b.extend(rgce); + let mut out = rec(0x0006, &b); + let mut s = (cached.len() as u16).to_le_bytes().to_vec(); + if biff8 { + s.push(0); + } + s.extend(cached.as_bytes()); + out.extend(rec(0x0207, &s)); + out +} + +/// A BIFF5 note: the text is in the record. +fn note(row: u16, col: u16, s: &str) -> Vec<u8> { + let mut b = [row.to_le_bytes(), col.to_le_bytes()].concat(); + b.extend((s.len() as u16).to_le_bytes()); + b.extend(s.as_bytes()); + rec(0x001C, &b) +} + +fn utf16z(s: &str) -> Vec<u8> { + let mut b = (s.chars().count() as u32 + 1).to_le_bytes().to_vec(); + for u in s.encode_utf16().chain([0]) { + b.extend(u.to_le_bytes()); + } + b +} + +/// A BIFF8 hyperlink to a URL, with a display text. +fn hlink(row: u16, col: u16, display: &str, url: &str) -> Vec<u8> { + let mut b = [row, row, col, col] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::<Vec<u8>>(); + b.extend([ + 0xD0, 0xC9, 0xEA, 0x79, 0xF9, 0xBA, 0xCE, 0x11, 0x8C, 0x82, 0x00, 0xAA, 0x00, 0x4B, 0xA9, + 0x0B, + ]); + b.extend(2u32.to_le_bytes()); + // Has a moniker, absolute, has a display text. + b.extend(0x13u32.to_le_bytes()); + b.extend(utf16z(display)); + b.extend([ + 0xE0, 0xC9, 0xEA, 0x79, 0xF9, 0xBA, 0xCE, 0x11, 0x8C, 0x82, 0x00, 0xAA, 0x00, 0x4B, 0xA9, + 0x0B, + ]); + let u = utf16z(url); + b.extend((u.len() as u32 - 4).to_le_bytes()); + b.extend(&u[4..]); + rec(0x01B8, &b) +} + +/// A workbook of `sheets`: name, state (0 visible, 1 hidden, 2 very hidden) +/// and the records of the sheet. +fn workbook(biff8: bool, sheets: &[(&str, u8, Vec<u8>)]) -> Vec<u8> { + let eof = rec(0x000A, &[]); + let boundsheet = |pos: u32, state: u8, name: &str| { + let mut b = pos.to_le_bytes().to_vec(); + b.extend([state, 0, name.len() as u8]); + if biff8 { + b.push(0); + } + b.extend(name.as_bytes()); + rec(0x0085, &b) + }; + let subs: Vec<Vec<u8>> = sheets + .iter() + .map(|(_, _, body)| { + let mut s = bof(biff8, 0x10); + s.extend(body); + s.extend(&eof); + s + }) + .collect(); + let codepage = rec(0x0042, &1252u16.to_le_bytes()); + let head_len = bof(biff8, 5).len() + + codepage.len() + + sheets + .iter() + .map(|s| boundsheet(0, 0, s.0).len()) + .sum::<usize>() + + eof.len(); + let mut g = bof(biff8, 5); + g.extend(codepage); + let mut pos = head_len; + for (i, (name, state, _)) in sheets.iter().enumerate() { + g.extend(boundsheet(pos as u32, *state, name)); + pos += subs[i].len(); + } + g.extend(&eof); + assert_eq!(g.len(), head_len); + for s in subs { + g.extend(s); + } + g +} + +#[test] +fn biff5_features() { + let mut open = vec![]; + open.extend(label(false, 0, 0, "Hello")); + open.extend(number(0, 1, 2.5)); + open.extend(bool_err(1, 0, 1, false)); + open.extend(bool_err(1, 1, 0x07, true)); + open.extend(formula_string(false, 2, 0, "source words", "cached words")); + open.extend(note(0, 0, "check me")); + let mut stash = vec![]; + stash.extend(label(false, 0, 0, "hidden words")); + let wb = workbook(false, &[("Open", 0, open), ("Stash", 1, stash)]); + let t = of(&wb, Format::Xls); + assert_eq!( + t.text, + "Open\nHello\t2.5\nTRUE\t#DIV/0!\ncached words\ncheck me\nStash\nhidden words\n" + ); + assert_eq!( + labels(&t), + [ + "sheet \"Open\"", + "sheet \"Open\" comments", + "hidden sheet \"Stash\"" + ] + ); + assert!(!t.text.contains("source words")); +} + +#[test] +fn biff8_features() { + let mut open = vec![]; + open.extend(label(true, 0, 0, "Docs")); + open.extend(hlink(0, 0, "Docs", "https://example.com/docs")); + open.extend(label(true, 1, 0, "https://example.com/")); + open.extend(hlink(1, 0, "https://example.com/", "https://example.com/")); + open.extend(formula_string(true, 2, 0, "source words", "cached words")); + let mut deeper = vec![]; + deeper.extend(label(true, 0, 0, "very hidden words")); + let wb = workbook(true, &[("Open", 0, open), ("Deeper", 2, deeper)]); + let t = of(&wb, Format::Xls); + assert_eq!( + t.text, + "Open\nDocs (https://example.com/docs)\nhttps://example.com/\ncached words\n\ + Deeper\nvery hidden words\n" + ); + assert_eq!(labels(&t), ["sheet \"Open\"", "hidden sheet \"Deeper\""]); + assert_eq!( + t.links, + [("Docs".to_string(), "https://example.com/docs".to_string())] + ); +} + +#[test] +fn an_encrypted_workbook_is_refused() { + for filepass in [ + // RC4 with a verifier of another password. + [&1u16.to_le_bytes()[..], &[1, 0, 1, 0], &[0x5Au8; 48]].concat(), + // XOR with another key. + [&0u16.to_le_bytes()[..], &[0x34, 0x12, 0x78, 0x56]].concat(), + ] { + let mut s = bof(true, 5); + s.extend(rec(0x002F, &filepass)); + s.extend(rec(0x000A, &[])); + let e = text(&s, Format::Xls, &Limits::default(), CAP).unwrap_err(); + assert_eq!(e, Error::Encrypted); + } +} + +// Refusals and limits. + +#[test] +fn the_wrong_format_is_refused() { + let l = Limits::default(); + let xls = fixture("sample.xls"); + assert!(matches!( + text(&xls, Format::Ods, &l, CAP), + Err(Error::NotFormat(_)) + )); + assert!(matches!( + text(&xls, Format::Numbers, &l, CAP), + Err(Error::NotFormat(_)) + )); + let ods = fixture("sample.ods"); + assert!(matches!( + text(&ods, Format::Xls, &l, CAP), + Err(Error::NotFormat(_)) + )); + assert!(matches!( + text(b"", Format::Fods, &l, CAP), + Err(Error::NotFormat(_) | Error::Malformed(_)) + )); +} + +#[test] +fn damaged_documents_are_refused() { + let l = Limits::default(); + // The compound file's signature, then nothing. + let mut cfb = fixture("sample.xls")[..8].to_vec(); + cfb.extend([0u8; 600]); + assert!(matches!( + text(&cfb, Format::Xls, &l, CAP), + Err(Error::Malformed(_) | Error::Unsupported(_)) + )); + // A workbook cut inside its directory, and one with its sectors erased. + let xls = fixture("sample.xls"); + let mut erased = xls.clone(); + erased[512..1024].fill(0xFF); + for d in [&xls[..600], &erased[..]] { + let r = text(d, Format::Xls, &l, CAP); + assert!(matches!(r, Err(Error::Malformed(_))), "{r:?}"); + } + // XML cut short. + let truncated = text(&fods()[..400], Format::Fods, &l, CAP); + assert!( + matches!(truncated, Err(Error::Malformed(_))), + "{truncated:?}" + ); + let xml = br#"<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"><Worksheet"#; + let r = text(xml, Format::SpreadsheetMl, &l, CAP); + assert!(matches!(r, Err(Error::Malformed(_))), "{r:?}"); + let junk = zip(&[ + ( + "mimetype", + b"application/vnd.oasis.opendocument.spreadsheet", + ), + ("content.xml", b"<office:document-content><oops"), + ]); + let r = text(&junk, Format::Ods, &l, CAP); + assert!(matches!(r, Err(Error::Malformed(_))), "{r:?}"); +} + +#[test] +fn every_truncation_is_read_or_refused() { + for (name, format) in ALL { + let data = fixture(name); + let step = (data.len() / 200).max(1); + for n in (0..data.len()).step_by(step) { + let _ = text(&data[..n], format, &Limits::default(), CAP); + } + } +} + +#[test] +fn limits_are_kept() { + for (name, format) in ALL { + let data = fixture(name); + let cap = CAP; + let r = |l: Limits| text(&data, format, &l, cap).unwrap_err(); + assert_eq!( + r(Limits::default().with_max_cells(5)), + Error::TooLarge("cells"), + "{name}" + ); + assert_eq!( + r(Limits::default().with_max_memory(2000)), + Error::TooLarge("memory"), + "{name}" + ); + let mut small = Limits::default(); + small.max_input = 100; + assert_eq!(r(small), Error::TooLarge("input"), "{name}"); + // The converter refuses the same. + assert_eq!( + convert(&data, format, &Limits::default().with_max_cells(5)).unwrap_err(), + Error::TooLarge("cells") + ); + } +} + +// The cap. + +#[test] +fn the_cap_cuts_the_text() { + for (name, format) in ALL { + let data = fixture(name); + let full = of(&data, format); + assert!(!full.truncated, "{name}"); + let short = [0, 1, 7, 20, 64, 150, full.text.len() - 1]; + for cap in short.into_iter().filter(|&c| c < full.text.len()) { + let t = text(&data, format, &Limits::default(), cap).unwrap(); + assert!(t.text.len() <= cap, "{name} at {cap}: {}", t.text.len()); + assert!(t.truncated, "{name} at {cap}"); + assert!(full.text.starts_with(&t.text), "{name} at {cap}"); + } + for cap in [full.text.len(), full.text.len() + 1] { + let t = text(&data, format, &Limits::default(), cap).unwrap(); + assert_eq!(t.text, full.text, "{name} at {cap}"); + assert!(!t.truncated, "{name} at {cap}"); + } + } +} diff --git a/crates/exav-render/tests/office_text_slides.rs b/crates/exav-render/tests/office_text_slides.rs new file mode 100644 index 00000000..0317a719 --- /dev/null +++ b/crates/exav-render/tests/office_text_slides.rs @@ -0,0 +1,254 @@ +//! The text of presentations and drawings, taken from the model the readers +//! fill and not from a PPTX written and read back. + +use exav_render::slides::{Error, Format, Limits, convert, text}; +use std::io::Read; + +fn fixture(dir: &str, name: &str) -> Vec<u8> { + let p = format!( + "{}/tests/fixtures/office/{dir}/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read(&p).unwrap_or_else(|e| panic!("{p}: {e}")) +} + +fn words(format: Format, dir: &str, name: &str) -> exav_render::formats::office::text::Text { + text(&fixture(dir, name), format, &Limits::default(), 1 << 20).unwrap() +} + +const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; + +/// A flat presentation holding `pages` (draw:page elements). +fn fodp(pages: &str) -> Vec<u8> { + format!( + r#"<office:document {NS} office:mimetype="application/vnd.oasis.opendocument.presentation"><office:body><office:presentation>{pages}</office:presentation></office:body></office:document>"# + ) + .into_bytes() +} + +fn rect(body: &str) -> String { + format!( + r#"<draw:rect svg:x="1cm" svg:y="1cm" svg:width="5cm" svg:height="2cm">{body}</draw:rect>"# + ) +} + +fn of(doc: &[u8]) -> exav_render::formats::office::text::Text { + text(doc, Format::Fodp, &Limits::default(), 1 << 20).unwrap() +} + +#[test] +fn every_format_gives_the_words_of_its_slides_one_section_to_a_slide() { + for (name, format) in [ + ("sample.ppt", Format::Ppt), + ("sample.odp", Format::Odp), + ("sample.fodp", Format::Fodp), + ] { + let t = words(format, "slides", name); + assert_eq!( + t.text, + "Quarterly budget\nRevenue up 12%\nCosts flat\nPlan\nNotes\nSecond slide text\n", + "{name}" + ); + let labels: Vec<_> = t.sections.iter().map(|s| s.label.as_str()).collect(); + assert_eq!(labels, ["slide 1", "slide 2"], "{name}"); + assert_eq!( + &t.text[t.sections[1].range.clone()], + "Notes\nSecond slide text\n" + ); + assert!(!t.truncated); + } + let key = words(Format::Keynote, "slides", "sample.key"); + assert_eq!(key.text, "Example Presentation\nThis is a subtitle.\n"); +} + +#[test] +fn a_drawing_gives_its_pages_by_name() { + for (name, format) in [("sample.odg", Format::Odg), ("sample.fodg", Format::Fodg)] { + let t = words(format, "draw", name); + let labels: Vec<_> = t.sections.iter().map(|s| s.label.as_str()).collect(); + assert_eq!(labels, ["slide 1 \"Plan\"", "slide 2 \"Detail\""], "{name}"); + assert!( + t.text.contains("Red box") && t.text.contains("Detail"), + "{name}" + ); + // A shape on a layer that is not displayed is not read. + assert!(!t.text.contains("Hidden note"), "{name}"); + } +} + +#[test] +fn chart_text_is_text() { + let t = words(Format::Odp, "slides", "chart.odp"); + for w in ["Quarterly sales", "Sales", "Q1", "Q2", "Q3"] { + assert!(t.text.lines().any(|l| l == w), "{w}"); + } +} + +#[test] +fn a_hyperlink_is_its_label_then_its_target() { + let page = format!( + r#"<draw:page>{}</draw:page>"#, + rect( + r#"<text:p>go <text:a xlink:href="https://evil.test/login">paypal.com</text:a> now</text:p><text:p><text:a xlink:href="https://same.test/">https://same.test/</text:a></text:p>"# + ) + ); + let t = of(&fodp(&page)); + assert_eq!( + t.text, + "go paypal.com (https://evil.test/login) now\nhttps://same.test/\n" + ); + // Only a link whose label is not its target is a pair. + assert_eq!( + t.links, + [( + "paypal.com".to_string(), + "https://evil.test/login".to_string() + )] + ); +} + +#[test] +fn the_slide_number_is_not_text() { + let page = format!( + "<draw:page>{}</draw:page>", + rect("<text:p>Page <text:page-number>7</text:page-number> end</text:p>") + ); + let t = of(&fodp(&page)); + assert!( + t.text.contains("Page") && t.text.contains("end"), + "{}", + t.text + ); + // The reader writes the slide's own number (1) as the field's text. + assert!(!t.text.contains('1') && !t.text.contains('7'), "{}", t.text); +} + +#[test] +fn a_table_is_a_line_to_a_row_with_a_tab_between_cells() { + let page = r#"<draw:page><draw:frame svg:x="0cm" svg:y="0cm" svg:width="8cm" svg:height="3cm"><table:table><table:table-column table:number-columns-repeated="2"/><table:table-row><table:table-cell><text:p>a1</text:p></table:table-cell><table:table-cell><text:p>b1</text:p></table:table-cell></table:table-row><table:table-row><table:table-cell><text:p>a2</text:p></table:table-cell><table:table-cell><text:p>b2</text:p></table:table-cell></table:table-row></table:table></draw:frame></draw:page>"#; + let t = of(&fodp(page)); + assert_eq!(t.text, "a1\tb1\na2\tb2\n"); +} + +#[test] +fn a_hidden_slide_is_read_and_says_it_is_hidden() { + let pages = format!( + r#"<draw:page presentation:visibility="hidden">{}</draw:page><draw:page>{}</draw:page>"#, + rect("<text:p>secret</text:p>"), + rect("<text:p>shown</text:p>") + ); + let t = of(&fodp(&pages)); + let labels: Vec<_> = t.sections.iter().map(|s| s.label.as_str()).collect(); + assert_eq!(labels, ["hidden slide 1", "slide 2"]); + assert_eq!(t.text, "secret\nshown\n"); +} + +#[test] +fn text_a_master_draws_on_every_slide_is_read_apart_from_the_placeholders() { + let doc = format!( + r#"<office:document {NS} office:mimetype="application/vnd.oasis.opendocument.presentation"><office:master-styles><style:master-page style:name="M" style:display-name="Corporate">{}{}</style:master-page></office:master-styles><office:body><office:presentation><draw:page draw:master-page-name="M">{}</draw:page></office:presentation></office:body></office:document>"#, + r#"<draw:rect svg:x="0cm" svg:y="0cm" svg:width="4cm" svg:height="1cm"><text:p>Confidential notice</text:p></draw:rect>"#, + r#"<draw:frame presentation:class="title" svg:x="0cm" svg:y="0cm" svg:width="4cm" svg:height="1cm"><draw:text-box><text:p>Click to edit the title</text:p></draw:text-box></draw:frame>"#, + rect("<text:p>slide words</text:p>") + ); + let t = of(doc.as_bytes()); + let labels: Vec<_> = t.sections.iter().map(|s| s.label.as_str()).collect(); + assert_eq!(labels, ["slide 1", "master \"Corporate\""]); + assert!(t.text.contains("Confidential notice")); + assert!(!t.text.contains("Click to edit")); +} + +#[test] +fn the_cap_cuts_the_text_and_says_so() { + let pages = format!( + "<draw:page>{}</draw:page>", + rect("<text:p>aaaaaaaaaa</text:p><text:p>bbbbbbbbbb</text:p>") + ); + let t = text(&fodp(&pages), Format::Fodp, &Limits::default(), 15).unwrap(); + assert!(t.truncated); + assert!(t.text.len() <= 15); + assert!(t.text.starts_with("slide") || t.text.starts_with('a')); +} + +#[test] +fn it_refuses_what_the_converter_refuses() { + // Not that format. + assert!(matches!( + text( + &fixture("slides", "sample.ppt"), + Format::Odp, + &Limits::default(), + 1 << 20 + ), + Err(Error::NotFormat(_)) + )); + // Damaged: an error, no panic. + let mut damaged = fixture("slides", "sample.ppt"); + for b in damaged.iter_mut().skip(512).take(4096) { + *b = 0xFF; + } + assert!(text(&damaged, Format::Ppt, &Limits::default(), 1 << 20).is_err()); + // Past a limit. + let mut small = Limits::default(); + small.max_input = 100; + assert!(matches!( + text( + &fixture("slides", "sample.ppt"), + Format::Ppt, + &small, + 1 << 20 + ), + Err(Error::TooLarge(_)) + )); +} + +/// The words of the PPTX the converter writes for a fixture, in order. +fn pptx_words(pptx: &[u8]) -> Vec<String> { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + let mut names: Vec<String> = z + .file_names() + .filter_map(|n| n.ok().map(|n| n.to_string())) + .filter(|n| n.starts_with("ppt/slides/slide") && n.ends_with(".xml")) + .collect(); + names.sort_by_key(|n| { + n.trim_start_matches("ppt/slides/slide") + .trim_end_matches(".xml") + .parse::<u32>() + .unwrap_or(0) + }); + let mut out = vec![]; + for n in names { + let mut xml = String::new(); + z.by_name(&n).unwrap().read_to_string(&mut xml).unwrap(); + let mut rest = xml.as_str(); + while let Some(i) = rest.find("<a:t>") { + let from = i + 5; + let j = rest[from..].find("</a:t>").unwrap(); + out.push(rest[from..from + j].to_string()); + rest = &rest[from + j..]; + } + } + out +} + +#[test] +fn the_words_are_those_of_the_pptx_the_converter_writes() { + for (dir, name, format) in [ + ("slides", "sample.ppt", Format::Ppt), + ("slides", "sample.odp", Format::Odp), + ("slides", "sample.key", Format::Keynote), + ("draw", "sample.odg", Format::Odg), + ] { + let bytes = fixture(dir, name); + let c = convert(&bytes, format, &Limits::default()).unwrap(); + let t = text(&bytes, format, &Limits::default(), 1 << 20).unwrap(); + let mine: Vec<&str> = t.text.split_whitespace().collect(); + let theirs: Vec<String> = pptx_words(&c.pptx) + .iter() + .flat_map(|w| w.split_whitespace().map(String::from).collect::<Vec<_>>()) + .collect(); + for w in &theirs { + assert!(mine.contains(&w.as_str()), "{name}: {w} missing"); + } + } +} diff --git a/crates/exav-render/tests/ply.rs b/crates/exav-render/tests/ply.rs new file mode 100644 index 00000000..ee04c0f9 --- /dev/null +++ b/crates/exav-render/tests/ply.rs @@ -0,0 +1,692 @@ +//! The PLY reader on files written here from known geometry in every +//! encoding, with the lists, the properties and the elements PLY files +//! vary in; and on hostile files. + +mod mesh_common; + +use std::fmt::Write as _; +use std::io::Write as _; +use std::time::Duration; + +use exav_render::mesh::Scene; +use exav_render::ply::{Error, read}; +use mesh_common::*; + +#[derive(Clone, Copy, PartialEq)] +enum Enc { + Ascii, + Little, + Big, +} + +/// A property: its name, its type, and the type of its list's count. +type Prop = (&'static str, &'static str, Option<&'static str>); + +/// One element: its name, its properties and its rows, each row a list of +/// values for every property (one value for a scalar). +struct El { + name: &'static str, + props: Vec<Prop>, + rows: Vec<Vec<Vec<f64>>>, +} + +fn put(out: &mut Vec<u8>, enc: Enc, ty: &str, v: f64) { + if enc == Enc::Ascii { + write!(out, "{v} ").unwrap(); + return; + } + macro_rules! num { + ($t:ty) => {{ + let n = v as $t; + out.extend_from_slice(&if enc == Enc::Big { + n.to_be_bytes() + } else { + n.to_le_bytes() + }); + }}; + } + match ty { + "char" | "int8" => num!(i8), + "uchar" | "uint8" => num!(u8), + "short" | "int16" => num!(i16), + "ushort" | "uint16" => num!(u16), + "int" | "int32" => num!(i32), + "uint" | "uint32" => num!(u32), + "float" | "float32" => num!(f32), + "double" | "float64" => num!(f64), + _ => panic!("{ty}"), + } +} + +fn file(enc: Enc, elements: &[El]) -> Vec<u8> { + let mut out = b"ply\n".to_vec(); + out.extend_from_slice( + match enc { + Enc::Ascii => "format ascii 1.0\n", + Enc::Little => "format binary_little_endian 1.0\n", + Enc::Big => "format binary_big_endian 1.0\n", + } + .as_bytes(), + ); + out.extend_from_slice(b"comment made by the tests\n"); + for e in elements { + writeln!(out, "element {} {}", e.name, e.rows.len()).unwrap(); + for (name, ty, count) in &e.props { + match count { + Some(c) => writeln!(out, "property list {c} {ty} {name}").unwrap(), + None => writeln!(out, "property {ty} {name}").unwrap(), + } + } + } + out.extend_from_slice(b"end_header\n"); + for e in elements { + for row in &e.rows { + for ((_, ty, count), values) in e.props.iter().zip(row) { + if let Some(c) = count { + put(&mut out, enc, c, values.len() as f64); + } + for &v in values { + put(&mut out, enc, ty, v); + } + } + if enc == Enc::Ascii { + out.push(b'\n'); + } + } + } + out +} + +fn vertex_element(points: &[P], with_color: bool) -> El { + let mut props: Vec<Prop> = vec![ + ("x", "float", None), + ("y", "float", None), + ("z", "float", None), + ]; + if with_color { + props.extend([ + ("red", "uchar", None), + ("green", "uchar", None), + ("blue", "uchar", None), + ]); + } + El { + name: "vertex", + props, + rows: points + .iter() + .enumerate() + .map(|(i, p)| { + let mut row = vec![vec![p[0]], vec![p[1]], vec![p[2]]]; + if with_color { + row.extend(color_of(i).map(|c| vec![f64::from(c)])); + } + row + }) + .collect(), + } +} + +fn face_element(faces: &[Vec<usize>], count: &'static str, index: &'static str) -> El { + El { + name: "face", + props: vec![("vertex_indices", index, Some(count))], + rows: faces + .iter() + .map(|f| vec![f.iter().map(|&i| i as f64).collect()]) + .collect(), + } +} + +fn color_of(i: usize) -> [u8; 3] { + [(i * 30) as u8, 255 - (i * 30) as u8, (i * 7) as u8] +} + +fn cube_faces() -> Vec<Vec<usize>> { + CUBE_QUADS.iter().map(|q| q.to_vec()).collect() +} + +fn cube(enc: Enc) -> Vec<u8> { + file( + enc, + &[ + vertex_element(&CUBE, true), + face_element(&cube_faces(), "uchar", "int"), + ], + ) +} + +fn parse(bytes: &[u8]) -> Scene { + read(bytes, usize::MAX).unwrap() +} + +#[test] +fn a_cube_is_the_same_in_ascii_and_both_binary_orders() { + for enc in [Enc::Ascii, Enc::Little, Enc::Big] { + let s = parse(&cube(enc)); + assert_eq!(s.triangles(), 12); + assert_near(volume(&s), 1.0, "volume"); + assert_near(area(&s), 6.0, "area"); + assert_eq!(extent(&s), [[0.; 3], [1.; 3]]); + assert_eq!(s.warnings, Default::default()); + assert_sound(&s); + // Each corner has the colour its vertex was given. + let b = &s.batches[0]; + let colors = b.colors.as_ref().unwrap(); + for (i, p) in b.positions.chunks(3).enumerate() { + let v = CUBE + .iter() + .position(|c| (0..3).all(|k| f64::from(p[k]) == c[k])) + .unwrap(); + assert_eq!(&colors[i * 3..i * 3 + 3], &color_of(v), "vertex {v}"); + } + } +} + +#[test] +fn list_counts_and_indices_of_every_integer_type_are_read() { + for enc in [Enc::Ascii, Enc::Little, Enc::Big] { + for count in [ + "uchar", "char", "ushort", "short", "uint", "int", "uint8", "int32", + ] { + for index in [ + "uchar", "char", "ushort", "short", "uint", "int", "uint16", "int8", + ] { + let bytes = file( + enc, + &[ + vertex_element(&CUBE, false), + face_element(&cube_faces(), count, index), + ], + ); + let s = parse(&bytes); + assert_eq!(s.triangles(), 12, "{count} {index}"); + assert_near(volume(&s), 1.0, "volume"); + } + } + } +} + +#[test] +fn coordinates_of_every_type_are_read() { + for ty in ["float", "double", "int", "short", "uchar"] { + let points = TETRA; + let el = El { + name: "vertex", + props: vec![("x", ty, None), ("y", ty, None), ("z", ty, None)], + rows: points + .iter() + .map(|p| p.iter().map(|&v| vec![v]).collect()) + .collect(), + }; + let faces: Vec<Vec<usize>> = TETRA_FACES.iter().map(|f| f.to_vec()).collect(); + for enc in [Enc::Ascii, Enc::Little, Enc::Big] { + let s = parse(&file( + enc, + &[ + El { + name: el.name, + props: el.props.clone(), + rows: el.rows.clone(), + }, + face_element(&faces, "uchar", "uint"), + ], + )); + assert_eq!(s.triangles(), 4, "{ty}"); + assert_near(volume(&s), 1.0 / 6.0, ty); + } + } +} + +#[test] +fn a_pentagon_is_three_triangles_and_a_notched_polygon_is_covered_once() { + let pentagon: Vec<P> = (0..5) + .map(|k| { + let a = f64::from(k) * std::f64::consts::TAU / 5.0; + [a.cos(), a.sin(), 0.0] + }) + .collect(); + let bytes = file( + Enc::Little, + &[ + vertex_element(&pentagon, false), + El { + name: "face", + props: vec![("vertex_index", "int", Some("uchar"))], + rows: vec![vec![vec![0., 1., 2., 3., 4.]]], + }, + ], + ); + let s = parse(&bytes); + assert_eq!(s.triangles(), 3); + assert_near( + area(&s), + 2.5 * (std::f64::consts::TAU / 5.0).sin(), + "pentagon", + ); + // A U in the xz plane, facing the other way. + let u: Vec<P> = [ + [0., 0.], + [3., 0.], + [3., 2.], + [2., 2.], + [2., 1.], + [1., 1.], + [1., 2.], + [0., 2.], + ] + .iter() + .map(|p| [p[0], 0.0, p[1]]) + .collect(); + let bytes = file( + Enc::Ascii, + &[ + vertex_element(&u, false), + face_element(&[(0..8).rev().collect()], "uchar", "int"), + ], + ); + let s = parse(&bytes); + assert_eq!(s.triangles(), 6); + assert_near(area(&s), 5.0, "area"); +} + +#[test] +fn other_elements_and_properties_are_skipped_by_their_types() { + for enc in [Enc::Ascii, Enc::Little, Enc::Big] { + let mut vertex = vertex_element(&CUBE, true); + // Properties before, among and after the ones read, one a list. + vertex.props.insert(0, ("confidence", "double", None)); + vertex.props.insert(2, ("tex", "short", Some("uchar"))); + vertex.props.push(("flags", "uint", None)); + for (i, row) in vertex.rows.iter_mut().enumerate() { + row.insert(0, vec![0.25]); + row.insert(2, vec![1.0; i % 3]); + row.push(vec![4_000_000_000.0]); + } + let mut face = face_element(&cube_faces(), "uchar", "int"); + face.props.insert(0, ("material", "short", None)); + face.props.push(("texcoord", "float", Some("uchar"))); + for (i, row) in face.rows.iter_mut().enumerate() { + row.insert(0, vec![-3.0]); + row.push(vec![0.5; 2 * (i % 4)]); + } + let camera = El { + name: "camera", + props: vec![("fov", "float", None), ("name", "uchar", Some("uchar"))], + rows: vec![vec![vec![60.0], vec![65.0, 66.0, 67.0]]; 3], + }; + let edge = El { + name: "edge", + props: vec![("vertex1", "int", None), ("vertex2", "int", None)], + rows: vec![vec![vec![0.0], vec![1.0]]; 12], + }; + let s = parse(&file(enc, &[camera, vertex, face, edge])); + assert_eq!(s.triangles(), 12); + assert_near(volume(&s), 1.0, "volume"); + assert_eq!(extent(&s), [[0.; 3], [1.; 3]]); + assert!(s.batches[0].colors.is_some()); + assert_eq!(s.warnings, Default::default()); + } +} + +#[test] +fn normals_are_used_when_the_file_has_them() { + let mut vertex = vertex_element(&TETRA[..3], false); + vertex.props.extend([ + ("nx", "float", None), + ("ny", "float", None), + ("nz", "float", None), + ]); + for row in &mut vertex.rows { + row.extend([vec![0.0], vec![0.0], vec![-4.0]]); + } + let s = parse(&file( + Enc::Little, + &[vertex, face_element(&[vec![0, 1, 2]], "uchar", "int")], + )); + for n in s.batches[0].normals.chunks(3) { + assert_eq!(n, [0.0, 0.0, -1.0]); + } + let s = parse(&file( + Enc::Little, + &[ + vertex_element(&TETRA[..3], false), + face_element(&[vec![0, 1, 2]], "uchar", "int"), + ], + )); + assert_eq!(&s.batches[0].normals[..3], &[0.0, 0.0, 1.0]); +} + +#[test] +fn colours_of_each_type_are_scaled_to_bytes() { + let cases: [(&str, [f64; 3], [u8; 3]); 4] = [ + ("uchar", [255., 128., 0.], [255, 128, 0]), + ("ushort", [65535., 32896., 0.], [255, 128, 0]), + ("float", [1.0, 0.5, 0.0], [255, 128, 0]), + ("double", [2.0, -1.0, 0.2], [255, 0, 51]), + ]; + for (ty, value, want) in cases { + let vertex = El { + name: "vertex", + props: vec![ + ("x", "float", None), + ("y", "float", None), + ("z", "float", None), + ("r", ty, None), + ("g", ty, None), + ("b", ty, None), + ("alpha", ty, None), + ], + rows: TETRA[..3] + .iter() + .map(|p| { + vec![ + vec![p[0]], + vec![p[1]], + vec![p[2]], + vec![value[0]], + vec![value[1]], + vec![value[2]], + vec![0.0], + ] + }) + .collect(), + }; + let s = parse(&file( + Enc::Little, + &[vertex, face_element(&[vec![0, 1, 2]], "uchar", "int")], + )); + let c = s.batches[0].colors.as_ref().unwrap(); + assert_eq!(&c[..3], &want, "{ty}"); + } +} + +#[test] +fn headers_as_some_writers_make_them_are_read() { + // The remarks in braces of the format's own example. + let text = "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x { the x }\n\ + property float y\nproperty float z\nelement face 1\n\ + property list uchar int vertex_index { corners }\nend_header\n\ + 0 0 0\n1 0 0\n0 1 0\n3 0 1 2\n"; + assert_eq!(parse(text.as_bytes()).triangles(), 1); + // A comment keyword with no space after it, and a line of text with none. + let text = text.replace("ply\n", "ply\ncommentWFhZ\nCreated by Blender3D 246\n"); + assert_eq!(parse(text.as_bytes()).triangles(), 1); + // An empty list of faces before the vertices. + let text = "ply\nformat ascii 1.0\nelement face 0\nproperty list uchar int vertex_indices\n\ + element vertex 1\nproperty float x\nproperty float y\nproperty float z\n\ + end_header\n0 0 0\n"; + assert!(matches!( + read(text.as_bytes(), 10).unwrap_err(), + Error::Unsupported(_) + )); + // Faces with something in them still need the vertices first. + let text = text.replace("element face 0", "element face 1"); + let e = read(format!("{text}3 0 0 0\n").as_bytes(), 10).unwrap_err(); + assert!(e.to_string().contains("faces before vertices"), "{e}"); +} + +#[test] +fn vertices_with_no_z_are_flat() { + let text = "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\n\ + element face 1\nproperty list uchar int vertex_index\nend_header\n\ + 0 0\n2 0\n0 2\n3 0 1 2\n"; + let s = parse(text.as_bytes()); + assert_eq!(s.triangles(), 1); + assert_eq!(extent(&s), [[0., 0., 0.], [2., 2., 0.]]); + let e = read(text.replace("property float y\n", "").as_bytes(), 10).unwrap_err(); + assert!(e.to_string().contains("x and y"), "{e}"); +} + +#[test] +fn a_point_cloud_is_reported_as_unsupported() { + let bytes = file(Enc::Little, &[vertex_element(&CUBE, true)]); + let e = read(&bytes, 100).unwrap_err(); + assert!(matches!(e, Error::Unsupported(_))); + assert!(e.to_string().contains("point cloud"), "{e}"); + // A face element that holds no faces is the same. + let bytes = file( + Enc::Ascii, + &[ + vertex_element(&CUBE, false), + face_element(&[], "uchar", "int"), + ], + ); + assert!(matches!( + read(&bytes, 100).unwrap_err(), + Error::Unsupported(_) + )); +} + +#[test] +fn what_is_not_a_ply_file_is_refused() { + for bad in [ + &b""[..], + b"ply", + b"ply\n", + b"plyx\nformat ascii 1.0\nend_header\n", + b"ply\nformat ascii 1.0\nelement vertex 1\n", + b"hello\n", + b"\x7fELF", + b"ply\r", + ] { + assert_eq!(read(bad, 10).unwrap_err(), Error::NotPly, "{bad:?}"); + } + let cases: [(&[u8], &str); 5] = [ + (b"ply\nformat ebcdic 1.0\nend_header\n", "format"), + ( + b"ply\nproperty float x\nend_header\n", + "property before element", + ), + ( + b"ply\nformat ascii 1.0\nelement vertex 1\nproperty quad x\nend_header\n", + "property type", + ), + ( + b"ply\nformat ascii 1.0\nelement vertex -1\nend_header\n", + "element count", + ), + (b"ply\nelement vertex 1\nend_header\n", "no format line"), + ]; + for (bad, what) in cases { + let e = read(bad, 10).unwrap_err(); + assert!(e.to_string().contains(what), "{e}"); + } + // CRLF headers are fine. + let crlf = String::from_utf8(cube(Enc::Ascii)) + .unwrap() + .replace('\n', "\r\n"); + assert_eq!(parse(crlf.as_bytes()).triangles(), 12); +} + +#[test] +fn faces_that_cannot_be_drawn_are_dropped_and_counted() { + let faces = vec![vec![0, 1, 2], vec![0, 1, 99], vec![0, 1], vec![3, 2, 1, 0]]; + let s = parse(&file( + Enc::Ascii, + &[ + vertex_element(&CUBE, false), + face_element(&faces, "uchar", "int"), + ], + )); + assert_eq!(s.triangles(), 3); + assert_eq!(s.warnings.invalid, 1); + // A negative index is out of range too. + let el = El { + name: "face", + props: vec![("vertex_indices", "int", Some("uchar"))], + rows: vec![vec![vec![0., 1., 2.]], vec![vec![0., -1., 2.]]], + }; + let s = parse(&file(Enc::Little, &[vertex_element(&CUBE, false), el])); + assert_eq!(s.triangles(), 1); + assert_eq!(s.warnings.invalid, 1); + // Every face bad: an error, not an empty picture. + let bad = vec![vec![0, 1, 99]]; + let bytes = file( + Enc::Ascii, + &[ + vertex_element(&CUBE, false), + face_element(&bad, "uchar", "int"), + ], + ); + assert!(matches!(read(&bytes, 10).unwrap_err(), Error::Invalid(_))); + // Vertices without a y. + let mut v = vertex_element(&CUBE, false); + v.props.remove(1); + v.rows.iter_mut().for_each(|r| drop(r.remove(1))); + let bytes = file( + Enc::Ascii, + &[v, face_element(&cube_faces(), "uchar", "int")], + ); + assert!(matches!(read(&bytes, 10).unwrap_err(), Error::Invalid(_))); +} + +const SQUARE_HEADER: &str = "ply\nformat ascii 1.0\nelement vertex 4\nproperty float x\n\ + property float y\nproperty float z\nelement face 2\nproperty list uchar int vertex_indices\n\ + end_header\n"; + +#[test] +fn an_ascii_line_with_more_values_than_the_header_names_is_one_record() { + // Each face line has three values the header does not declare, as the + // VCG library wrote its wedge colours. + let extra = + format!("{SQUARE_HEADER}0 0 0\n1 0 0\n1 1 0\n0 1 0\n3 0 1 2 9 9 9\n3 0 2 3 9 9 9\n"); + let s = parse(extra.as_bytes()); + assert_eq!(s.triangles(), 2); + assert!(!s.warnings.damaged); + assert_eq!(s.warnings.invalid, 0); + // A file that is right as a stream of values is read as one: records + // that share a line or run over several. + let stream = format!("{SQUARE_HEADER}0 0 0 1 0 0\n1 1 0 0 1 0\n3 0 1 2 3 0 2\n3\n"); + let s = parse(stream.as_bytes()); + assert_eq!(s.triangles(), 2); + assert!(!s.warnings.damaged); +} + +#[test] +fn a_file_that_ends_early_gives_what_it_holds() { + let whole = cube(Enc::Little); + // Cut in the faces: the first faces are kept. + let s = parse(&whole[..whole.len() - 7]); + assert!(s.warnings.damaged); + assert!( + s.triangles() >= 8 && s.triangles() < 12, + "{}", + s.triangles() + ); + // Cut in the vertices: no face can be read. + let cut = whole.len() - 6 * 8 - 8 * 15; + assert!(read(&whole[..cut], 100).is_err()); +} + +#[test] +fn coordinates_that_are_not_numbers_or_not_sizes_drop_their_faces() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e13] { + let mut points = CUBE.to_vec(); + points[6] = [bad, 0.0, 0.0]; + let s = parse(&file( + Enc::Little, + &[ + vertex_element(&points, false), + face_element(&cube_faces(), "uchar", "int"), + ], + )); + // The three quads at corner 6 are gone, three are whole. + assert_eq!(s.triangles(), 6, "{bad}"); + assert_eq!(s.warnings.invalid, 3, "{bad}"); + assert_sound(&s); + } +} + +#[test] +fn the_budget_stops_the_reading() { + let s = read(&cube(Enc::Ascii), 5).unwrap(); + assert_eq!(s.triangles(), 5); + assert_eq!(s.warnings.truncated, 1); + assert_eq!(read(&cube(Enc::Ascii), 12).unwrap().warnings.truncated, 0); +} + +#[test] +fn counts_that_the_data_cannot_hold_are_cut_to_the_data() { + // Four billion vertices and faces, a few bytes of data. + for (enc, tail) in [ + (Enc::Ascii, "0 0 0\n1 0 0\n0 1 0\n3 0 1 2\n"), + (Enc::Little, ""), + ] { + let mut text = format!( + "ply\nformat {} 1.0\nelement vertex 4294967295\nproperty float x\nproperty float y\nproperty float z\nelement face 18446744073709551615\nproperty list uchar int vertex_indices\nend_header\n", + if enc == Enc::Ascii { "ascii" } else { "binary_little_endian" }, + ) + .into_bytes(); + text.extend_from_slice(tail.as_bytes()); + let started = std::time::Instant::now(); + let r = read(&text, 1000); + assert!(started.elapsed() < Duration::from_secs(5)); + if let Ok(s) = r { + assert_sound(&s); + assert!(s.warnings.damaged); + } + } + // A list that claims four billion items. + let mut b = b"ply\nformat binary_little_endian 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 2\nproperty list uint int vertex_indices\nend_header\n".to_vec(); + for p in &TETRA[..3] { + for v in p { + b.extend_from_slice(&(*v as f32).to_le_bytes()); + } + } + b.extend_from_slice(&u32::MAX.to_le_bytes()); + b.extend_from_slice(&[0; 12]); + let r = within(Duration::from_secs(5), "the long list", || read(&b, 1000)); + assert!(r.is_err()); + // An element with no properties and four billion rows has nothing to read. + let b = b"ply\nformat binary_little_endian 1.0\nelement junk 4000000000\nelement vertex 0\nproperty float x\nproperty float y\nproperty float z\nend_header\n"; + within(Duration::from_secs(5), "the empty element", || read(b, 10)).unwrap_err(); +} + +#[test] +fn headers_with_too_much_in_them_are_refused() { + let mut text = String::from("ply\nformat ascii 1.0\nelement vertex 1\n"); + for k in 0..100_000 { + writeln!(text, "property float p{k}").unwrap(); + } + text.push_str("end_header\n0\n"); + let r = within(Duration::from_secs(5), "many properties", || { + read(text.as_bytes(), 10) + }); + assert!(r.is_err()); + let mut text = String::from("ply\nformat ascii 1.0\n"); + for k in 0..10_000 { + writeln!(text, "element e{k} 1\nproperty float x").unwrap(); + } + text.push_str("end_header\n"); + assert!(read(text.as_bytes(), 10).is_err()); + // A header that never ends. + let mut text = String::from("ply\nformat ascii 1.0\n"); + text.push_str(&"comment ".repeat(100_000)); + assert_eq!(read(text.as_bytes(), 10).unwrap_err(), Error::NotPly); +} + +#[test] +fn a_polygon_of_a_million_corners_is_cut_at_the_budget_in_linear_time() { + let n = 1_000_000usize; + let points: Vec<P> = (0..n).map(|k| [k as f64, (k % 2) as f64, 0.0]).collect(); + let face = El { + name: "face", + props: vec![("vertex_indices", "int", Some("uint"))], + rows: vec![vec![(0..n).map(|k| k as f64).collect()]], + }; + let bytes = file(Enc::Little, &[vertex_element(&points, false), face]); + let s = within(Duration::from_secs(20), "the million-gon", || { + read(&bytes, 200_000).unwrap() + }); + assert_eq!(s.triangles(), 200_000); + assert_eq!(s.warnings.truncated, 1); + assert_sound(&s); +} + +#[test] +fn a_file_cut_anywhere_or_changed_anywhere_is_read_soundly() { + for enc in [Enc::Ascii, Enc::Little, Enc::Big] { + sweep(&cube(enc), |b| read(b, 1000).ok()); + } +} diff --git a/crates/exav-render/tests/raw.rs b/crates/exav-render/tests/raw.rs new file mode 100644 index 00000000..50d2fe98 --- /dev/null +++ b/crates/exav-render/tests/raw.rs @@ -0,0 +1,705 @@ +//! Camera RAW files, shown by their embedded previews: synthetic containers of +//! each family (tests/fixtures/raw/make.py), whose previews are four colour +//! quadrants, so that what was chosen and how it was turned shows in the +//! pixels. Hostile files are those fixtures with a field patched. +#![cfg(feature = "raw")] + +use std::cell::Cell; +use std::sync::Once; +use std::time::{Duration, Instant}; + +use exav_render::image::raw::{self, Kind}; +use exav_render::image::{DECODE_MAX, Error, Format, Pixels, Samples, decode, decode_any}; + +fn fixture(name: &str) -> Vec<u8> { + let path = format!("{}/tests/fixtures/raw/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +thread_local!(static PANICKED: Cell<bool> = const { Cell::new(false) }); + +/// Records a panic on this thread, which the readers would otherwise turn +/// into `Undecodable` without a trace. +fn watch_panics() { + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + PANICKED.with(|p| p.set(true)); + previous(info); + })); + }); + PANICKED.with(|p| p.set(false)); +} + +fn panicked() -> bool { + PANICKED.with(|p| p.get()) +} + +fn rgb(p: &Pixels) -> &[u8] { + assert_eq!(p.channels.count(), 3, "RGB pixels expected"); + match &p.samples { + Samples::U8(v) => v, + other => panic!("8-bit samples expected, got {other:?}"), + } +} + +/// The pixel at the middle of quadrant (`qx`, `qy`), each 0 or 1. +fn quadrant(p: &Pixels, qx: usize, qy: usize) -> [u8; 3] { + let (w, h) = (p.width as usize, p.height as usize); + let (x, y) = (w / 4 + qx * w / 2, h / 4 + qy * h / 2); + let at = (y * w + x) * 3; + rgb(p)[at..at + 3].try_into().unwrap() +} + +const RED: [u8; 3] = [255, 0, 0]; +const GREEN: [u8; 3] = [0, 255, 0]; +const BLUE: [u8; 3] = [0, 0, 255]; +const YELLOW: [u8; 3] = [255, 255, 0]; + +/// Within JPEG's loss of the four colours. +fn is_colour(got: [u8; 3], want: [u8; 3]) -> bool { + got.iter().zip(want).all(|(&g, w)| g.abs_diff(w) < 40) +} + +/// The quadrants as seen, top left, top right, bottom left, bottom right. +fn quadrants(p: &Pixels) -> [[u8; 3]; 4] { + [ + quadrant(p, 0, 0), + quadrant(p, 1, 0), + quadrant(p, 0, 1), + quadrant(p, 1, 1), + ] +} + +fn assert_quadrants(p: &Pixels, want: [[u8; 3]; 4], what: &str) { + let got = quadrants(p); + for (g, w) in got.iter().zip(want) { + assert!(is_colour(*g, w), "{what}: got {got:?}, want {want:?}"); + } +} + +const AS_STORED: [[u8; 3]; 4] = [RED, GREEN, BLUE, YELLOW]; + +fn open(name: &str) -> (Pixels, raw::Info) { + raw::preview(&fixture(name), DECODE_MAX).unwrap_or_else(|e| panic!("{name}: {e}")) +} + +#[test] +fn every_family_is_detected_by_its_bytes() { + let cases = [ + ("preview-jpeg.dng", Kind::Dng), + ("preview-rgb.dng", Kind::Dng), + ("no-preview.dng", Kind::Dng), + ("canon.cr2", Kind::Cr2), + ("canon-o1.cr3", Kind::Cr3), + ("canon-heif.cr3", Kind::Cr3), + ("canon.crw", Kind::Crw), + ("nikon.nef", Kind::TiffEp), + ("sony.arw", Kind::TiffEp), + ("pentax.pef", Kind::TiffEp), + ("samsung.srw", Kind::TiffEp), + ("hasselblad.3fr", Kind::TiffEp), + ("sony-thumbnail.srf", Kind::TiffEp), + ("kodak16-be.dcr", Kind::TiffEp), + ("olympus.orf", Kind::Orf), + ("olympus-rs.orf", Kind::Orf), + ("olympus-be.orf", Kind::Orf), + ("panasonic.rw2", Kind::Rw2), + ("panasonic-no-preview.rw2", Kind::Rw2), + ("phaseone.iiq", Kind::Iiq), + ("fujifilm-o1.raf", Kind::Raf), + ("sigma.x3f", Kind::X3f), + ("minolta.mrw", Kind::Mrw), + ]; + for (file, kind) in cases { + assert_eq!(raw::detect(&fixture(file)), Some(kind), "{file}"); + } +} + +/// What no maker's name goes with, TIFF-structured, is RAW by its sensor data +/// alone: each compression the makers number, a colour filter array by its +/// photometric or its tags, and the DNGPrivateData tag. +#[test] +fn sensor_data_alone_makes_a_tiff_a_raw_file() { + let files = [ + "compression-99.tif", + "compression-32767.tif", + "compression-32769.tif", + "compression-32772.tif", + "compression-34713.tif", + "compression-65000.tif", + "compression-65535.tif", + "photometric-cfa-only.tif", + "cfa-tags-only.tif", + "private-data-only.tif", + ]; + for file in files { + let data = fixture(file); + assert_eq!(raw::detect(&data), Some(Kind::TiffEp), "{file}"); + let (p, info) = open(file); + assert_eq!((p.width, p.height, info.jpeg), (16, 12, false), "{file}"); + } + // The same file without its sensor data is the TIFF it looks like. + assert_eq!(raw::detect(&fixture("plain.tif")), None); +} + +/// A camera maker's name next to a camera's structure is enough, whatever the +/// case it is written in, for each maker; another name is not. +#[test] +fn a_camera_makers_name_beside_a_subifd_makes_a_tiff_a_raw_file() { + let base = fixture("hasselblad.3fr"); + let ifd0 = first_ifd(&base); + let make = entry(&base, ifd0, 0x10F); + let at = u32_at(&base, make + 8, true); + let makes = [ + "CANON", + "Nikon", + "SONY", + "olympus", + "PANASONIC", + "Leica", + "FUJIFILM", + "PENTAX", + "RICOH", + "SAMSUNG", + "KODAK", + "EPSON", + "MINOLTA", + "MAMIYA", + "LEAF", + "PHASE ONE", + "HASSELBLAD", + "CASIO", + "SIGMA", + ]; + for name in makes { + let mut d = base.clone(); + d[at..at + 11].fill(0); + d[at..at + name.len()].copy_from_slice(name.as_bytes()); + assert_eq!(raw::detect(&d), Some(Kind::TiffEp), "{name}"); + } + for name in ["ACME", "Adobe", "GIMP", ""] { + let mut d = base.clone(); + d[at..at + 11].fill(0); + d[at..at + name.len()].copy_from_slice(name.as_bytes()); + assert_eq!(raw::detect(&d), None, "{name}"); + } +} + +/// Only a few JPEG start markers are tried, so that a file of nothing but +/// those costs a bounded time: a preview the directories name is found +/// whatever comes before it. +#[test] +fn a_preview_the_directories_name_is_found_past_a_crowd_of_start_markers() { + let (p, info) = open("many-markers.dng"); + assert_eq!((p.width, p.height, info.found), (64, 48, 1)); +} + +/// The file name's extension says nothing: only the bytes are read. +#[test] +fn detection_ignores_the_name_and_the_tail() { + // A prefix is all a signature needs, and a cut-off file still says what + // it is. + assert_eq!( + raw::detect(&fixture("fujifilm-o1.raf")[..40]), + Some(Kind::Raf) + ); + assert_eq!(raw::detect(&fixture("canon-o1.cr3")[..40]), Some(Kind::Cr3)); + assert_eq!(raw::detect(b""), None); + assert_eq!(raw::detect(b"II*\0"), None); +} + +/// A TIFF stays a TIFF: the TIFF decoder reads it, `raw` does not claim it, +/// even with a camera maker's name in it. +#[test] +fn a_plain_tiff_is_not_a_raw_file() { + for file in ["plain.tif", "plain-with-make.tif"] { + let tiff = fixture(file); + assert_eq!(raw::detect(&tiff), None, "{file}"); + assert_eq!(Format::detect(&tiff), Some(Format::Tiff), "{file}"); + let via_any = decode_any(&tiff, DECODE_MAX).unwrap(); + let direct = decode(&tiff, Format::Tiff, DECODE_MAX).unwrap(); + assert_eq!(via_any, direct, "{file}"); + assert_eq!((via_any.width, via_any.height), (16, 12), "{file}"); + assert_quadrants(&via_any, AS_STORED, file); + } +} + +/// A camera's structure and no preview, with nothing else to say it is RAW: +/// `raw` leaves it to the TIFF decoder, which is what the file may be. +#[test] +fn a_tiff_with_a_camera_structure_and_no_preview_is_left_to_the_tiff_decoder() { + let tiff = fixture("camera-structure-no-preview.tif"); + assert_eq!(raw::detect(&tiff), Some(Kind::TiffEp)); + assert_eq!(raw::decode(&tiff, DECODE_MAX), Err(Error::Unsupported)); + assert_eq!( + decode_any(&tiff, DECODE_MAX), + decode(&tiff, Format::Tiff, DECODE_MAX) + ); + assert_ne!(decode_any(&tiff, DECODE_MAX), Err(Error::NoPreview)); +} + +#[test] +fn a_dng_shows_its_largest_preview() { + // A 16 by 12 uncompressed thumbnail in the first directory, a 64 by 48 + // JPEG in a SubIFD, sensor data (lossless JPEG) in another. + let (p, info) = open("preview-jpeg.dng"); + assert_eq!((p.width, p.height), (64, 48)); + assert_eq!( + info, + raw::Info { + kind: Kind::Dng, + width: 64, + height: 48, + orientation: 1, + jpeg: true, + found: 2, + } + ); + assert_quadrants(&p, AS_STORED, "preview-jpeg.dng"); +} + +#[test] +fn decode_any_shows_a_tiff_structured_raw_by_its_preview() { + // `Format::detect` reads the first bytes as TIFF, as `exav-imagehash` + // must; `decode_any` is the one that knows the file is a RAW. + for (file, size) in [ + ("preview-jpeg.dng", (64, 48)), + ("nikon.nef", (80, 48)), + ("sony.arw", (72, 48)), + ("canon.cr2", (96, 64)), + ("pentax.pef", (88, 56)), + ] { + let data = fixture(file); + assert_eq!(Format::detect(&data), Some(Format::Tiff), "{file}"); + let p = decode_any(&data, DECODE_MAX).unwrap_or_else(|e| panic!("{file}: {e}")); + assert_eq!((p.width, p.height), size, "{file}"); + assert_quadrants(&p, AS_STORED, file); + } + // The formats no TIFF decoder reads (`detect` knows none of them). + for (file, size) in [ + ("olympus.orf", (104, 72)), + ("panasonic.rw2", (112, 64)), + ("fujifilm-o1.raf", (64, 48)), + ("canon-o1.cr3", (128, 96)), + ("sigma.x3f", (80, 56)), + ("canon.crw", (72, 48)), + ] { + let data = fixture(file); + assert_eq!(Format::detect(&data), None, "{file}"); + let p = decode_any(&data, DECODE_MAX).unwrap_or_else(|e| panic!("{file}: {e}")); + assert_eq!((p.width, p.height), size, "{file}"); + assert_quadrants(&p, AS_STORED, file); + } +} + +/// The families keep their previews in different places: a SubIFD, the first +/// directory, a maker note, a tag nothing documents, a box, a section. +#[test] +fn each_family_gives_the_preview_where_it_keeps_it() { + let cases = [ + ("canon.cr2", 96, 64, true), + ("nikon.nef", 80, 48, true), + ("sony.arw", 72, 48, true), + ("pentax.pef", 88, 56, true), + ("samsung.srw", 56, 40, true), + ("olympus.orf", 104, 72, true), + ("olympus-rs.orf", 104, 72, true), + ("olympus-be.orf", 104, 72, true), + ("panasonic.rw2", 112, 64, true), + ("fujifilm-o1.raf", 64, 48, true), + ("canon-o1.cr3", 128, 96, true), + ("sigma.x3f", 80, 56, true), + ("canon.crw", 72, 48, true), + ("hasselblad.3fr", 32, 24, false), + ("phaseone.iiq", 24, 16, false), + ]; + for (file, w, h, jpeg) in cases { + let (p, info) = open(file); + assert_eq!((info.width, info.height, info.jpeg), (w, h, jpeg), "{file}"); + assert_eq!((p.width, p.height), (w, h), "{file}"); + assert_quadrants(&p, AS_STORED, file); + } +} + +#[test] +fn a_progressive_preview_decodes() { + let (p, _) = open("progressive.dng"); + assert_eq!((p.width, p.height), (48, 32)); + assert_quadrants(&p, AS_STORED, "progressive.dng"); +} + +#[test] +fn uncompressed_previews_are_read_whatever_their_depth_and_byte_order() { + let (p, info) = open("preview-grey.dng"); + assert_eq!((p.width, p.height, p.channels.count()), (16, 12, 1)); + assert!(!info.jpeg); + for file in ["kodak16-be.dcr", "kodak16-le.dcr"] { + let (p, info) = open(file); + assert_eq!((p.width, p.height), (16, 12), "{file}"); + assert!(!info.jpeg, "{file}"); + assert_quadrants(&p, AS_STORED, file); + // The high byte of each 16-bit sample. + assert!(matches!(&p.samples, Samples::U8(_)), "{file}"); + } +} + +/// Lossy DNG compresses its sensor data as baseline JPEG, which is no +/// preview, here larger than the real one. +#[test] +fn sensor_data_is_not_taken_for_a_preview_even_as_a_baseline_jpeg() { + let (p, info) = open("lossy-linear.dng"); + assert_eq!((p.width, p.height), (48, 32)); + assert_eq!(info.found, 1); +} + +/// Where the preview is turned by the orientation of the file: the first +/// directory's, or the JPEG's own Exif when it has one. +#[test] +fn the_orientation_is_applied_from_where_it_is_written() { + // IFD0 says 6 (a rotation a quarter turn clockwise) for a RGB preview. + let (p, info) = open("preview-rgb.dng"); + assert_eq!((info.width, info.height, info.orientation), (32, 24, 6)); + assert_eq!((p.width, p.height), (24, 32)); + assert_quadrants(&p, [BLUE, RED, YELLOW, GREEN], "preview-rgb.dng"); + // The JPEG's own Exif says 6 and IFD0 says 3: the JPEG's is the one. + let (p, info) = open("orientation-own.dng"); + assert_eq!(info.orientation, 6); + assert_quadrants(&p, [BLUE, RED, YELLOW, GREEN], "orientation-own.dng"); + // A CR3 keeps it in the CMT1 box of `moov`, not in its JPEGs. + let (p, info) = open("canon-o8.cr3"); + assert_eq!(info.orientation, 8); + assert_eq!((p.width, p.height), (96, 128)); + assert_quadrants(&p, [GREEN, YELLOW, RED, BLUE], "canon-o8.cr3"); + assert_eq!(open("canon-o1.cr3").1.orientation, 1); +} + +/// All eight, from the JPEG's own Exif, against what the Exif specification +/// says each value is. +#[test] +fn all_eight_orientations_turn_the_picture_as_exif_defines_them() { + let [tl, tr, bl, br] = AS_STORED; + // The quadrants as seen after each value, row by row. + let want = [ + [tl, tr, bl, br], + [tr, tl, br, bl], + [br, bl, tr, tl], + [bl, br, tl, tr], + [tl, bl, tr, br], + [bl, tl, br, tr], + [br, tr, bl, tl], + [tr, br, tl, bl], + ]; + for (i, want) in want.into_iter().enumerate() { + let o = i + 1; + let (p, info) = open(&format!("fujifilm-o{o}.raf")); + assert_eq!(info.orientation as usize, o); + let swapped = o >= 5; + let size = if swapped { (48, 64) } else { (64, 48) }; + assert_eq!((p.width, p.height), size, "orientation {o}"); + assert_quadrants(&p, want, &format!("orientation {o}")); + } +} + +/// A CR3 holds a thumbnail (THMB), a preview (PRVW) and the full-size JPEG +/// at the start of `mdat`, whose box has a 64-bit size. +#[test] +fn a_cr3_gives_the_full_size_jpeg_when_the_budget_allows_it() { + let data = fixture("canon-o1.cr3"); + let (p, info) = raw::preview(&data, DECODE_MAX).unwrap(); + assert_eq!((info.kind, info.found, info.jpeg), (Kind::Cr3, 3, true)); + assert_eq!((p.width, p.height), (128, 96)); + // 4 bytes a pixel: 96 by 64 fits, 128 by 96 does not. + let (p, info) = raw::preview(&data, 96 * 64 * 4).unwrap(); + assert_eq!((info.found, p.width, p.height), (3, 96, 64)); + let (p, _) = raw::preview(&data, 16 * 12 * 4).unwrap(); + assert_eq!((p.width, p.height), (16, 12)); +} + +#[test] +fn a_file_with_no_preview_says_so() { + for file in [ + "no-preview.dng", + "panasonic-no-preview.rw2", + "minolta.mrw", + "canon-heif.cr3", + ] { + let data = fixture(file); + assert!(raw::detect(&data).is_some(), "{file}"); + assert_eq!( + raw::decode(&data, DECODE_MAX), + Err(Error::NoPreview), + "{file}" + ); + assert_eq!( + decode_any(&data, DECODE_MAX), + Err(Error::NoPreview), + "{file}" + ); + } + assert_eq!( + Error::NoPreview.to_string(), + "camera RAW file with no preview to show" + ); +} + +#[test] +fn what_is_not_a_raw_file_is_unsupported() { + assert_eq!(raw::decode(b"", DECODE_MAX), Err(Error::Unsupported)); + assert_eq!( + raw::decode(b"just some text, not a camera file", DECODE_MAX), + Err(Error::Unsupported) + ); + // A JPEG is an image, not a RAW file with a preview. + assert_eq!( + raw::decode(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 2], DECODE_MAX), + Err(Error::Unsupported) + ); +} + +/// The budget is on what the page holds, as RGBA (four bytes a pixel, six when +/// the picture must be turned): a preview over it is passed by for a smaller +/// one, and the error says it is the size when there is none. +#[test] +fn a_preview_over_the_budget_gives_way_to_a_smaller_one() { + let data = fixture("preview-jpeg.dng"); + // 64 by 48 is 12,288 bytes; 16 by 12 is 768. + let (p, _) = raw::preview(&data, 12_288).unwrap(); + assert_eq!((p.width, p.height), (64, 48)); + let (p, info) = raw::preview(&data, 12_287).unwrap(); + assert_eq!((p.width, p.height), (16, 12)); + assert!(!info.jpeg); + assert_eq!(raw::decode(&data, 767), Err(Error::TooLarge)); + assert_eq!(decode_any(&data, 767), Err(Error::TooLarge)); + // Turned, the same preview needs six bytes a pixel. + let turned = fixture("preview-rgb.dng"); + assert_eq!(raw::preview(&turned, 32 * 24 * 6).unwrap().1.width, 32); + assert_eq!(raw::preview(&turned, 32 * 24 * 6 - 1).unwrap().1.width, 16); + // A limit over the decoder's own is the decoder's. + assert!(raw::decode(&data, u64::MAX).is_ok()); +} + +fn u16_at(d: &[u8], at: usize, le: bool) -> usize { + let b = [d[at], d[at + 1]]; + usize::from(if le { + u16::from_le_bytes(b) + } else { + u16::from_be_bytes(b) + }) +} + +fn u32_at(d: &[u8], at: usize, le: bool) -> usize { + let b = [d[at], d[at + 1], d[at + 2], d[at + 3]]; + (if le { + u32::from_le_bytes(b) + } else { + u32::from_be_bytes(b) + }) as usize +} + +fn put_u32(d: &mut [u8], at: usize, v: u32, le: bool) { + d[at..at + 4].copy_from_slice(&if le { v.to_le_bytes() } else { v.to_be_bytes() }); +} + +/// Offsets of the entries (tag, offset of the entry) of the directory at `ifd`. +fn entries(d: &[u8], ifd: usize) -> Vec<(u16, usize)> { + let le = d[0] == b'I'; + (0..u16_at(d, ifd, le)) + .map(|k| { + let at = ifd + 2 + k * 12; + (u16_at(d, at, le) as u16, at) + }) + .collect() +} + +fn first_ifd(d: &[u8]) -> usize { + u32_at(d, 4, d[0] == b'I') +} + +fn entry(d: &[u8], ifd: usize, tag: u16) -> usize { + entries(d, ifd) + .into_iter() + .find(|e| e.0 == tag) + .unwrap_or_else(|| panic!("no tag {tag:#x}")) + .1 +} + +/// No hostile input may hang or panic: each of these has some preview or +/// none, and answers in a bounded time. +fn assert_survives(data: &[u8], what: &str) -> Result<(Pixels, raw::Info), Error> { + watch_panics(); + let started = Instant::now(); + let r = raw::preview(data, 64 << 20); + assert!(!panicked(), "{what}: panicked"); + assert!(started.elapsed() < Duration::from_secs(2), "{what}: slow"); + r +} + +#[test] +fn a_chain_of_directories_that_loops_is_read_once() { + // IFD0 of the CR2 chained to itself. + let mut d = fixture("canon.cr2"); + let ifd0 = first_ifd(&d); + let le = true; + let next = ifd0 + 2 + u16_at(&d, ifd0, le) * 12; + put_u32(&mut d, next, ifd0 as u32, le); + let (p, info) = assert_survives(&d, "chain loop").unwrap(); + assert_eq!((p.width, p.height), (96, 64)); + // The JPEG of the directory, once, and the thumbnail found by its marker. + assert_eq!(info.found, 2); + // And the SubIFD list of a DNG pointing back at its first directory. + let mut d = fixture("preview-jpeg.dng"); + let ifd0 = first_ifd(&d); + let sub = entry(&d, ifd0, 0x14A); + let list = u32_at(&d, sub + 8, true); + put_u32(&mut d, list, ifd0 as u32, true); + let (p, info) = assert_survives(&d, "subifd loop").unwrap(); + assert_eq!((p.width, p.height), (64, 48)); + // The first directory's thumbnail, once, and the JPEG. + assert_eq!(info.found, 2); +} + +#[test] +fn offsets_and_counts_past_the_data_are_refused_not_followed() { + // A strip that starts past the end: the other preview is used. + let mut d = fixture("preview-rgb.dng"); + let ifd0 = first_ifd(&d); + let strips = entry(&d, ifd0, 0x111); + put_u32(&mut d, strips + 8, u32::MAX - 3, false); + let (p, info) = assert_survives(&d, "strip offset").unwrap(); + assert_eq!(info.width, 32); + assert_eq!(p.width, 24); + // A strip whose length runs past the end of the data. + let mut d = fixture("preview-rgb.dng"); + let counts = entry(&d, ifd0, 0x117); + put_u32(&mut d, counts + 8, u32::MAX, false); + let (_, info) = assert_survives(&d, "strip length").unwrap(); + assert_eq!(info.width, 32); + // A count of values that does not fit: the entry is not read. + let mut d = fixture("preview-rgb.dng"); + let bits = entry(&d, ifd0, 0x102); + put_u32(&mut d, bits + 4, u32::MAX, false); + let _ = assert_survives(&d, "count"); + // Every preview's strips gone: there is none, which is no crash. + let mut d = fixture("preview-rgb.dng"); + for ifd in [ifd0] { + for tag in [0x111, 0x117] { + let e = entry(&d, ifd, tag); + put_u32(&mut d, e + 8, u32::MAX - 3, false); + } + } + let sub_list = u32_at(&d, entry(&d, ifd0, 0x14A) + 8, false); + for k in 0..2 { + let sub = u32_at(&d, sub_list + 4 * k, false); + for tag in [0x111, 0x117] { + if let Some(e) = entries(&d, sub).into_iter().find(|e| e.0 == tag) { + put_u32(&mut d, e.1 + 8, u32::MAX - 3, false); + } + } + } + assert_eq!( + assert_survives(&d, "no strips").err(), + Some(Error::NoPreview) + ); +} + +#[test] +fn a_directory_with_more_entries_than_the_file_holds_is_skipped() { + let mut d = fixture("canon.cr2"); + let ifd0 = first_ifd(&d); + d[ifd0..ifd0 + 2].copy_from_slice(&u16::MAX.to_le_bytes()); + // The first directory cannot be read: the JPEG streams are still found + // where they lie. + let (p, _) = assert_survives(&d, "entry count").unwrap(); + assert_eq!((p.width, p.height), (96, 64)); +} + +#[test] +fn a_preview_declared_enormous_is_not_allocated() { + // The uncompressed preview claims 65,000 by 65,000 and has a few bytes. + let mut d = fixture("preview-rgb.dng"); + let ifd0 = first_ifd(&d); + for tag in [0x100, 0x101] { + let e = entry(&d, ifd0, tag); + put_u32(&mut d, e + 8, 65_000, false); + } + let (_, info) = assert_survives(&d, "declared size").unwrap(); + assert_eq!(info.width, 32); + // JPEG streams too: a frame header that says 65,535 by 65,535. + let mut d = fixture("fujifilm-o1.raf"); + let sof = d.array_windows().position(|w| *w == [0xFF, 0xC0]).unwrap(); + d[sof + 5..sof + 9].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); + // Past a limit that is the caller's, it is a size; past the decoder's own, + // the image is undecodable. + assert_eq!( + assert_survives(&d, "frame size").err(), + Some(Error::TooLarge) + ); + assert_eq!(raw::decode(&d, DECODE_MAX).err(), Some(Error::Undecodable)); +} + +/// An uncompressed preview is read when it is stored as TIFF stores it, +/// chunky: a planar one is not taken for one. +#[test] +fn a_planar_uncompressed_preview_is_not_read() { + let mut d = fixture("preview-rgb.dng"); + let ifd0 = first_ifd(&d); + let sub_list = u32_at(&d, entry(&d, ifd0, 0x14A) + 8, false); + let sub = u32_at(&d, sub_list + 4, false); + let planar = entry(&d, sub, 0x11C); + d[planar + 8..planar + 10].copy_from_slice(&2u16.to_be_bytes()); + let (_, info) = raw::preview(&d, DECODE_MAX).unwrap(); + assert_eq!((info.width, info.found), (16, 1)); +} + +#[test] +fn a_file_made_of_start_markers_costs_a_bounded_time() { + // A start marker every eight bytes, each followed by comment segments + // that run through every start marker after it and never reach a frame: + // read in full, each costs a walk of the rest of the file. + let mut d = b"FUJIFILMCCD-RAW ".to_vec(); + d.extend_from_slice(&[0xFF, 0xD8, 0xFF, 0xFE, 0x00, 0x06, 0, 0].repeat(8 << 20)); + let r = assert_survives(&d, "start markers"); + assert_eq!(r.err(), Some(Error::NoPreview)); +} + +#[test] +fn every_truncation_of_every_fixture_is_answered() { + watch_panics(); + let dir = format!("{}/tests/fixtures/raw", env!("CARGO_MANIFEST_DIR")); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|e| e == "py") { + continue; + } + let data = std::fs::read(&path).unwrap(); + for cut in (0..data.len()).step_by(7) { + let _ = raw::preview(&data[..cut], 64 << 20); + let _ = decode_any(&data[..cut], 64 << 20); + assert!(!panicked(), "{} cut at {cut}", path.display()); + } + } +} + +#[test] +fn bytes_flipped_in_a_fixture_never_panic() { + watch_panics(); + for file in [ + "canon.cr2", + "nikon.nef", + "preview-jpeg.dng", + "canon-o8.cr3", + "olympus.orf", + ] { + let data = fixture(file); + for at in 0..data.len().min(400) { + for v in [0x00, 0xFF, 0x7F, 0x80] { + let mut d = data.clone(); + d[at] = v; + let _ = raw::preview(&d, 64 << 20); + assert!(!panicked(), "{file} byte {at} = {v:#x}"); + } + } + } +} diff --git a/crates/exav-render/tests/saturate.rs b/crates/exav-render/tests/saturate.rs new file mode 100644 index 00000000..0b36e50c --- /dev/null +++ b/crates/exav-render/tests/saturate.rs @@ -0,0 +1,294 @@ +//! Every fixture, with each length or count in turn set to an extreme. +//! +//! A file's header says how big its parts are and how many there are, and a +//! file that lies about it is the oldest trick against a reader: the size +//! becomes an allocation before a byte of the data has been seen, a count a +//! loop. Fuzzing finds these by chance. This sweep is the deterministic +//! version: for each fixture, at each position of its start, 4 bytes are +//! overwritten with `0xFFFFFFFF` and with `0x7FFFFFFF`, and the file is read. +//! None may hold more than a few times the file (measured as what the process +//! has resident, where a request the reader never touches costs nothing), +//! take seconds, or panic. `EXAV_DEBUG_SATURATE=full` takes more positions and +//! values. A finding prints the fixture, the position and the value. + +#![cfg(all( + feature = "office-docs", + feature = "office-slides", + feature = "office-sheets" +))] + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +/// What reading may hold over the size of the file. +const RESIDENT_BASE: usize = 96 << 20; +const RESIDENT_PER_BYTE: usize = 256; +const SLOW: Duration = Duration::from_secs(4); +/// What the converters are given to hold, so that holding more is theirs. +const OFFICE_MEMORY: u64 = 32 << 20; + +fn walk_dir(dir: &Path, out: &mut Vec<PathBuf>) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + walk_dir(&p, out); + } else { + out.push(p); + } + } +} + +/// A fixture's name without a `.gz`, and its bytes. +fn load(p: &Path) -> Option<(String, Vec<u8>)> { + let raw = std::fs::read(p).ok()?; + let name = p.display().to_string(); + match name.strip_suffix(".gz") { + Some(plain) => { + let mut out = vec![]; + flate2::read::GzDecoder::new(&raw[..]) + .take(8 << 20) + .read_to_end(&mut out) + .ok()?; + Some((plain.to_string(), out)) + } + None => Some((name, raw)), + } +} + +/// What reads a file, by its extension. `None` when nothing here does. +type Reader = fn(&[u8]); + +fn reader_for(name: &str) -> Option<Reader> { + let ext = name.rsplit('.').next()?.to_ascii_lowercase(); + Some(match ext.as_str() { + // Not JPEG XL: zenjxl-decoder sizes the buffer of each section of a frame + // from the length its table of contents declares, and zero-fills it + // before the data is looked at, so one header can take a gigabyte. The + // pixel and channel limits set in `image::jxl` do not reach it. + #[cfg(feature = "image")] + "png" | "jpg" | "jpeg" | "gif" | "tif" | "tiff" | "bmp" | "webp" | "ico" | "ppm" + | "pgm" | "pbm" | "pam" | "qoi" | "dds" | "ff" | "hdr" | "j2k" | "jp2" | "jb2" | "raf" + | "dng" | "cr2" | "cr3" | "nef" | "arw" | "orf" | "rw2" | "pef" | "dcr" => |d| { + let _ = exav_render::image::decode_any(d, 64 << 20); + }, + #[cfg(feature = "stl")] + "stl" => |d| { + let _ = exav_render::stl::read(d, 1 << 20); + }, + #[cfg(feature = "obj")] + "obj" => |d| { + let _ = exav_render::obj::read(d, 1 << 20); + }, + #[cfg(feature = "ply")] + "ply" => |d| { + let _ = exav_render::ply::read(d, 1 << 20); + }, + #[cfg(feature = "gltf")] + "gltf" | "glb" => |d| { + let _ = exav_render::gltf::read(d, 1 << 20); + }, + #[cfg(feature = "ifc")] + "ifc" => |d| { + let _ = exav_render::ifc::read(d, &exav_render::ifc::Limits::default()); + }, + #[cfg(feature = "dwg")] + "dwg" => |d| { + let _ = exav_render::dwg::Document::parse(d).map(|doc| { + for l in doc.layouts() { + let _ = doc.tessellate(Some(&l.name), None); + } + }); + }, + #[cfg(feature = "dwg")] + "dxf" => |d| { + let _ = exav_render::cad::read_dxf(d); + }, + "ppt" => slides(exav_render::slides::Format::Ppt), + "odp" => slides(exav_render::slides::Format::Odp), + "fodp" => slides(exav_render::slides::Format::Fodp), + "key" => slides(exav_render::slides::Format::Keynote), + "odg" => slides(exav_render::slides::Format::Odg), + "fodg" => slides(exav_render::slides::Format::Fodg), + "vsdx" => slides(exav_render::slides::Format::Vsdx), + "xls" => sheets(exav_render::sheets::Format::Xls), + "ods" => sheets(exav_render::sheets::Format::Ods), + "fods" => sheets(exav_render::sheets::Format::Fods), + "numbers" => sheets(exav_render::sheets::Format::Numbers), + "doc" => docs(exav_render::docs::Format::Doc), + "odt" => docs(exav_render::docs::Format::Odt), + "fodt" => docs(exav_render::docs::Format::Fodt), + "rtf" => docs(exav_render::docs::Format::Rtf), + "pages" => docs(exav_render::docs::Format::Pages), + _ => return None, + }) +} + +fn slides(f: exav_render::slides::Format) -> Reader { + // A function pointer cannot capture, so one per format. + match f { + exav_render::slides::Format::Ppt => |d| conv_slides(d, exav_render::slides::Format::Ppt), + exav_render::slides::Format::Odp => |d| conv_slides(d, exav_render::slides::Format::Odp), + exav_render::slides::Format::Fodp => |d| conv_slides(d, exav_render::slides::Format::Fodp), + exav_render::slides::Format::Keynote => { + |d| conv_slides(d, exav_render::slides::Format::Keynote) + } + exav_render::slides::Format::Odg => |d| conv_slides(d, exav_render::slides::Format::Odg), + exav_render::slides::Format::Fodg => |d| conv_slides(d, exav_render::slides::Format::Fodg), + _ => |d| conv_slides(d, exav_render::slides::Format::Vsdx), + } +} + +fn conv_slides(d: &[u8], f: exav_render::slides::Format) { + let l = exav_render::slides::Limits::default().with_max_memory(OFFICE_MEMORY); + let _ = exav_render::slides::convert(d, f, &l); +} + +fn sheets(f: exav_render::sheets::Format) -> Reader { + match f { + exav_render::sheets::Format::Xls => |d| conv_sheets(d, exav_render::sheets::Format::Xls), + exav_render::sheets::Format::Ods => |d| conv_sheets(d, exav_render::sheets::Format::Ods), + exav_render::sheets::Format::Fods => |d| conv_sheets(d, exav_render::sheets::Format::Fods), + _ => |d| conv_sheets(d, exav_render::sheets::Format::Numbers), + } +} + +fn conv_sheets(d: &[u8], f: exav_render::sheets::Format) { + let l = exav_render::sheets::Limits::default().with_max_memory(OFFICE_MEMORY); + let _ = exav_render::sheets::convert(d, f, &l); +} + +fn docs(f: exav_render::docs::Format) -> Reader { + match f { + exav_render::docs::Format::Doc => |d| conv_docs(d, exav_render::docs::Format::Doc), + exav_render::docs::Format::Odt => |d| conv_docs(d, exav_render::docs::Format::Odt), + exav_render::docs::Format::Fodt => |d| conv_docs(d, exav_render::docs::Format::Fodt), + exav_render::docs::Format::Rtf => |d| conv_docs(d, exav_render::docs::Format::Rtf), + _ => |d| conv_docs(d, exav_render::docs::Format::Pages), + } +} + +fn conv_docs(d: &[u8], f: exav_render::docs::Format) { + let l = exav_render::docs::Limits::default().with_max_memory(OFFICE_MEMORY); + let _ = exav_render::docs::convert(d, f, &l); +} + +fn sweep() -> (Vec<usize>, Vec<Vec<u8>>) { + if std::env::var("EXAV_DEBUG_SATURATE").is_ok_and(|v| v == "full") { + let mut at: Vec<usize> = (0..256).collect(); + at.extend((256..2048).step_by(2)); + at.extend((2048..8192).step_by(8)); + ( + at, + vec![ + vec![0xFF; 4], + vec![0xFF, 0xFF, 0xFF, 0x7F], + vec![0x00, 0x00, 0x00, 0x80], + vec![0x00, 0x00, 0x00, 0x10], + vec![0xFF; 2], + vec![0xFF; 8], + ], + ) + } else { + let mut at: Vec<usize> = (0..64).collect(); + at.extend((64..512).step_by(8)); + (at, vec![vec![0xFF; 4], vec![0xFF, 0xFF, 0xFF, 0x7F]]) + } +} + +/// The resident-set high-water mark in bytes, from `/proc` (Linux only). +fn high_water() -> usize { + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| { + s.lines() + .find_map(|l| l.strip_prefix("VmHWM:")) + .and_then(|v| v.trim().trim_end_matches("kB").trim().parse::<usize>().ok()) + }) + .map_or(0, |kb| kb << 10) +} + +fn reset_high_water() { + let _ = std::fs::write("/proc/self/clear_refs", "5"); +} + +#[test] +fn a_field_at_its_extreme_costs_nothing_like_what_it_says() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let mut paths = vec![]; + walk_dir(&root, &mut paths); + let mut corpus: Vec<(String, Reader, Vec<u8>)> = vec![]; + for p in paths { + if let Some((name, data)) = load(&p) + && data.len() <= 256 << 10 + && let Some(r) = reader_for(&name) + { + corpus.push(( + name.strip_prefix(&format!("{}/", root.display())) + .unwrap_or(&name) + .to_string(), + r, + data, + )); + } + } + // The office fixtures alone, when the readers of the other formats are + // not built, are fewer. + let least = if cfg!(feature = "image") { 30 } else { 10 }; + assert!(corpus.len() > least, "only {} fixtures", corpus.len()); + let (positions, values) = sweep(); + let mut findings: Vec<String> = vec![]; + let mut runs = 0u64; + for (name, read, data) in &corpus { + let allowed = RESIDENT_BASE + RESIDENT_PER_BYTE * data.len(); + let mut seen: Vec<usize> = vec![]; + for &at in &positions { + if seen.contains(&at) { + continue; + } + for value in &values { + if at + value.len() > data.len() { + continue; + } + let mut mutated = data.clone(); + mutated[at..at + value.len()].copy_from_slice(value); + runs += 1; + reset_high_water(); + let before = high_water(); + let started = Instant::now(); + let r = std::panic::catch_unwind(|| read(&mutated)); + let took = started.elapsed(); + let held = high_water().saturating_sub(before); + let hex: String = value.iter().map(|b| format!("{b:02x}")).collect(); + let end = at + value.len(); + if r.is_err() { + findings.push(format!("{name}: bytes {at}..{end} set to {hex} panicked")); + seen.push(at); + } else if held > allowed { + findings.push(format!( + "{name}: bytes {at}..{end} set to {hex} held {} MiB (allowed {} MiB)", + held >> 20, + allowed >> 20 + )); + seen.push(at); + } else if took > SLOW { + findings.push(format!( + "{name}: bytes {at}..{end} set to {hex} took {:.1} s", + took.as_secs_f64() + )); + seen.push(at); + } + } + } + } + eprintln!( + "{} fixtures, {runs} reads, {} findings", + corpus.len(), + findings.len() + ); + assert!(findings.is_empty(), "\n{}", findings.join("\n")); +} diff --git a/crates/exav-render/tests/scaling.rs b/crates/exav-render/tests/scaling.rs new file mode 100644 index 00000000..6f18a8fa --- /dev/null +++ b/crates/exav-render/tests/scaling.rs @@ -0,0 +1,57 @@ +//! Documents of one kind of unit, many times over, converted in the time they +//! take to read. +//! +//! A step that looks at everything made so far for each unit it makes is +//! invisible in a document of a hundred units and takes minutes in one of a +//! million. Each test here makes a document of one unit, enough of them that +//! the step would take several times the limit, and gives the conversion that +//! limit. The limits are generous (an order above what the linear conversion +//! takes at the test profile's `opt-level = 1`, a quarter of the quadratic). + +#![cfg(all( + feature = "office-docs", + feature = "office-slides", + feature = "office-sheets" +))] + +use std::time::{Duration, Instant}; + +const ODF_NS: &str = "xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" \ + xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" office:version=\"1.2\""; + +fn flat_odt(body: &str) -> Vec<u8> { + format!( + "<?xml version=\"1.0\"?><office:document {ODF_NS} \ + office:mimetype=\"application/vnd.oasis.opendocument.text\"><office:body>\ + <office:text>{body}</office:text></office:body></office:document>" + ) + .into_bytes() +} + +fn converts_within(what: &str, input: &[u8], format: exav_render::docs::Format, limit: Duration) { + let limits = exav_render::docs::Limits::default().with_max_memory(2 << 30); + let started = Instant::now(); + let r = exav_render::docs::convert(input, format, &limits); + let took = started.elapsed(); + assert!(r.is_ok(), "{what}: {:?}", r.err()); + assert!( + took < limit, + "{what}: {:.1} s, the limit is {limit:?}", + took.as_secs_f64() + ); +} + +/// Each note looked for itself among all the notes before it, from the first: +/// 160,000 of them took 5 s, and 300,000 would take 18. +#[test] +fn a_document_of_300_000_footnotes() { + let note = "<text:note text:note-class=\"footnote\"><text:note-citation>1</text:note-citation>\ + <text:note-body><text:p>n</text:p></text:note-body></text:note>"; + let doc = flat_odt(&format!("<text:p>{}</text:p>", note.repeat(300_000))); + converts_within( + "300,000 footnotes", + &doc, + exav_render::docs::Format::Fodt, + Duration::from_secs(10), + ); +} diff --git a/crates/exav-render/tests/text.rs b/crates/exav-render/tests/text.rs new file mode 100644 index 00000000..542be615 --- /dev/null +++ b/crates/exav-render/tests/text.rs @@ -0,0 +1,151 @@ +//! `text::visible_text`: one entry for the text of every kind of document that +//! has some, chosen by the bytes where they say and by the name where not. + +#[path = "cad_common/mod.rs"] +mod common; + +use exav_render::text::{visible_text, wants}; +use exav_unpack::Limits; + +fn text(name: &str, data: &[u8]) -> Option<String> { + visible_text(name, data, 1 << 20, &Limits::default()).map(|v| v.text) +} + +#[test] +fn markdown_html_and_package_parts_by_what_they_are() { + assert_eq!( + text("a.md", b"Please *read* it").unwrap(), + "Please read it\n" + ); + assert_eq!( + text( + "page.bin", + b"<html><body><p>one</p><a href=\"http://x.example/\">two</a></body></html>" + ) + .unwrap(), + "one\ntwo (http://x.example/)\n" + ); + assert_eq!( + text( + "word/document.xml", + b"<w:document><w:p><w:r><w:t>in a docx</w:t></w:r></w:p></w:document>" + ) + .unwrap(), + "in a docx\n" + ); +} + +/// Found by the `visible_text` fuzz target: the reader took a binary file with +/// a SpreadsheetML marker among its first bytes for that document, where +/// `wants`, which the scanner asks first, wants text to begin with. +#[test] +fn a_binary_file_with_a_spreadsheet_marker_among_its_bytes_is_not_that_document() { + let mut data = b"AC1012\0\0\0\0\0\0\x01X\0\0\0\0\0\x1e\0\x05\0\0\0\x12".to_vec(); + data.extend_from_slice( + b"<?xml version=\"1.0\"?><Workbook xmlns=\"urn:schemas-microsoft-com:office:spreadsheet\">\ + <Worksheet/></Workbook>", + ); + // And a flat OpenDocument file's root element. + let mut flat = b"{\\rinrtf2\0\x05\xb2\0\0\0\x01X\0\0\0\0\0\x1e\0\x05".to_vec(); + flat.extend_from_slice( + b"<office:document office:mimetype=\"application/vnd.oasis.opendocument.text\"/>", + ); + for data in [&data, &flat] { + for name in ["a.dwg", "a.xml", "member"] { + assert_eq!(text(name, data), None, "{name}"); + assert!(!wants(name, &data[..data.len().min(1024)]), "{name}"); + } + } + // The same document as text is read, and wanted. + let doc = &data[data.len() - 107..]; + assert!(wants("a.xml", doc)); + assert!(text("a.xml", doc).is_some()); +} + +#[test] +fn what_has_no_text_gives_none() { + assert_eq!(text("notes.txt", b"just some words"), None); + assert_eq!(text("a.bin", &[0u8; 64]), None); + assert_eq!(text("a.dwg", b"AC1032 not really"), None); + assert_eq!(text("a", b""), None); +} + +#[test] +fn a_drawing_is_its_texts_attributes_and_names_in_dxf_and_in_dwg_alike() { + let dxf = common::fixture("ascii/R2018/all.dxf").expect("fixture"); + let dwg = common::fixture("dwg/R2018/all.dwg").expect("fixture"); + for (name, data) in [("all.dxf", &dxf), ("all.dwg", &dwg)] { + let t = text(name, data).unwrap_or_else(|| panic!("{name}: no text")); + // TEXT, a layer name, and MTEXT without its formatting codes. + assert!(t.contains("Left text"), "{name}: {t}"); + assert!(t.contains("WALLS"), "{name}: {t}"); + assert!(!t.contains("\\P") && !t.contains("\\f"), "{name}: {t}"); + } + // The same drawing says the same in both. + let a: std::collections::BTreeSet<_> = text("a.dxf", &dxf) + .unwrap() + .lines() + .map(str::to_string) + .collect(); + let b: std::collections::BTreeSet<_> = text("a.dwg", &dwg) + .unwrap() + .lines() + .map(str::to_string) + .collect(); + assert!(a.contains("Left text") && b.contains("Left text")); + let common: Vec<_> = a.intersection(&b).collect(); + assert!( + common.len() * 2 > a.len(), + "{} of {} lines agree", + common.len(), + a.len() + ); +} + +#[test] +fn the_text_of_a_drawing_is_cut_at_the_cap() { + let dxf = common::fixture("ascii/R2018/all.dxf").expect("fixture"); + let v = visible_text("all.dxf", &dxf, 64, &Limits::default()).unwrap(); + assert!(v.truncated); + assert!(v.text.len() <= 64); +} + +#[test] +fn a_drawing_is_read_within_the_memory_the_caller_allows() { + let mut dxf = String::from(" 0\nSECTION\n 2\nENTITIES\n"); + for _ in 0..1000 { + dxf.push_str(" 0\nLINE\n 10\n0\n 20\n0\n 11\n1\n 21\n1\n"); + } + dxf.push_str(" 0\nTEXT\n 1\nafter the lines\n 10\n0\n 20\n0\n 0\nENDSEC\n 0\nEOF\n"); + let mut limits = Limits::default(); + let v = visible_text("a.dxf", dxf.as_bytes(), 1 << 20, &limits).unwrap(); + assert!(v.text.contains("after the lines") && !v.truncated); + // Room for fewer entities than the drawing has: the text found is kept, + // and the scan is told it is not all there. + limits.max_buffer_bytes = 100 * exav_render::cad::ENTITY_BYTES; + let v = visible_text("a.dxf", dxf.as_bytes(), 1 << 20, &limits).unwrap(); + assert!(!v.text.contains("after the lines") && v.truncated); + + // A DWG whose sections expand past it is unreadable, not empty. + let dwg = common::fixture("dwg/R2018/all.dwg").expect("fixture"); + limits.max_buffer_bytes = 1 << 10; + let v = visible_text("a.dwg", &dwg, 1 << 20, &limits).unwrap(); + assert!(v.text.is_empty()); + assert!(v.unreadable.is_some_and(|w| w.starts_with("too-large"))); +} + +#[test] +fn damaged_drawings_and_documents_do_not_panic() { + let dxf = common::fixture("ascii/R2018/all.dxf").expect("fixture"); + let dwg = common::fixture("dwg/R2018/all.dwg").expect("fixture"); + for data in [&dxf, &dwg] { + for cut in [0, 7, 100, data.len() / 3, data.len() - 1] { + let _ = text("x", &data[..cut]); + let mut flipped = data[..cut].to_vec(); + for i in (0..flipped.len()).step_by(97) { + flipped[i] ^= 0xa5; + } + let _ = text("x", &flipped); + } + } +} diff --git a/crates/exav-unpack-wasm/Cargo.lock b/crates/exav-unpack-wasm/Cargo.lock index 4f69ffc6..5bc7f7b6 100644 --- a/crates/exav-unpack-wasm/Cargo.lock +++ b/crates/exav-unpack-wasm/Cargo.lock @@ -28,22 +28,6 @@ dependencies = [ "libc", ] -[[package]] -name = "apfs" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c1b4ecd96379464ea8b4ef2517c8eb674d8658ad364e9176bf5b8c278aa801e" -dependencies = [ - "byteorder", - "thiserror", -] - -[[package]] -name = "arraydeque" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0ffd3d69bd89910509a5d31d1f1353f38ccffdd116dd0099bbd6627f7bd8ad8" - [[package]] name = "autocfg" version = "1.5.1" @@ -52,15 +36,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "1.3.2" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bitflags" @@ -101,6 +79,26 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -118,25 +116,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.5.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "shlex", ] -[[package]] -name = "cfb" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" -dependencies = [ - "fnv", - "uuid", - "web-time", -] - [[package]] name = "cfg-if" version = "1.0.5" @@ -182,12 +169,6 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -215,26 +196,11 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -250,9 +216,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] @@ -265,11 +231,12 @@ checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] name = "delharc" -version = "0.6.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3658b90877f637514897c3dcbd628e0d290bcc7fee551a398b3994ebdc62d51e" +checksum = "9cfdcd2bee8dc367afd9064f3b0849156a4c29fc70d98370dae62773594d04dd" dependencies = [ - "bitflags 2.13.2", + "bitflags", + "bytemuck", "chrono", "memchr", ] @@ -311,13 +278,13 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "exav-pe-emu" -version = "0.0.2" +version = "0.0.3" dependencies = [ "exav-x86", "rustc-hash", @@ -325,16 +292,13 @@ dependencies = [ [[package]] name = "exav-unpack" -version = "0.0.2" +version = "0.0.3" dependencies = [ "aes", - "apfs", "base64", "bitstream-io", "byteorder", "cbc", - "cfb", - "constant_time_eq", "crc32fast", "deflate64", "delharc", @@ -342,13 +306,8 @@ dependencies = [ "digest", "encoding_rs", "exav-pe-emu", - "explode", - "ext4-view", - "fatfs", "flate2", - "hfsplus", "hmac", - "lzfse_rust", "lzma-rust2", "lznt1", "lzxd", @@ -365,56 +324,23 @@ dependencies = [ "tar", "thiserror", "tinyvec", - "unshield", "xz4rust", "zip", ] [[package]] name = "exav-unpack-wasm" -version = "0.0.2" +version = "0.0.3" dependencies = [ "exav-unpack", - "flate2", "js-sys", "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", ] [[package]] name = "exav-x86" -version = "0.0.2" - -[[package]] -name = "explode" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46695dd0a1b7a2a6c3e0800604bd9738c2699682b9fc325d4886b6c1a78025ac" -dependencies = [ - "arraydeque", -] - -[[package]] -name = "ext4-view" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ecf6379b7cfeace5e109ccc51955afc05f5f7f2466ae0cf2697bda46afd01e6" -dependencies = [ - "bitflags 2.13.2", - "crc", -] - -[[package]] -name = "fatfs" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05669f8e7e2d7badc545c513710f0eba09c2fbef683eb859fd79c46c355048e0" -dependencies = [ - "bitflags 1.3.2", - "byteorder", - "log", -] +version = "0.0.3" [[package]] name = "filetime" @@ -428,9 +354,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -443,12 +369,6 @@ dependencies = [ "zlib-rs", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "futures-core" version = "0.3.34" @@ -491,16 +411,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "hfsplus" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1003d8eed80d61e8e8ea6a958bbe1afd3aef060882b9d6134e50500dd307c0dc" -dependencies = [ - "byteorder", - "thiserror", -] - [[package]] name = "hmac" version = "0.13.0" @@ -555,9 +465,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "block-padding", "hybrid-array", @@ -576,9 +486,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "log" @@ -586,17 +496,11 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" -[[package]] -name = "lzfse_rust" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5edeac3ec657925dececc672bdd58fa2212291886ddcb3e08aa46924c78340c6" - [[package]] name = "lzma-rust2" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b" +checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" [[package]] name = "lznt1" @@ -660,7 +564,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -717,9 +621,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] @@ -747,12 +651,9 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ruzstd" -version = "0.8.3" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" -dependencies = [ - "twox-hash", -] +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" [[package]] name = "salzweg" @@ -779,9 +680,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures", @@ -836,9 +737,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -872,7 +773,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -881,26 +782,11 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "pin-project-lite", -] - -[[package]] -name = "twox-hash" -version = "2.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" - [[package]] name = "typed-path" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" [[package]] name = "typenum" @@ -916,15 +802,15 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -935,26 +821,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" -[[package]] -name = "unshield" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dd29bc2ffd8a674b7de402169d86b3c083229373d05c388526d626408416c5c" -dependencies = [ - "byteorder", - "explode", -] - -[[package]] -name = "uuid" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -968,17 +834,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" -dependencies = [ - "js-sys", - "tokio", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -998,7 +853,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -1021,16 +876,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -1098,9 +943,9 @@ checksum = "606930b71bad6379f366994e4e6252a3462fa28bfc3695c33b07bef8f967d6d6" [[package]] name = "zip" -version = "9.0.1" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "bitstream-io", "crc32fast", diff --git a/crates/exav-unpack-wasm/Cargo.toml b/crates/exav-unpack-wasm/Cargo.toml index 7a95270b..418a1d98 100644 --- a/crates/exav-unpack-wasm/Cargo.toml +++ b/crates/exav-unpack-wasm/Cargo.toml @@ -1,11 +1,11 @@ # Excluded from the workspace (a wasm32-target artifact), so values are explicit. [package] name = "exav-unpack-wasm" -version = "0.0.2" -edition = "2021" +version = "0.0.3" +edition = "2024" license = "MIT" repository = "https://github.com/sylvinus/exav" -rust-version = "1.92" +rust-version = "1.95" description = "WebAssembly bindings for exav-unpack: in-browser, memory-safe universal archive extraction. Build with `wasm-pack build`." [lib] @@ -55,8 +55,9 @@ minimal = ["zip", "gzip", "tar"] # anything published. testing-faults = [] -all-formats-no-emu = ["exav-unpack/all-formats-no-emu"] -all-formats = ["exav-unpack/all-formats"] +# `zip` and `rar` too: `joinSet` joins those sets with their decoders' own joiners. +all-formats-no-emu = ["exav-unpack/all-formats-no-emu", "zip", "rar"] +all-formats = ["exav-unpack/all-formats", "zip", "rar"] pe-emu = ["exav-unpack/pe-emu"] checksums = ["exav-unpack/checksums"] decrypt = ["exav-unpack/decrypt"] @@ -67,7 +68,9 @@ bzip2 = ["exav-unpack/bzip2"] xz = ["exav-unpack/xz"] zstd = ["exav-unpack/zstd"] lzip = ["exav-unpack/lzip"] -cab = ["exav-unpack/cab"] +lzma = ["exav-unpack/lzma"] +rpm = ["exav-unpack/rpm"] +cab =["exav-unpack/cab"] sevenz = ["exav-unpack/sevenz"] rar = ["exav-unpack/rar"] arj = ["exav-unpack/arj"] @@ -117,11 +120,9 @@ wasm-opt = [ ] [dependencies] -exav-unpack = { path = "../exav-unpack", version = "0.0.2", default-features = false } +exav-unpack = { path = "../exav-unpack", version = "0.0.3", default-features = false } wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" js-sys = "0.3" -flate2 = "1" web-sys = { version = "0.3", features = [ "Blob", "BlobPropertyBag", diff --git a/crates/exav-unpack-wasm/NOTICE b/crates/exav-unpack-wasm/NOTICE index 26b7e3aa..b5915937 100644 --- a/crates/exav-unpack-wasm/NOTICE +++ b/crates/exav-unpack-wasm/NOTICE @@ -101,14 +101,19 @@ Each under the MIT License (MIT OR Apache-2.0 ones used under MIT): https://github.com/mkrueger/unarc-rs - bzip2-rs (bzip2 decoder, vendored): Copyright (c) 2021 Paolo Barbolini. https://github.com/paolobarbolini/bzip2-rs +- unshield and explode (InstallShield .z reader and PKWARE DCL decoder, + ported): Copyright (c) 2020 Aaron Griffith. https://github.com/agrif/unshield, + https://github.com/agrif/explode +- cmpfs (decoder of the files macOS compresses, after its layouts): Copyright + (c) 2026 Dil4rd. https://github.com/Dil4rd/dpp - EggDotNet (AZO decoder, ported): Copyright (c) Andrew Kolman. https://github.com/akolman/EggDotNet - AutoIt-Ripper (compiled AutoIt3 layout and tables): Copyright (c) nazywam and contributors. https://github.com/nazywam/AutoIt-Ripper -NSIS format headers, zlib -------------------------- +NSIS format headers and blast.c, zlib +------------------------------------- exav-unpack's NSIS reader implements the installer layout from NSIS's format header Source/exehead/fileform.h. @@ -116,6 +121,14 @@ header Source/exehead/fileform.h. Upstream: https://github.com/kichik/nsis Copyright (c) Nullsoft and contributors +exav-unpack's PKWARE DCL decoder keeps the algorithm and code-length tables of +blast.c, through the explode port above, altered to decode a whole buffer. + +Upstream: https://github.com/madler/zlib/tree/master/contrib/blast +Copyright (C) Mark Adler + +Each under these terms: + This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. @@ -135,6 +148,42 @@ subject to the following restrictions: 3. This notice may not be removed or altered from any source distribution. +Apple lzfse, BSD-3-Clause (source derivative) +--------------------------------------------- + +exav-unpack's LZFSE and LZVN decoder (DMG runs) is a Rust port of the decoder +of Apple's reference implementation. + +Upstream: https://github.com/lzfse/lzfse + +Copyright (c) 2015-2016, Apple Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder(s) nor the names of any + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Apache-2.0 references --------------------- @@ -167,8 +216,6 @@ the copyright its license file states, or its authors where it states none: adler2 Jonas Schievink aes 2018 Artyom Pavlov; 2018-2024 The RustCrypto Project Developers - apfs 2026 Dil4rd - arraydeque 2018 Andy Lok base64 2015 Alice Maz bitflags 2014 The Rust Project Developers bitstream-io 2017 Brian Langenberger @@ -184,8 +231,6 @@ the copyright its license file states, or its authors where it states none: cmov 2022-2026 The RustCrypto Project Developers const-oid 2020-2026 The RustCrypto Project Developers cpubits 2023-2026 The RustCrypto Project Developers - crc 2017 crc-rs Developers - crc-catalog 2019 Akhil Velagapudi crc32fast 2018 Sam Rijs, Alex Crichton and contributors crypto-common 2021-2026 RustCrypto Developers ctutils 2025-2026 The RustCrypto Project Developers @@ -195,9 +240,6 @@ the copyright its license file states, or its authors where it states none: 2017-2024 The RustCrypto Project Developers digest 2017 Artyom Pavlov; 2017-2025 RustCrypto Developers equivalent 2016-2023 the equivalent authors - explode 2020 Aaron Griffith - ext4-view 2023 Google LLC - fatfs 2017 RafaÅ‚ HarabieÅ„ filetime 2014 Alex Crichton flate2 2014-2026 Alex Crichton fnv 2017 Contributors @@ -205,14 +247,12 @@ the copyright its license file states, or its authors where it states none: futures-task 2016 Alex Crichton; 2017 The Tokio Authors futures-util 2016 Alex Crichton; 2017 The Tokio Authors hashbrown 2016 Amanieu d'Antras - hfsplus 2026 Dil4rd hmac 2017 Artyom Pavlov hybrid-array 2022-2026 The RustCrypto Project Developers indexmap 2016-2017 the indexmap authors inout 2022 Artyom Pavlov; 2022-2025 The RustCrypto Project Developers js-sys 2014 Alex Crichton log 2014 The Rust Project Developers - lzfse_rust 2020 Vin Singh Ghataore lznt1 Cody Wyatt Neiman lzxd 2020 Lonami mail-parser Stalwart Labs @@ -240,17 +280,14 @@ the copyright its license file states, or its authors where it states none: tar The tar-rs Project Contributors thiserror David Tolnay tinyvec 2019 Daniel "Lokathor" Gee - twox-hash 2015 Jake Goulding typed-path Chip Senkbeil typenum 2014 Paho Lurie-Gregg unicode-bidi 2015 The Rust Project Developers unicode-normalization 2015 The Rust Project Developers unicode-properties 2015 The Rust Project Developers - unshield 2020 Aaron Griffith uuid 2014 The Rust Project Developers; 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon wasm-bindgen 2014 Alex Crichton - wasm-bindgen-futures 2014 Alex Crichton wasm-bindgen-shared 2014 Alex Crichton web-sys 2014 Alex Crichton web-time 2023 dAxpeDDa @@ -263,10 +300,6 @@ no NOTICE file: lzma-rust2 the lzma-rust2 authors -Under CC0-1.0 (public domain; no notice required, recorded for completeness): - - constant_time_eq Cesar Eduardo Barros - MIT License ----------- diff --git a/crates/exav-unpack-wasm/README.md b/crates/exav-unpack-wasm/README.md index f267f279..2b7141a1 100644 --- a/crates/exav-unpack-wasm/README.md +++ b/crates/exav-unpack-wasm/README.md @@ -192,6 +192,11 @@ phone browser. - `isVolumePart(name)` → whether a *filename* marks one part of a byte-split archive. Names only, so it is cheap enough to run over a whole drop. - `joinVolumes(files)` → rejoin the split archives among files dropped together. +- `volumeSet(name, names)` → the volumes of the set `name` belongs to among + `names` (the file names of one directory): `{ parts, scheme, complete }`, or + `null` for a name that is not one of two or more volumes. Names only. +- `joinSet(scheme, parts)` → a complete set's volumes, in order, as the one + archive they make (`"split"`, `"rar"` or `"zip"`). ## Split archives @@ -215,9 +220,19 @@ nothing can read any more, and letting them vanish silently is the one outcome worth avoiding. Files that are not part of a set are simply absent from the result; you already have them. -RAR `.partN` and ZIP `.zNN` volumes are not rejoined here. Each carries its own -headers and a member's data resumes *past* the next volume's header, so -concatenating them yields garbage that still looks like an archive. +`joinVolumes` does not rejoin RAR `.partN` and ZIP `.zNN` volumes. Each carries +its own headers and a member's data resumes *past* the next volume's header, so +concatenating them yields garbage that still looks like an archive. `volumeSet` +and `joinSet` read all three kinds of set, the RAR decoder's joiner and the ZIP +spanning reader doing what the format needs: + +```js +const set = volumeSet("x.part1.rar", namesInTheFolder); // { parts, scheme, complete } or null +if (set?.complete) { + const volumes = await Promise.all(set.parts.map(read)); // Uint8Arrays, in order + const archive = await Archive.open(joinSet(set.scheme, volumes)); +} +``` ## Notes diff --git a/crates/exav-unpack-wasm/js/index.d.ts b/crates/exav-unpack-wasm/js/index.d.ts index 6ae8fff7..722b6d84 100644 --- a/crates/exav-unpack-wasm/js/index.d.ts +++ b/crates/exav-unpack-wasm/js/index.d.ts @@ -114,5 +114,23 @@ export function joinVolumes( /** Whether a filename marks one part of a byte-split archive (`big.7z.001`). */ export function isVolumePart(name: string): boolean; +/** + * The volumes of the set `name` belongs to among `names`, the file names of one + * directory: in volume order, by scheme ("split" `big.7z.001`, "rar" `x.part1.rar` + * or `x.rar` with `x.r00`, "zip" `x.z01` to `x.zip`), and whether every volume is + * there. `null` for a name that is not one of two or more volumes. Names only. + */ +export function volumeSet( + name: string, + names: string[], +): { parts: string[]; scheme: "split" | "rar" | "zip"; complete: boolean } | null; + +/** + * A complete set's volumes, in volume order, as the one archive they make, for + * `Archive.open`. RAR and ZIP volumes are joined by their decoders, not by + * concatenation. + */ +export function joinSet(scheme: "split" | "rar" | "zip", parts: Uint8Array[]): Uint8Array; + /** Instantiate the wasm module. Memoised; awaited internally by every call. */ export default function init(module_or_path?: unknown): Promise<unknown>; diff --git a/crates/exav-unpack-wasm/js/index.js b/crates/exav-unpack-wasm/js/index.js index 3f075233..bc28cde4 100644 --- a/crates/exav-unpack-wasm/js/index.js +++ b/crates/exav-unpack-wasm/js/index.js @@ -16,9 +16,11 @@ import init, { detectFormat, joinVolumes, isVolumePart, + volumeSet, + joinSet, } from "../pkg/exav_unpack_wasm.js"; -export { detectFormat, joinVolumes, isVolumePart }; +export { detectFormat, joinVolumes, isVolumePart, volumeSet, joinSet }; /** * Instantiate the module, once. diff --git a/crates/exav-unpack-wasm/package-lock.json b/crates/exav-unpack-wasm/package-lock.json index 0b9bf0ad..3a716303 100644 --- a/crates/exav-unpack-wasm/package-lock.json +++ b/crates/exav-unpack-wasm/package-lock.json @@ -1,109 +1,96 @@ { - "name": "exav-unpack-wasm", - "version": "0.1.0", + "name": "@exav/unpack-wasm", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "exav-unpack-wasm", - "version": "0.1.0", + "name": "@exav/unpack-wasm", + "version": "0.0.3", "license": "MIT", "devDependencies": { - "@playwright/test": "^1.61.1", + "@playwright/test": "^1.64.1", "serve": "^14.2.6", - "vitest": "^4.1.9" + "vitest": "^5.0.5" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "engines": { + "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@oxc-project/types": { - "version": "0.137.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", - "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "dev": true, "license": "MIT", + "peer": true, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", - "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -113,14 +100,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", - "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -130,14 +118,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", - "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -147,14 +136,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", - "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -164,14 +154,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", - "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -181,14 +172,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", - "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -201,14 +193,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", - "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -221,14 +214,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", - "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -241,14 +235,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", - "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -261,14 +256,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", - "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -281,14 +277,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", - "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -301,14 +298,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", - "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -318,33 +316,15 @@ "os": [ "openharmony" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", - "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", - "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -354,14 +334,15 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", - "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -371,6 +352,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -380,25 +362,8 @@ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -425,40 +390,23 @@ "dev": true, "license": "MIT" }, - "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -470,74 +418,16 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@zeit/schemas": { "version": "2.36.0", "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", @@ -746,9 +636,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -917,13 +807,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -965,6 +848,7 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8" } @@ -984,9 +868,9 @@ "license": "MIT" }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -1077,9 +961,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1087,6 +971,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -1211,11 +1096,12 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -1227,23 +1113,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -1253,6 +1139,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1262,9 +1149,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -1274,6 +1161,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1283,9 +1171,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -1295,6 +1183,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1304,9 +1193,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -1316,6 +1205,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1325,9 +1215,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -1337,6 +1227,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1346,9 +1237,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -1361,6 +1252,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1370,9 +1262,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -1385,6 +1277,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1394,9 +1287,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -1409,6 +1302,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1418,9 +1312,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -1433,6 +1327,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1442,9 +1337,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -1454,6 +1349,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1463,9 +1359,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -1475,6 +1371,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -1484,13 +1381,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/merge-stream": { @@ -1574,9 +1471,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -1585,6 +1482,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -1616,9 +1514,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -1679,24 +1577,18 @@ "dev": true, "license": "MIT" }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -1707,41 +1599,38 @@ } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -1758,10 +1647,11 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -1828,13 +1718,14 @@ } }, "node_modules/rolldown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", - "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@oxc-project/types": "=0.137.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1844,21 +1735,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.3", - "@rolldown/binding-darwin-arm64": "1.1.3", - "@rolldown/binding-darwin-x64": "1.1.3", - "@rolldown/binding-freebsd-x64": "1.1.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", - "@rolldown/binding-linux-arm64-gnu": "1.1.3", - "@rolldown/binding-linux-arm64-musl": "1.1.3", - "@rolldown/binding-linux-ppc64-gnu": "1.1.3", - "@rolldown/binding-linux-s390x-gnu": "1.1.3", - "@rolldown/binding-linux-x64-gnu": "1.1.3", - "@rolldown/binding-linux-x64-musl": "1.1.3", - "@rolldown/binding-openharmony-arm64": "1.1.3", - "@rolldown/binding-wasm32-wasi": "1.1.3", - "@rolldown/binding-win32-arm64-msvc": "1.1.3", - "@rolldown/binding-win32-x64-msvc": "1.1.3" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/safe-buffer": { @@ -1957,13 +1848,6 @@ "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -1972,26 +1856,20 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -2063,16 +1941,19 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -2096,24 +1977,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -2149,16 +2012,17 @@ } }, "node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -2175,7 +2039,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2226,54 +2090,32 @@ } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2281,16 +2123,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -2348,20 +2190,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/widest-line": { diff --git a/crates/exav-unpack-wasm/package.json b/crates/exav-unpack-wasm/package.json index 01acf452..4dcde5f1 100644 --- a/crates/exav-unpack-wasm/package.json +++ b/crates/exav-unpack-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@exav/unpack-wasm", - "version": "0.0.2", + "version": "0.0.3", "type": "module", "description": "WebAssembly bindings for exav-unpack: in-browser, memory-safe universal archive extraction. Supports zip, rar, 7z, tar, gz, bz2, xz, cab, arj, lha, iso, cpio, ar, xar, ole, pdf, email, UPX.", "license": "MIT", @@ -62,8 +62,8 @@ }, "homepage": "https://exav.org/unpack/wasm/", "devDependencies": { - "@playwright/test": "^1.61.1", + "@playwright/test": "^1.64.1", "serve": "^14.2.6", - "vitest": "^4.1.9" + "vitest": "^5.0.5" } } diff --git a/crates/exav-unpack-wasm/src/lib.rs b/crates/exav-unpack-wasm/src/lib.rs index eefcc96c..5887f1d8 100644 --- a/crates/exav-unpack-wasm/src/lib.rs +++ b/crates/exav-unpack-wasm/src/lib.rs @@ -29,7 +29,7 @@ mod source; use exav_unpack::source::BlockCache; use exav_unpack::{ - walk, Budget, ByteSource, Entry as UnpackEntry, Format, LimitHit, Limits, Member, MemberMeta, + Budget, ByteSource, Entry as UnpackEntry, Format, LimitHit, Limits, Member, MemberMeta, walk, }; use js_sys::{Array, Object, Reflect, Uint8Array}; use source::Src; @@ -248,16 +248,16 @@ fn limits_from_js(v: &JsValue) -> Limits { // An unknown name is ignored rather than rejected: a page pinned to an // older module should not fail outright because it listed a format that // build does not have, and anything not listed is excluded anyway. - if let Ok(v) = Reflect::get(v, &JsValue::from_str("allowedFormats")) { - if let Some(arr) = v.dyn_ref::<Array>() { - let mut set = std::collections::BTreeSet::new(); - for item in arr.iter() { - if let Some(f) = item.as_string().and_then(|n| format_by_name(&n)) { - set.insert(f); - } + if let Ok(v) = Reflect::get(v, &JsValue::from_str("allowedFormats")) + && let Some(arr) = v.dyn_ref::<Array>() + { + let mut set = std::collections::BTreeSet::new(); + for item in arr.iter() { + if let Some(f) = item.as_string().and_then(|n| format_by_name(&n)) { + set.insert(f); } - l.allowed_formats = Some(set); } + l.allowed_formats = Some(set); } l } @@ -588,3 +588,116 @@ pub fn join_volumes(files: &Array) -> Result<Array, JsValue> { pub fn is_volume_part(name: &str) -> bool { exav_unpack::volume::parse(name).is_some_and(|v| v.scheme.is_byte_split()) } + +/// The volumes of the set `name` belongs to, among `names` (the file names of +/// one directory): `{ parts, scheme, complete }`, `parts` in volume order, +/// `scheme` "split" (`big.7z.001`), "rar" (`x.part1.rar`, `x.rar` and +/// `x.r00`) or "zip" (`x.z01` ... `x.zip`), and `complete` whether the set has +/// every volume from the first to the last. `null` where `name` is not one of +/// two or more volumes: a lone `x.rar` or `x.zip` is an archive on its own. +/// +/// Names only, matched as `volume::parse` reads them: nothing is opened, and +/// nothing outside `names` can be named. +#[wasm_bindgen(js_name = "volumeSet")] +pub fn volume_set(name: &str, names: Vec<String>) -> JsValue { + use exav_unpack::volume::{self, Scheme}; + let ci = |a: &str, b: &str| a.eq_ignore_ascii_case(b); + // `x.zip` is the last volume of `x.z01`, `x.z02` ... + let zip_last = name + .len() + .checked_sub(4) + .and_then(|at| name.get(..at).zip(name.get(at..))) + .filter(|(_, ext)| ci(ext, ".zip")) + .map(|(stem, _)| stem.to_string()); + let seed = match volume::parse(name) { + Some(v) => v, + None => match zip_last { + Some(stem) => volume::VolumeName { + stem, + index: 0, + scheme: Scheme::ZipSplit, + }, + None => return JsValue::NULL, + }, + }; + let mut found: Vec<(usize, String)> = Vec::new(); + for n in &names { + if let Some(v) = volume::parse(n) + && seed.same_set_as(&v) + { + found.push((v.index, n.clone())); + } + } + let mut last_zip = None; + if matches!(seed.scheme, Scheme::ZipSplit) { + last_zip = names + .iter() + .find(|n| ci(n, &format!("{}.zip", seed.stem))) + .cloned(); + } + found.sort_by_key(|(i, _)| *i); + // Two names for one position: the shape is not the one a writer made. + let clash = found.array_windows().any(|[a, b]| a.0 == b.0); + let mut parts: Vec<String> = found.iter().map(|(_, n)| n.clone()).collect(); + let mut complete = !clash && found.iter().enumerate().all(|(k, (i, _))| k == *i); + if matches!(seed.scheme, Scheme::ZipSplit) { + // The central directory is in the last part: no `.zip`, no archive. + complete &= last_zip.is_some(); + parts.extend(last_zip); + } + if parts.len() < 2 { + return JsValue::NULL; + } + let scheme = match seed.scheme { + Scheme::NumberedSuffix { .. } => "split", + Scheme::RarPart { .. } | Scheme::RarOld => "rar", + Scheme::ZipSplit => "zip", + }; + let out = Object::new(); + let list = Array::new(); + for p in &parts { + list.push(&JsValue::from_str(p)); + } + let _ = Reflect::set(&out, &"parts".into(), &list); + let _ = Reflect::set(&out, &"scheme".into(), &JsValue::from_str(scheme)); + let _ = Reflect::set(&out, &"complete".into(), &JsValue::from_bool(complete)); + out.into() +} + +/// The volumes of a complete set, given in volume order, as the one archive +/// they make: "split" parts end to end, a "rar" set through the RAR decoder's +/// joiner, a "zip" set (`.z01` ... `.zip`) read as one stream. The result is +/// what `Archive.open` takes. +#[wasm_bindgen(js_name = "joinSet")] +pub fn join_set(scheme: &str, parts: &Array) -> Result<Uint8Array, JsValue> { + let mut volumes: Vec<Vec<u8>> = Vec::new(); + for p in parts.iter() { + let bytes = p + .dyn_ref::<Uint8Array>() + .ok_or_else(|| JsValue::from_str("each part must be a Uint8Array"))?; + volumes.push(bytes.to_vec()); + } + let joined: Vec<u8> = match scheme { + "split" => volumes.concat(), + #[cfg(feature = "rar")] + "rar" => { + let slices: Vec<&[u8]> = volumes.iter().map(Vec::as_slice).collect(); + exav_unpack::join_rar_volumes(&slices).map_err(err)? + } + #[cfg(feature = "zip")] + "zip" => { + let boxed: Vec<Box<dyn ByteSource>> = volumes + .into_iter() + .map(|v| Box::new(v) as Box<dyn ByteSource>) + .collect(); + let span = exav_unpack::span::ZipSpan::new(boxed).map_err(err)?; + span.window(0, span.len()).into_owned() + } + other => { + return Err(JsValue::from_str(&format!( + "a \"{other}\" set cannot be joined by this build" + ))); + } + }; + Ok(Uint8Array::from(joined.as_slice())) +} diff --git a/crates/exav-unpack-wasm/src/source.rs b/crates/exav-unpack-wasm/src/source.rs index 6d1d4328..7a9ed432 100644 --- a/crates/exav-unpack-wasm/src/source.rs +++ b/crates/exav-unpack-wasm/src/source.rs @@ -87,7 +87,8 @@ impl<F: Fetch> Seek for Windowed<F> { // Seeking PAST the end is legal and reads nothing; the next `read` // returns zero bytes. Refusing here would reject a reader that seeks to // a computed offset before checking it. - self.pos = u64::try_from(at).map_err(|_| io::Error::other("seek past the largest offset"))?; + self.pos = + u64::try_from(at).map_err(|_| io::Error::other("seek past the largest offset"))?; Ok(self.pos) } } diff --git a/crates/exav-unpack-wasm/tests/volumes.test.ts b/crates/exav-unpack-wasm/tests/volumes.test.ts new file mode 100644 index 00000000..1bf55f62 --- /dev/null +++ b/crates/exav-unpack-wasm/tests/volumes.test.ts @@ -0,0 +1,93 @@ +// The split archives `volumeSet` tells from names and `joinSet` joins, on the +// module as built (`wasm-pack build`): the RAR sets are the ones the official +// tool wrote, which exav-unpack's tests read too. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { beforeAll, describe, expect, it } from 'vitest'; + +// @ts-expect-error the facade is plain JS beside its own declarations +import init, { Archive, joinSet, volumeSet } from '../js/index.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const rar = path.join(here, '..', '..', 'exav-unpack', 'tests', 'fixtures', 'rar_volumes'); +const read = (name: string) => new Uint8Array(fs.readFileSync(path.join(rar, name))); + +beforeAll(async () => { + await init({ module_or_path: fs.readFileSync(path.join(here, '..', 'pkg', 'exav_unpack_wasm_bg.wasm')) }); +}); + +describe('the volumes of a set, from names', () => { + it('are in volume order whichever the order of the names', () => { + expect(volumeSet('big.7z.001', ['big.7z.003', 'big.7z.001', 'big.7z.002', 'note.txt'])).toEqual({ + parts: ['big.7z.001', 'big.7z.002', 'big.7z.003'], + scheme: 'split', + complete: true, + }); + expect(volumeSet('a.part2.rar', ['a.part3.rar', 'a.part1.rar', 'a.part2.rar'])).toEqual({ + parts: ['a.part1.rar', 'a.part2.rar', 'a.part3.rar'], + scheme: 'rar', + complete: true, + }); + // The bare .rar is the first, .r00 the second. + expect(volumeSet('a.r01', ['a.r01', 'a.rar', 'a.r00'])?.parts).toEqual(['a.rar', 'a.r00', 'a.r01']); + }); + + it('end with the .zip for a ZIP spanned over files, from any of its parts', () => { + for (const name of ['s.z01', 's.z02', 's.zip']) { + expect(volumeSet(name, ['s.zip', 's.z02', 's.z01'])).toEqual({ parts: ['s.z01', 's.z02', 's.zip'], scheme: 'zip', complete: true }); + } + }); + + it('are incomplete with a volume missing, and a set only of those the names have', () => { + expect(volumeSet('big.7z.001', ['big.7z.001', 'big.7z.003'])?.complete).toBe(false); + expect(volumeSet('a.part1.rar', ['a.part1.rar', 'a.part2.rar'])?.complete).toBe(true); + expect(volumeSet('a.part2.rar', ['a.part2.rar', 'a.part3.rar'])?.complete).toBe(false); + // The central directory is in the last part. + expect(volumeSet('s.z01', ['s.z01', 's.z02'])?.complete).toBe(false); + expect(volumeSet('s.z01', ['s.z01', 's.z02', 's.zip'])?.complete).toBe(true); + expect(volumeSet('s.z01', ['s.z01', 's.z03', 's.zip'])?.complete).toBe(false); + }); + + it('are not found for a file that is one on its own, or in another set of the same stem', () => { + expect(volumeSet('a.rar', ['a.rar'])).toBeNull(); + expect(volumeSet('a.zip', ['a.zip'])).toBeNull(); + expect(volumeSet('notes.txt', ['notes.txt', 'notes.002'])).toBeNull(); + expect(volumeSet('a.part1.rar', ['a.part1.rar', 'a.7z.001', 'a.7z.002'])).toBeNull(); + expect(volumeSet('a.7z.001', ['a.7z.001', 'a.zip.002'])).toBeNull(); + // Case does not matter to the stem or the scheme. + expect(volumeSet('A.PART1.RAR', ['A.PART1.RAR', 'a.part2.rar'])?.parts).toEqual(['A.PART1.RAR', 'a.part2.rar']); + }); + + it('are not found when two names claim a position', () => { + expect(volumeSet('a.7z.001', ['a.7z.001', 'a.7z.01', 'a.7z.002'])?.complete ?? false).toBe(false); + }); +}); + +describe('a set joined', () => { + it('is read whole by its decoder: a RAR member that continues in the next volume has its size and bytes', async () => { + for (const names of [ + ['set5.part1.rar', 'set5.part2.rar', 'set5.part3.rar'], + ['set4.rar', 'set4.r00', 'set4.r01'], + ]) { + const set = volumeSet(names[0]!, names)!; + const parts = set.parts.map(read); + const size = async (data: Uint8Array) => { + const archive = await Archive.open(data); + const noise = (await archive.list()).find((m: { name: string }) => m.name === 'noise.bin'); + return noise.uncompressedSize as number; + }; + expect(await size(parts[0]!), 'the first volume on its own').toBe(-1); + expect(await size(joinSet('rar', parts))).toBe(20000); + } + }); + + it('is the parts end to end for a split, and a ZIP spanned over files read as one', async () => { + const part = (n: number) => new Uint8Array([n]); + expect([...joinSet('split', [part(1), part(2), part(3)])]).toEqual([1, 2, 3]); + expect(() => joinSet('rar', [part(1), part(2)])).toThrow(); + expect(() => joinSet('lzh', [part(1)])).toThrow(/cannot be joined/); + expect(() => joinSet('split', [1 as unknown as Uint8Array])).toThrow(/Uint8Array/); + }); +}); diff --git a/crates/exav-unpack/Cargo.toml b/crates/exav-unpack/Cargo.toml index 41e48e57..14327ef3 100644 --- a/crates/exav-unpack/Cargo.toml +++ b/crates/exav-unpack/Cargo.toml @@ -41,7 +41,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std" # it is useful on its own and because it is the only thing that needs a CPU # decoder: keeping it out of here means a build without `pepack` never compiles # `exav-x86` at all. -exav-pe-emu = { version = "0.0.2", path = "../exav-pe-emu", optional = true } +exav-pe-emu = { version = "0.0.3", path = "../exav-pe-emu", optional = true } # --- Per-format optional deps. Each is pulled in only by the feature(s) below # that need it; a dep shared by several formats (e.g. `flate2`) is compiled if @@ -56,34 +56,21 @@ zip = { version = "9", default-features = false, features = ["deflate-flate2", " # entirely (~8,600 `unsafe` ops, the single largest source in the dep tree). tar = { version = "0.4", default-features = false, optional = true } # For the bzip2 decoder vendored from `bzip2-rs` in `formats/bzip2_rs`. -tinyvec = { version = "1.11", optional = true } +tinyvec = { version = "1.13", optional = true } byteorder = { version = "1", optional = true } lzxd = { version = "0.2", optional = true } -# FAT filesystem reader (a few `unsafe` uses of its own); `chrono` (timestamps) -# is left off, so the only transitive deps are `bitflags` and `log`. -fatfs = { version = "0.3", default-features = false, features = ["std", "alloc"], optional = true } # LZNT1, the compression NTFS applies per compression unit. Single-purpose and # dependency-free; the MFT walk itself is in-tree. lznt1 = { version = "0.1", optional = true } -# ext2/3/4 reader. Read-only by construction, no `unsafe`, no dependencies. -ext4-view = { version = "1", optional = true } # LZW, for ZOO's LZD codec. Dependency-free. salzweg = { version = "0.1", optional = true } -# InstallShield `.z` reader. Its only real dep is `explode` (MIT), a leaf -# decoder for the PKWARE DCL "implode" codec those archives use. -unshield = { version = "0.2", default-features = false, optional = true } -# The same decoder, read through a cap of our own: `unshield` only gives a -# member whole, whatever it expands to. -explode = { version = "0.1", optional = true } -# structured documents that contain embedded files -cfb = { version = "0.14.0", optional = true } # AutoCAD drawings before 2007 are in the code page `$DWGCODEPAGE` names, # among them the double-byte Asian ones, which `\M+` escapes use too. # encoding_rs is the WHATWG decoder set Firefox ships; built without # `simd-accel`. encoding_rs = { version = "0.8.42", default-features = false, features = ["alloc"], optional = true } -mail-parser = { version = "0.11.4", optional = true } -delharc = { version = "0.6.2", optional = true } +mail-parser = { version = "0.11.9", optional = true } +delharc = { version = "0.8.0", optional = true } # ARJ decompression: bitstream-io for the "fastest" LZ77 codec bitstream-io = { version = "4.10.0", optional = true } # Pure-Rust crypto for decrypting password-known encrypted archive members @@ -95,31 +82,42 @@ aes = { version = "0.9", optional = true } hmac = { version = "0.13", optional = true } sha1 = { version = "0.11", optional = true } pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"], optional = true } -constant_time_eq = { version = "0.3", optional = true } -# DMG support -hfsplus = { version = "0.2", optional = true } -apfs = { version = "0.2", optional = true } -lzfse_rust = { version = "0.2.1", optional = true } -base64 = { version = "0.22", optional = true } -quick-xml = { version = "0.41", optional = true } +base64 = { version = "0.23", optional = true } +quick-xml = { version = "0.42", optional = true } des = { version = "0.9", default-features = false, optional = true } cbc = { version = "0.2", optional = true } -flate2 = { version = "1.1.9", optional = true } +flate2 = { version = "1.1.10", optional = true } # ZIP method 9 (Deflate64 / "enhanced deflate"). A leaf dep: zero runtime # dependencies, MIT, and `#![forbid(unsafe_code)]` like this crate. It is the # same decoder the `zip` crate uses behind its own `deflate64` feature; we depend # on it directly so the raw-bytes path (orphan local headers, where we never go # through the `zip` crate's reader) decodes method 9 too. deflate64 = { version = "0.1.12", optional = true } -lzma-rust2 = { version = "0.21", default-features = false, features = ["std"], optional = true } +lzma-rust2 = { version = "0.23", default-features = false, features = ["std"], optional = true } # PDF crypto primitives (replacing hand-rolled implementations) md-5 = { version = "0.11", optional = true } sha2 = { version = "0.11", optional = true } digest = { version = "0.11", optional = true } stringprep = { version = "0.1", optional = true } -xz4rust = { version = "0.2.1", default-features = false, features = ["std", "bcj", "crc64", "delta", "no_unsafe"], optional = true } -ruzstd = { version = "0.8", optional = true } -crc32fast = { version = "1.5.0", optional = true } +xz4rust = { version = "0.2.3", default-features = false, features = ["std", "bcj", "crc64", "delta", "no_unsafe"], optional = true } +# 0.9.1 bounds a block's decoded size (RUSTSEC-2026-0344). Without `hash` +# (`twox-hash`): exav does not check zstd's content checksum. +ruzstd = { version = "0.9.1", default-features = false, features = ["std"], optional = true } +crc32fast = { version = "1.5.3", optional = true } +# HTML (`html`) and the text of a document (`text`, `text-pdf`). HTML is read by html5gum's +# tokenizer alone (no tree; `forbid(unsafe_code)`, no dependency without its +# `jetscii` feature), a PDF's text by hayro, which draws glyphs with their +# Unicode: the same decoders (hayro-jpeg2000, hayro-jbig2) that +# exav-render uses. Pinned with `=` for `cargo install`. +# +# hayro-interpret without `embed-cmaps` (the predefined CJK CMaps, brotli +# compressed: it brings `brotli`), and it turns on hayro-syntax's `unsafe` +# feature itself, which switches every `flate2` in the build to zlib-rs and +# adds SIMD to the image decoders: `text-pdf` is therefore not a default. +html5gum = { version = "=0.8.4", default-features = false, optional = true } +hayro-interpret = { version = "=0.8.0", default-features = false, features = ["embed-fonts"], optional = true } +hayro-syntax = { version = "=0.8.0", optional = true } +kurbo = { version = "0.13", optional = true } [features] default = ["all-formats", "decrypt", "cli"] @@ -145,11 +143,11 @@ all-formats = ["all-formats-no-emu", "pe-emu"] # attacker-supplied control flow. `pepack` stays in: it decompresses known # packers with native decoders and does not execute anything. all-formats-no-emu = [ - "zip", "gzip", "tar", "bzip2", "xz", "zstd", "lzip", "cab", "sevenz", "rar", + "zip", "gzip", "tar", "bzip2", "xz", "zstd", "lzip", "lzma", "rpm", "cab", "sevenz", "rar", "arj", "lha", "iso", "ole", "pdf", "email", "dmg", "upx", "ar", "cpio", "xar", "chm", "uuencode", "xdp", "szdd", "tnef", "swf", "binhex", "lnk", "partition", "vhd", "lzw", "diskimage", "wim", "lz4", "arc", "ace", "stuffit", "alz", "egg", "hwp3", - "fat", "ext", "zoo", "ishieldz", "inno", "ntfs", "pyc", + "fat", "exfat", "squashfs", "cramfs", "romfs", "erofs", "ext", "zoo", "ishieldz", "inno", "ntfs", "pyc", "nsis", "machofat", "sfx", "autoit", "onenote", "rtf", "pepack", "javaclass", "aimodel", "screnc", "base64scan", "dxf", "dwg", ] @@ -162,10 +160,12 @@ bzip2 = ["bzip2-decoder"] # Internal: the vendored bzip2 decoder, shared by every format that carries # bzip2. Not a format of its own; select `bzip2` instead. bzip2-decoder = ["dep:tinyvec", "dep:crc32fast"] -xz = ["dep:xz4rust"] +xz = ["dep:xz4rust", "dep:crc32fast"] zstd = ["dep:ruzstd"] lzip = ["dep:lzma-rust2", "dep:crc32fast"] -cab = ["dep:lzxd", "dep:flate2", "dep:byteorder"] +lzma = ["dep:lzma-rust2"] +rpm = [] +cab =["dep:lzxd", "dep:flate2", "dep:byteorder"] chm = ["dep:lzxd"] sevenz = ["dep:lzma-rust2", "bzip2-decoder", "dep:flate2", "dep:crc32fast"] rar = [] @@ -225,16 +225,28 @@ alz = ["dep:flate2", "bzip2-decoder"] egg = ["dep:flate2", "bzip2-decoder", "dep:lzma-rust2"] hwp3 = ["dep:flate2"] # FAT filesystem reader. -fat =["dep:fatfs"] +fat = [] +# exFAT, walked like FAT: a fragmented file comes back whole from its chain. +exfat = [] +# SquashFS 4, in the compressions it has: gzip, LZMA, LZO (in-tree), XZ, LZ4 +# and Zstandard, whose decoders are those of the formats of the same names. +squashfs = ["dep:flate2", "xz", "lzma", "lz4", "zstd"] +# cramfs, the small read-only filesystem of boot images: blocks of zlib. +cramfs = ["dep:flate2"] +# romfs, the smallest read-only filesystem: files stored as they are. +romfs = [] +# EROFS, the read-only filesystem of Android and of container images; the files +# stored as they are. A compressed file is reported. +erofs = [] # ext2/3/4, walked as a filesystem for the same reason as FAT: a fragmented file # comes back whole from the inode's block map and in pieces from a carve. -ext = ["dep:ext4-view"] +ext = [] # ZOO carries LZD (LZW) and LZH. The latter is `lh5` on the wire, so `delharc` # already covers it. zoo = ["dep:salzweg", "dep:delharc"] # InstallShield `.z`. NOT the `ISc(` InstallScript cabinet, whose only # implementation is LGPL and which stays recognised-but-unopened. -ishieldz = ["dep:unshield", "dep:explode"] +ishieldz = [] inno = [] # NTFS is walked through the MFT rather than as a filesystem tree: a scanner # wants every file's bytes, and the MFT also still holds deleted records. @@ -242,9 +254,17 @@ ntfs = ["dep:lznt1"] upx = ["dep:flate2", "dep:lzma-rust2", "dep:base64"] # Structured documents. -ole = ["dep:cfb", "dep:flate2"] +ole = ["dep:flate2", "dep:encoding_rs"] +# The builders of the tests of what reads compound files (`cfb::build`). +test-util = [] pdf = ["dep:flate2"] -email = ["dep:mail-parser", "dep:base64"] +# HTML as tokens and its bytes as text (the public `html` module). +html = ["dep:html5gum", "dep:encoding_rs"] +# The text a reader sees in HTML and in the XML parts of Office and +# OpenDocument files (the public `text` module). `text-pdf` adds PDF's. +text = ["html", "dep:quick-xml"] +text-pdf = ["text", "pdf", "dep:hayro-interpret", "dep:hayro-syntax", "dep:kurbo"] +email =["dep:mail-parser", "dep:base64"] # AutoCAD DXF: the files a drawing embeds (an OLE2FRAME's object), and the # public `dxf` module that drawing readers (exav-render's) build on. dxf = ["dep:encoding_rs"] @@ -256,7 +276,7 @@ dwg = ["dep:encoding_rs"] # Apple disk images (UDIF + HFS+/APFS). The encrypted-DMG cipher stack is under # the `decrypt` feature, not here. dmg = [ - "dep:hfsplus", "dep:apfs", "dep:lzfse_rust", "dep:xz4rust", "bzip2-decoder", + "dep:xz4rust", "bzip2-decoder", "dep:flate2", "dep:base64", "dep:quick-xml", ] @@ -269,9 +289,12 @@ dmg = [ # same as when no password is supplied. Effective only combined with a format # that has encryptable members. ARJ's ciphers come with `arj`, not this. decrypt = [ - "dep:aes", "dep:hmac", "dep:pbkdf2", "dep:sha1", "dep:constant_time_eq", + "offcrypto", "dep:aes", "dep:hmac", "dep:pbkdf2", "dep:sha1", "dep:des", "dep:cbc", "dep:md-5", "dep:sha2", "dep:digest", "dep:stringprep", ] +# Excel workbooks saved with a password: RC4, RC4 CryptoAPI and XOR +# obfuscation (the public `offcrypto` module), which exav-render opens too. +offcrypto = ["dep:md-5", "dep:sha1"] # Verify RAR5-decompressed output against the header CRC-32 (debug assert). # Off by default; enabled by the rar5_check example/tests. @@ -316,23 +339,26 @@ required-features = ["zip", "cli"] sha2 = "0.11" # Test helpers build ZIP fixtures and gunzip stored disk images; as optional # normal dependencies they would be missing from a single-format test build. -flate2 = "1.1.9" +flate2 = "1.1.10" zip = { version = "9", default-features = false, features = ["deflate-flate2"] } # Reference PPMd7 (var.H) and PPMd8 (var.I) encoders, used only to validate the # models in `formats/ppmd7` and `formats/ppmd8` (CC0-1.0/MIT-0). ppmd-rust = "1" # Used only in tests to create 7z archives for roundtrip testing. -sevenz-rust2 = { version = "0.20.2", default-features = false, features = ["bzip2", "ppmd", "util", "compress"] } +sevenz-rust2 = { version = "0.24.1", default-features = false, features = ["bzip2", "ppmd", "util", "compress"] } # Used only in tests to build CAB fixtures for roundtrip testing. cab = "0.6.0" # Its XZ writer, used only in tests to build XZ (method 95) ZIP members. -lzma-rust2 = { version = "0.21", default-features = false, features = ["std", "encoder", "xz"] } +lzma-rust2 = { version = "0.23", default-features = false, features = ["std", "encoder", "xz"] } # Reference bzip2 encoder (a port of libbzip2), used only in tests to build # streams the vendored decoder must read. bzip2 = "0.6" -# Used only in tests to prove the strict CFB reader rejects a malformed -# compound file that exav's lenient OLE fallback still recovers. -cfb = "0.14.0" +# Used only in tests: an independent writer and strict reader of compound +# files, to build them and to check exav's lenient reader against. +cfb = "0.15.0" +# The oracle `formats/lzfse` is checked against, and its encoder builds the +# streams it reads. +lzfse_rust = "0.2.1" # docs.rs builds with every feature on, so the parts gated behind a feature # (in particular exav-core's `unstable-internals`) appear on the rendered page diff --git a/crates/exav-unpack/MEMORY.md b/crates/exav-unpack/MEMORY.md index 34e456f7..fcc7d818 100644 --- a/crates/exav-unpack/MEMORY.md +++ b/crates/exav-unpack/MEMORY.md @@ -70,7 +70,9 @@ goes through the same pipeline (`scan_object`). `Read` decoder straight off the source, no buffering on either side. - **Stored-offset containers** parse a small header or table through `Read + Seek`, then seek and `take` each member (`stream_stored`): ar, cpio, - partition, machofat, tnef, onenote, sfx, pyc, and the ISO 9660 trees of iso. + partition (a volume over `max_buffer_bytes` that holds a filesystem exav + reads is walked in place instead), machofat, tnef, onenote, sfx, pyc, and the ISO 9660 trees + of iso. A part of a table left unwalked (too many partitions or ISO directories, a truncated table) is a member of its own, reported unsupported. - **ISO** walks its ISO 9660 trees and then its UDF tree, skipping files the @@ -82,10 +84,44 @@ goes through the same pipeline (`scan_object`). FWS header. **szdd**: SZDD's LZSS as a streaming `Read`; KWAJ is decoded whole. - **Solid blocks (7z, cab)**: the block or folder is a forward-only `Read`; each file is a `take(size)` window after skipping to its offset, so the - decompressed solid unit is never buffered. 7z still holds its compressed - input, and AES members take the buffered CRC/password path. + decompressed solid unit is never buffered. 7z reads its header from the end + of the source and a folder's packed bytes from where they are + (`source::Reader`), so the archive is not held; the members before the one + wanted are skipped and charged to the scan budget before they are decoded. + AES folders take the buffered CRC/password path and BCJ2 folders the graph + decoder, each holding the folder's packed bytes (at most `max_buffer_bytes`). +- **WIM** (`formats/wim`): the header, the table of resources and the directory + tree (held, up to `max_buffer_bytes`) are read from where they are; a file + resource is a `ResourceReader` that reads and decodes a chunk at a time. With + checksums verified its SHA-1 is checked as it is read, and a mismatch is an + error at its end. **xar**: the table of contents is inflated (up to 64 MiB) + and each member is a reader over its range of the heap. - **DMG**: the UDIF disk is a `Read + Seek` over its decoded runs, and its - HFS+/APFS files are read from it. + HFS+/APFS files are read from it (`formats/hfsplus.rs`, `formats/apfs.rs`). + The directory records of a volume are kept, up to `max_members`, to build + paths; a file within `max_buffer_bytes` is held while it is read, a longer + one is streamed. +- **Virtual disks (VHD, VHDX, QCOW2, VMDK)**: a `VDisk` (`formats/vdisk.rs`), + a `Read + Seek` that reads the container's tables from the source as units + are reached and holds nothing but a cache of decoded compressed units (32 + MiB at most) and, for a streamOptimized VMDK, an index of one entry per grain + (counted against `max_buffer_bytes`). A disk of at most `max_buffer_bytes` is + read into one member; a larger one is walked in place (`formats/volumes.rs`): + its filesystems (NTFS, FAT, exFAT, ext, HFS+, APFS, SquashFS, cramfs, romfs, + EROFS) are read through the disk as a `Read + Seek`, one file at a time, a + file within `reserve()` held and a longer one streamed (`vol::Got::Stream`). + What the disk decodes is charged to the scan budget. A + raw image of one of them, and a raw partitioned disk with a volume over the + limit, are walked the same way. +- **The filesystems** (`formats/{ntfs,fat,exfat,ext,hfsplus,apfs,squashfs, + cramfs,romfs,erofs}.rs`, on `formats/vol.rs`) hold a few blocks of metadata + and one file of at most `reserve()` bytes, whatever the volume; a longer file + is a `Read` over its stretches (`vol::Pieces` over a `vol::Plan`, or a + `vol::Fill` of their own for the compressed ones), which fails where the + volume stops giving it. Directories are read a block at a time; + the total read of directories is bounded by the volume's size (a directory + that lists one above it, or shares blocks with another, is walked once or + runs the bound out and is reported). - **Everything else** is read whole (`read_whole`: zero-copy when the source is in memory, bounded by `max_buffer_bytes`), and its members come out as `Bytes`. @@ -134,7 +170,8 @@ it. so unwritten extents (zeroes) cost nothing until then. - **Decoders that thread `max_buffer`**: CAB (`Cabinet::new`, each folder and the combined buffer), 7z solid blocks, PPMd input and output, BCJ filters, DMG - HFS+/APFS files (read into a writer capped at `reserve()`), an encrypted + HFS+/APFS files (a size past `reserve()` is a limit; a compressed one is + unpacked under the same cap), an encrypted DMG (decrypted whole), encrypted ZIP members (`read_encrypted_member`), ARJ (the whole archive on entry), VBA (`decompress`, `build_artifacts`), the `ar` name table, the RAR3 window diff --git a/crates/exav-unpack/NOTICE b/crates/exav-unpack/NOTICE index 5dbbcdb8..b9943778 100644 --- a/crates/exav-unpack/NOTICE +++ b/crates/exav-unpack/NOTICE @@ -132,6 +132,66 @@ Upstream: https://github.com/akolman/EggDotNet Copyright (c) Andrew Kolman +unshield and explode, MIT (source derivatives) +---------------------------------------------- + +src/formats/ishield_z.rs reads InstallShield .z tables of contents as the +unshield crate 0.2.0 does, and src/formats/dcl_explode.rs decodes their PKWARE +DCL "implode" members as the explode crate 0.1.2 does, a port of blast.c +(below). + +Upstream: https://github.com/agrif/unshield, https://github.com/agrif/explode +Copyright (c) 2020 Aaron Griffith + + +cmpfs decmpfs decoder, MIT (source derivative) +---------------------------------------------- + +src/formats/decmpfs.rs, the decoder of the files macOS compresses (decmpfs) for +HFS+ and APFS, follows cmpfs 0.1.0's reading of the attribute and of the +resource fork (the types, the block tables, the stored-block markers), over +exav's own codecs and with its own bounds. Used under the MIT License. + +Upstream: https://github.com/Dil4rd/dpp +Copyright (c) 2026 Dil4rd + + +Apple lzfse, BSD-3-Clause (source derivative) +--------------------------------------------- + +src/formats/lzfse.rs, the LZFSE and LZVN decoder of DMG runs, is a Rust port of +the decoder of Apple's reference implementation, changed to decode a whole +buffer into a capped one. + +Upstream: https://github.com/lzfse/lzfse +Copyright (c) 2015-2016, Apple Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder(s) nor the names of any + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + AutoIt-Ripper, MIT (reference implementation) --------------------------------------------- @@ -143,8 +203,8 @@ Upstream: https://github.com/nazywam/AutoIt-Ripper Copyright (c) nazywam and contributors -NSIS format headers, zlib -------------------------- +NSIS format headers and blast.c, zlib +------------------------------------- src/formats/nsis.rs implements the installer layout from NSIS's format header Source/exehead/fileform.h. @@ -152,6 +212,14 @@ Source/exehead/fileform.h. Upstream: https://github.com/kichik/nsis Copyright (c) Nullsoft and contributors +src/formats/dcl_explode.rs keeps the algorithm and code-length tables of +blast.c, through the explode port above, altered to decode a whole buffer. + +Upstream: https://github.com/madler/zlib/tree/master/contrib/blast +Copyright (C) Mark Adler + +Each under these terms: + This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. @@ -191,14 +259,23 @@ Copyright the sevenz-rust2 authors. Licensed under the Apache License, Version 2.0 (text below). -Public-domain sources, recorded for completeness ------------------------------------------------- +Specifications and public-domain sources, recorded for completeness +------------------------------------------------------------------- - src/formats/ppmd7/ is vendored and adapted from ppmd-rust 1.4.0 (https://crates.io/crates/ppmd-rust), CC0-1.0 OR MIT-0. - src/formats/aplib.rs reproduces the bit and gamma readers of the reference depack.c from aPLib by Jørgen Ibsen, released free to use (https://ibsensoftware.com/products_aPLib.html). +- The BIFF reader (src/formats/biff/) follows Microsoft's [MS-XLS] under the + Microsoft Open Specifications Promise, and, for BIFF2 to BIFF5 formula + tokens and the argument counts of the function table (ftab.rs), + OpenOffice.org's "Microsoft Excel File Format" documentation (Daniel Rentz, + Public Documentation License 1.0). +- The DOS and Mac code page tables in src/formats/charset_tables.rs (the + ones encoding_rs does not have) are generated by scripts/codepages.py from + the mapping tables of Python's codecs, which are the Unicode Consortium's + published mappings. - The decode tables in src/formats/screnc.rs are the Microsoft Script Encoder constants as published in Didier Stevens' public-domain decode-vbe.py (https://blog.didierstevens.com/programs/). diff --git a/crates/exav-unpack/README.md b/crates/exav-unpack/README.md index 6cde2f02..ff0c698f 100644 --- a/crates/exav-unpack/README.md +++ b/crates/exav-unpack/README.md @@ -15,10 +15,15 @@ member is reported as unsupported, never silently dropped. zstd, XZ, PPMd and the PKZIP 1.x ones), gzip, tar, xz, bzip2, zstd, lzip, LZ4, Unix `compress`, CAB, 7z, RAR (RAR3 LZ+PPMd, RAR5 LZ), ARJ, LHA, ZOO, ARC, EGG, ALZ, ISO 9660 and UDF, ar, cpio, xar, WIM, OLE2, PDF, MIME email and TNEF, Apple -DMG (UDIF + HFS+/APFS), virtual disks (VHD, VHDX, QCOW2, VMDK) with the NTFS and -FAT filesystems inside them, UPX and other PE packers, and more. The full list +DMG (UDIF + HFS+/APFS), virtual disks (VHD, VHDX, QCOW2, VMDK), and the +filesystems of disks and partitions (NTFS, FAT, exFAT, ext2/3/4, HFS+, APFS, +SquashFS, cramfs, romfs, EROFS), UPX and other PE packers, and more. The full list is at [exav.org](https://exav.org/unpack/formats/). +A virtual disk, or a raw disk image, larger than the buffer limit is not held: +its tables are read as the walk reaches them, an unallocated range costs +nothing, and the files of its filesystems come out one at a time. + Every format is a **Cargo feature**, so you can build only what you need (`--no-default-features --features zip` for a ZIP-only extractor). Decryption (`decrypt`, on by default) and making a checksum mismatch an error diff --git a/crates/exav-unpack/examples/dump_members.rs b/crates/exav-unpack/examples/dump_members.rs index c83b0958..14638a92 100644 --- a/crates/exav-unpack/examples/dump_members.rs +++ b/crates/exav-unpack/examples/dump_members.rs @@ -11,7 +11,7 @@ //! //! Usage: `cargo run --example dump_members --all-features -- <file> <outdir>` -use exav_unpack::{detect, Budget, Limits, Member}; +use exav_unpack::{Budget, Limits, Member, detect}; use std::path::PathBuf; fn main() { diff --git a/crates/exav-unpack/examples/rar5_check.rs b/crates/exav-unpack/examples/rar5_check.rs index 746c6f62..ac23d67a 100644 --- a/crates/exav-unpack/examples/rar5_check.rs +++ b/crates/exav-unpack/examples/rar5_check.rs @@ -98,11 +98,11 @@ fn check_archive(data: &[u8]) -> (u32, u32, u32, u32, u32) { let mut extra_size = 0u64; { let mut qq = hdr + t1 + t2; - if hflags & 0x01 != 0 { - if let Some((v, n)) = vint(data, qq) { - extra_size = v; - qq += n; - } + if hflags & 0x01 != 0 + && let Some((v, n)) = vint(data, qq) + { + extra_size = v; + qq += n; } let _ = qq; } diff --git a/crates/exav-unpack/scripts/codepages.py b/crates/exav-unpack/scripts/codepages.py new file mode 100644 index 00000000..0336b2a0 --- /dev/null +++ b/crates/exav-unpack/scripts/codepages.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Writes src/formats/charset_tables.rs: the single-byte DOS and Mac code +pages that encoding_rs does not have, as tables of 256 UTF-16 code units, from +Python's own codecs. Undefined bytes map to U+FFFD. + +usage, from crates/exav-unpack: +python3 -I scripts/codepages.py > src/formats/charset_tables.rs +""" + +PAGES = [ + (437, "cp437"), + (720, "cp720"), + (737, "cp737"), + (775, "cp775"), + (850, "cp850"), + (852, "cp852"), + (855, "cp855"), + (857, "cp857"), + (858, "cp858"), + (860, "cp860"), + (861, "cp861"), + (862, "cp862"), + (863, "cp863"), + (864, "cp864"), + (865, "cp865"), + (869, "cp869"), + (10006, "mac_greek"), + (10029, "mac_latin2"), + (10079, "mac_iceland"), + (10081, "mac_turkish"), +] + + +def table(codec): + out = [] + for b in range(256): + try: + s = bytes([b]).decode(codec) + except UnicodeDecodeError: + s = "�" + cp = ord(s) if len(s) == 1 else 0xFFFD + if cp > 0xFFFF: + cp = 0xFFFD + out.append(cp) + return out + + +print("// Generated by scripts/codepages.py from Python's codecs. Do not edit.") +print() +print("/// (code page, its 256 characters).") +print("#[rustfmt::skip]") +print("pub(crate) static TABLES: &[(u16, [u16; 256])] = &[") +for cp, codec in PAGES: + t = table(codec) + print(f" // {codec}") + print(f" ({cp}, [") + for i in range(0, 256, 16): + print(" " + ", ".join(f"0x{x:04X}" for x in t[i:i + 16]) + ",") + print(" ]),") +print("];") diff --git a/crates/exav-unpack/src/bytes.rs b/crates/exav-unpack/src/bytes.rs index 2e3e16d6..0b9ba51a 100644 --- a/crates/exav-unpack/src/bytes.rs +++ b/crates/exav-unpack/src/bytes.rs @@ -18,9 +18,126 @@ pub(crate) fn to_usize(v: u64) -> usize { usize::try_from(v).unwrap_or(usize::MAX) } +/// A bounds-checked little-endian reader for formats made of short records: +/// a read past the end gives zeros and sets `short`, so a truncated record +/// never panics. +#[derive(Clone)] +pub struct Cur<'a> { + pub d: &'a [u8], + pub p: usize, + pub short: bool, +} + +impl<'a> Cur<'a> { + pub fn new(d: &'a [u8]) -> Self { + Cur { + d, + p: 0, + short: false, + } + } + + pub fn at(d: &'a [u8], p: usize) -> Self { + Cur { d, p, short: false } + } + + pub fn left(&self) -> usize { + self.d.len().saturating_sub(self.p) + } + + pub fn skip(&mut self, n: usize) { + if self.left() < n { + self.short = true; + self.p = self.d.len(); + } else { + self.p += n; + } + } + + /// `n` bytes, or what is left (and `short` set). + pub fn bytes(&mut self, n: usize) -> &'a [u8] { + let start = self.p.min(self.d.len()); + if self.left() < n { + self.short = true; + self.p = self.d.len(); + &self.d[start..] + } else { + self.p += n; + &self.d[start..start + n] + } + } + + pub fn u8(&mut self) -> u8 { + match self.d.get(self.p) { + Some(&b) => { + self.p += 1; + b + } + None => { + self.short = true; + 0 + } + } + } + + pub fn u16(&mut self) -> u16 { + match *self.bytes(2) { + [a, b] => u16::from_le_bytes([a, b]), + _ => 0, + } + } + + pub fn i16(&mut self) -> i16 { + self.u16() as i16 + } + + pub fn u32(&mut self) -> u32 { + match *self.bytes(4) { + [a, b, c, d] => u32::from_le_bytes([a, b, c, d]), + _ => 0, + } + } + + pub fn i32(&mut self) -> i32 { + self.u32() as i32 + } + + pub fn f64(&mut self) -> f64 { + let b = self.bytes(8); + let mut a = [0u8; 8]; + if b.len() == 8 { + a.copy_from_slice(b); + } + f64::from_le_bytes(a) + } +} + +/// The `u16` at `p`, zero past the end. +pub fn u16_at(d: &[u8], p: usize) -> u16 { + Cur::at(d, p).u16() +} + +/// The `u32` at `p`, zero past the end. +pub fn u32_at(d: &[u8], p: usize) -> u32 { + Cur::at(d, p).u32() +} + #[cfg(test)] mod tests { - use super::{at, to_usize}; + use super::{Cur, at, to_usize}; + + #[test] + fn short_reads_give_zero_and_flag() { + let mut c = Cur::new(&[1, 2, 3]); + assert_eq!(c.u16(), 0x0201); + assert!(!c.short); + assert_eq!(c.u32(), 0); + assert!(c.short); + assert_eq!(c.left(), 0); + let mut c = Cur::at(&[1], 5); + assert_eq!(c.bytes(3), &[] as &[u8]); + assert_eq!(c.f64(), 0.0); + } #[test] fn a_wide_offset_saturates_instead_of_truncating() { diff --git a/crates/exav-unpack/src/formats/aimodel.rs b/crates/exav-unpack/src/formats/aimodel.rs index a8e55a3e..8f66942e 100644 --- a/crates/exav-unpack/src/formats/aimodel.rs +++ b/crates/exav-unpack/src/formats/aimodel.rs @@ -138,8 +138,8 @@ fn extract_safetensors<R>( // it were the header hides whatever falls past the cut, so an over-cap header // is reported — and the part that fits is still handed over, since a prefix // is content even when it is not all of it. - if header.len() as u64 > cap { - if let Some(r) = visit( + if header.len() as u64 > cap + && let Some(r) = visit( Entry::unsupported( "safetensors-header".into(), header.len() as u64, @@ -148,9 +148,9 @@ fn extract_safetensors<R>( the part that fits was scanned", ), budget, - ) { - return Ok(Some(r)); - } + ) + { + return Ok(Some(r)); } let take = (header.len() as u64).min(cap) as usize; let member = header[..take].to_vec(); @@ -204,7 +204,7 @@ fn disassemble(data: &[u8], cap: u64) -> Vec<u8> { // Append `bytes` + `\n` to output, honoring the cap. Returns false if the // cap was reached (caller stops the walk). macro_rules! push_out { - ($bytes:expr) => {{ + ($bytes:expr_2021) => {{ let b: &[u8] = $bytes; if out.len() >= cap { return out; @@ -221,7 +221,7 @@ fn disassemble(data: &[u8], cap: u64) -> Vec<u8> { // Record a freshly-pushed string literal for later STACK_GLOBAL use and add // it to output. macro_rules! record_str { - ($bytes:expr) => {{ + ($bytes:expr_2021) => {{ let v: Vec<u8> = $bytes; push_out!(&v); prev2 = prev1.take(); @@ -232,7 +232,7 @@ fn disassemble(data: &[u8], cap: u64) -> Vec<u8> { // Read a little-endian unsigned integer of `n` bytes starting at `i+1`; // returns None on short read. macro_rules! read_le { - ($n:expr) => {{ + ($n:expr_2021) => {{ let n: usize = $n; match crate::bytes::at(data, i + 1, n) { Some(s) => { @@ -561,7 +561,7 @@ mod tests { let entries = extract(Format::AiModel, &pkl, &mut budget).unwrap(); assert_eq!(entries.len(), 1); let out = &entries[0].data; - assert!(out.windows(2).any(|w| w == b"os"), "os not surfaced"); + assert!(out.array_windows().any(|w| w == b"os"), "os not surfaced"); assert!( out.windows(6).any(|w| w == b"system"), "system not surfaced" @@ -609,7 +609,7 @@ mod tests { let entries = extract(Format::AiModel, &pkl, &mut budget).unwrap(); assert_eq!(entries.len(), 1); let out = &entries[0].data; - assert!(out.windows(2).any(|w| w == b"os")); + assert!(out.array_windows().any(|w| w == b"os")); assert!(out.windows(6).any(|w| w == b"system")); // The truncated string literal must not appear. assert!(!out.windows(3).any(|w| w == b"abc")); diff --git a/crates/exav-unpack/src/formats/apfs.rs b/crates/exav-unpack/src/formats/apfs.rs new file mode 100644 index 00000000..4fca4536 --- /dev/null +++ b/crates/exav-unpack/src/formats/apfs.rs @@ -0,0 +1,1690 @@ +//! APFS: the files of a container, read where they lie. +//! +//! The container is a `Read + Seek`, read where the walk goes: its +//! superblock, the object maps, the nodes of each volume's file-system tree, +//! and each file's extents. Only a file is ever held, bounded by the buffer +//! limit. +//! +//! A scanner wants every file's bytes, not a navigable tree, so the file-system +//! tree is read once, in key order, for its directory records, and the paths +//! are rebuilt from those. Each file is then looked up by its id for the +//! three things it has: the inode (size, flags), the attributes +//! (`com.apple.decmpfs`, read by `formats/decmpfs`) and the extents. +//! +//! ```text +//! block 0, checkpoint area container superblock: block size, the volumes' +//! ids, the container's object map +//! object map B-tree: virtual object id -> block +//! volume superblock the volume's object map and tree root ids +//! file-system tree B-tree, ordered by (object id, record type): +//! inode (3) mode, flags, size +//! attribute (4) extended attribute, or the stream holding it +//! file extent (8) logical offset -> blocks; none: a hole +//! directory record (9) (parent id, name) -> the object's id and kind +//! ``` +//! +//! All fields are little-endian, and every object begins with the Fletcher-64 +//! checksum of its block. Like every format's, it is not checked unless the +//! budget asks for that (`checksums`), except to choose between the container's +//! superblocks. Implemented from Apple's published reference. +//! +//! Every volume of the container is walked, under its name when there are +//! several. A volume that is encrypted is reported, not read. A symbolic link +//! is not read. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] + +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; + +use super::decmpfs; +use super::vol::{ + Attrs as VolAttrs, Emit, Found, Got, Next, Piece, Pieces, Plan, Vol, hand, le16, le32, le64, + read_file, +}; +use crate::{Budget, Entry, LimitHit}; + +/// "NXSB" and "APSB", little-endian, 32 bytes into their blocks. +const NX_MAGIC: u32 = 0x4253_584E; +const APSB_MAGIC: u32 = 0x4253_5041; + +/// Record types of the file-system tree. +const J_INODE: u8 = 3; +const J_XATTR: u8 = 4; +const J_FILE_EXTENT: u8 = 8; +const J_DREC: u8 = 9; + +/// The id of the root directory. +const ROOT: u64 = 2; + +/// Directory record kinds. +const DT_DIR: u16 = 4; +const DT_LNK: u16 = 10; + +/// `APFS_FS_UNENCRYPTED`, a volume flag. +const FS_UNENCRYPTED: u64 = 1; + +/// An inode flag: the size of the file before compression is recorded. +const HAS_UNCOMPRESSED_SIZE: u64 = 0x4_0000; + +/// A tree is no deeper than this: a node's level counts down to a leaf's 0. +const MAX_LEVEL: u16 = 16; + +/// The most records one walk of a tree visits. +const MAX_RECORDS: u64 = 1 << 26; + +/// The most mappings of an object map that are kept. +const MAX_OMAP: usize = 1 << 20; + +/// The most extents of one file that are followed. +const MAX_EXTENTS: usize = 1 << 20; + +/// The most blocks of the checkpoint area that are searched for the newest +/// superblock. +const MAX_CHECKPOINT_BLOCKS: u64 = 1024; + +/// The most folders a path passes through. +const MAX_PATH_DEPTH: usize = 256; + +/// The most blocks kept after they are read. +const CACHE: usize = 256; + +/// The most an attribute held in a stream is, bytes. +const ATTR_MAX: u64 = 1 << 16; + +/// The Fletcher-64 checksum of an object's block, from its ninth byte on. +fn fletcher64(data: &[u8]) -> u64 { + const M: u64 = 0xFFFF_FFFF; + let (mut s1, mut s2) = (0u64, 0u64); + for c in data.as_chunks::<4>().0 { + s1 = s1.saturating_add(u64::from(u32::from_le_bytes(*c))) % M; + s2 = s2.saturating_add(s1) % M; + } + let c1 = M.saturating_sub(s1.saturating_add(s2) % M); + let c2 = M.saturating_sub(s1.saturating_add(c1) % M); + c2 << 32 | c1 +} + +/// A structure that is not what it must be, or cannot be read. +struct Bad; + +type Res<T> = Result<T, Bad>; + +/// Whether a block carries the checksum of the rest of it. +fn sum_holds(b: &[u8]) -> bool { + b.get(8..) + .is_some_and(|rest| le64(b, 0) == fletcher64(rest)) +} + +/// The container's blocks. +struct Fs<'a, 'd> { + v: &'a mut Vol<'d>, + block: u64, + cache: HashMap<u64, Vec<u8>>, + /// Whether a block whose checksum does not hold is refused. It is not, as + /// with every format: a checksum that is wrong must not keep a file from + /// being scanned. + strict: bool, +} + +impl Fs<'_, '_> { + /// Block `n`, if it is in the volume, whatever its checksum. + fn raw(&mut self, n: u64) -> Option<Vec<u8>> { + self.v.at( + n.checked_mul(self.block)?, + crate::bytes::to_usize(self.block), + ) + } + + /// Block `n`, if it is in the volume and, when checksums are checked, its + /// checksum holds. + fn read(&mut self, n: u64) -> Option<Vec<u8>> { + let b = self.raw(n)?; + (!self.strict || sum_holds(&b)).then_some(b) + } + + /// [`Self::read`], kept for the next time: the top of a tree is read for + /// every lookup. + fn block(&mut self, n: u64) -> Option<Vec<u8>> { + if let Some(b) = self.cache.get(&n) { + return Some(b.clone()); + } + let b = self.read(n)?; + if self.cache.len() >= CACHE { + self.cache.clear(); + } + self.cache.insert(n, b.clone()); + Some(b) + } +} + +const ROOT_NODE: u16 = 1; +const LEAF_NODE: u16 = 2; +const FIXED_NODE: u16 = 4; + +/// A node of a B-tree. +struct Node { + b: Vec<u8>, + flags: u16, + level: u16, + count: usize, + /// Where the table of contents, the keys and the values begin and end. + toc: usize, + keys: usize, + vals: usize, +} + +impl Node { + fn parse(b: Vec<u8>) -> Option<Node> { + let flags = le16(&b, 32); + let count = crate::bytes::to_usize(u64::from(le32(&b, 36))); + let toc = 56usize.checked_add(usize::from(le16(&b, 40)))?; + let keys = toc.checked_add(usize::from(le16(&b, 42)))?; + // The root ends in the tree's description, which the values stop before. + let vals = match flags & ROOT_NODE != 0 { + true => b.len().checked_sub(40)?, + false => b.len(), + }; + let entry = if flags & FIXED_NODE != 0 { 4 } else { 8 }; + if toc.checked_add(count.checked_mul(entry)?)? > keys || keys > vals { + return None; + } + Some(Node { + level: le16(&b, 34), + flags, + count, + toc, + keys, + vals, + b, + }) + } + + fn leaf(&self) -> bool { + self.flags & LEAF_NODE != 0 + } + + /// Record `i`: its key and its value. `fixed` is the sizes of the keys and + /// values of a tree that has only one size of each. + fn rec(&self, i: usize, fixed: Option<(usize, usize)>) -> Option<(&[u8], &[u8])> { + if i >= self.count { + return None; + } + let at = |o: usize| Some(usize::from(le16(&self.b, o))); + let (key, val) = if self.flags & FIXED_NODE != 0 { + let (k, v) = fixed?; + let e = self.toc.checked_add(i.checked_mul(4)?)?; + ((at(e)?, k), (at(e.checked_add(2)?)?, v)) + } else { + let e = self.toc.checked_add(i.checked_mul(8)?)?; + ( + (at(e)?, at(e.checked_add(2)?)?), + (at(e.checked_add(4)?)?, at(e.checked_add(6)?)?), + ) + }; + // What a node above the leaves holds is the id of a child. + let val_len = if self.leaf() { val.1 } else { 8 }; + let val_at = self.vals.checked_sub(val.0).filter(|&s| s >= self.keys)?; + Some(( + crate::bytes::at(&self.b, self.keys.checked_add(key.0)?, key.1)?, + crate::bytes::at(&self.b, val_at, val_len)?, + )) + } +} + +/// What an object map holds: the block of each object id, `None` where the +/// newest mapping is a deletion. +type Omap = HashMap<u64, Option<u64>>; + +/// A B-tree, from its root's block. +struct Tree<'o> { + root: u64, + fixed: Option<(usize, usize)>, + /// How the ids of its children become blocks: through an object map, or + /// not at all (they are blocks). + omap: Option<&'o Omap>, +} + +/// A position in a tree, from which its records follow in order. +struct Cursor { + /// The nodes from the root to a leaf, and in each the child taken (the + /// record of a leaf not yet given). + stack: Vec<(Node, usize)>, + seen: u64, +} + +impl Tree<'_> { + fn child(&self, fs: &mut Fs, node: &Node, i: usize) -> Res<Node> { + let (_, id) = node.rec(i, self.fixed).ok_or(Bad)?; + let id = le64(id, 0); + let block = match self.omap { + Some(map) => map.get(&id).copied().flatten().ok_or(Bad)?, + None => id, + }; + let child = Node::parse(fs.block(block).ok_or(Bad)?).ok_or(Bad)?; + let leaf_ok = child.leaf() == (child.level == 0); + match child.level.checked_add(1) == Some(node.level) && leaf_ok { + true => Ok(child), + false => Err(Bad), + } + } + + /// The first record whose key `before` does not put before the one wanted, + /// and those after it. Keys of the nodes above are the least of what is + /// below them, so the descent takes the last child whose key is before. + fn seek(&self, fs: &mut Fs, before: &dyn Fn(&[u8]) -> bool) -> Res<Cursor> { + let mut node = Node::parse(fs.block(self.root).ok_or(Bad)?).ok_or(Bad)?; + if node.level > MAX_LEVEL || node.leaf() != (node.level == 0) { + return Err(Bad); + } + let mut stack = Vec::new(); + loop { + let keys = (0..node.count).map(|i| node.rec(i, self.fixed).map(|(k, _)| k)); + if node.leaf() { + let mut at = node.count; + for (i, key) in keys.enumerate() { + if !before(key.ok_or(Bad)?) { + at = i; + break; + } + } + stack.push((node, at)); + return Ok(Cursor { stack, seen: 0 }); + } + let mut pick = 0; + for (i, key) in keys.enumerate() { + if !before(key.ok_or(Bad)?) { + break; + } + pick = i; + } + let child = self.child(fs, &node, pick)?; + stack.push((node, pick)); + node = child; + } + } +} + +impl Cursor { + /// The next record, a copy of its key and of its value. + fn next(&mut self, fs: &mut Fs, tree: &Tree) -> Res<Option<(Vec<u8>, Vec<u8>)>> { + loop { + let Some((node, at)) = self.stack.last_mut() else { + return Ok(None); + }; + if !node.leaf() { + return Err(Bad); + } + if *at < node.count { + let i = *at; + *at = at.saturating_add(1); + self.seen = self.seen.saturating_add(1); + if self.seen > MAX_RECORDS { + return Err(Bad); + } + let (k, v) = node.rec(i, tree.fixed).ok_or(Bad)?; + return Ok(Some((k.to_vec(), v.to_vec()))); + } + // This leaf is done: on to the next child of the nearest node + // above that has one, and down its first children to a leaf. + self.stack.pop(); + let mut down = loop { + let Some((parent, taken)) = self.stack.last_mut() else { + return Ok(None); + }; + *taken = taken.saturating_add(1); + if *taken < parent.count { + break tree.child(fs, parent, *taken)?; + } + self.stack.pop(); + }; + while !down.leaf() { + let first = tree.child(fs, &down, 0)?; + self.stack.push((down, 0)); + down = first; + } + self.stack.push((down, 0)); + } + } +} + +/// The object id and the record type a key of the file-system tree starts with. +fn key_of(key: &[u8]) -> (u64, u8) { + let packed = le64(key, 0); + (packed & 0x0FFF_FFFF_FFFF_FFFF, (packed >> 60) as u8) +} + +/// The object map at physical block `block`: the block of each object id as of +/// transaction `xid`. +fn load_omap(fs: &mut Fs, block: u64, xid: u64) -> Option<Omap> { + let object = fs.block(block)?; + let tree = Tree { + root: le64(&object, 48), + fixed: Some((16, 16)), + omap: None, + }; + let mut cursor = tree.seek(fs, &|_| false).ok()?; + // (the newest transaction seen, where it maps the object) + let mut newest: HashMap<u64, (u64, Option<u64>)> = HashMap::new(); + while let Some((key, val)) = cursor.next(fs, &tree).ok()? { + let (id, at) = (le64(&key, 0), le64(&key, 8)); + if at > xid || key.len() < 16 || val.len() < 16 { + continue; + } + if newest.len() >= MAX_OMAP && !newest.contains_key(&id) { + return None; + } + // Bit 0 of the flags marks a deleted mapping. + let to = (le32(&val, 0) & 1 == 0).then(|| le64(&val, 8)); + match newest.get(&id) { + Some(&(seen, _)) if seen > at => {} + _ => { + newest.insert(id, (at, to)); + } + } + } + Some(newest.into_iter().map(|(id, (_, to))| (id, to)).collect()) +} + +/// An inode, as far as a walk reads it. +struct Inode { + /// The id its extents are keyed by. + stream: u64, + flags: u32, + size: u64, +} + +impl Inode { + fn parse(val: &[u8]) -> Option<Inode> { + if val.len() < 92 { + return None; + } + let size = match dstream_size(val.get(92..)?) { + Some(size) => size, + None if le64(val, 48) & HAS_UNCOMPRESSED_SIZE != 0 => le64(val, 84), + None => 0, + }; + Some(Inode { + stream: le64(val, 8), + flags: le32(val, 68), + size, + }) + } +} + +/// The size of the data stream in an inode's extended fields: a count and a +/// length, an (id, flags, length) triple for each, then their values, each +/// padded to eight bytes. The stream is field 8 and starts with its size. +fn dstream_size(x: &[u8]) -> Option<u64> { + let count = usize::from(le16(x, 0)); + let mut at = count.checked_mul(4)?.checked_add(4)?; + for i in 0..count { + let e = i.checked_mul(4)?.checked_add(4)?; + let len = usize::from(le16(x, e.checked_add(2)?)); + if *x.get(e)? == 8 && len >= 8 { + return Some(le64(crate::bytes::at(x, at, 8)?, 0)); + } + at = at.checked_add(len.checked_add(7)? & !7)?; + } + None +} + +/// The name in a directory record's key: a length and a hash, or a length +/// alone, then the name and its NUL. +fn drec_name(key: &[u8]) -> Option<String> { + let hashed = usize::try_from(le32(key, 8) & 0x3FF).ok()?; + let (len, at) = match key.len() == hashed.checked_add(12)? { + true => (hashed, 12), + false => (usize::from(le16(key, 8)), 10), + }; + let name = + crate::bytes::at(key, at, len).filter(|n| at.saturating_add(n.len()) == key.len())?; + let name = name.strip_suffix(&[0]).unwrap_or(name); + Some(String::from_utf8_lossy(name).into_owned()) +} + +/// A file named by a directory record. +struct Named { + id: u64, + parent: u64, + name: String, +} + +/// A file's extents: (offset in the file, length, first block); a first block +/// of 0 is a hole. +struct Extent { + at: u64, + len: u64, + block: u64, +} + +/// What a file's attributes give a walk. +#[derive(Default)] +struct Attrs { + decmpfs: Option<Vec<u8>>, + /// The stream that is the resource fork: its id and size. + fork: Option<(u64, u64)>, +} + +/// A volume, mounted. +struct Volume<'o> { + tree: Tree<'o>, +} + +impl Volume<'_> { + fn inode(&self, fs: &mut Fs, id: u64) -> Res<Inode> { + let mut c = self.records(fs, id, J_INODE)?; + match c.next(fs, &self.tree)? { + Some((k, v)) if key_of(&k) == (id, J_INODE) => Inode::parse(&v).ok_or(Bad), + _ => Err(Bad), + } + } + + /// A cursor at the first record of object `id` of type `ty` (or the first + /// after). + fn records(&self, fs: &mut Fs, id: u64, ty: u8) -> Res<Cursor> { + self.tree.seek(fs, &|k| key_of(k) < (id, ty)) + } + + fn extents(&self, fs: &mut Fs, id: u64) -> Res<Vec<Extent>> { + let mut c = self.records(fs, id, J_FILE_EXTENT)?; + let mut out = Vec::new(); + while let Some((k, v)) = c.next(fs, &self.tree)? { + if key_of(&k) != (id, J_FILE_EXTENT) { + break; + } + if out.len() >= MAX_EXTENTS { + return Err(Bad); + } + // The key: the id and type, then the offset in the file. The + // value: the length (and flags above 56 bits), the first block, + // the crypto id. + out.push(Extent { + at: le64(&k, 8), + len: le64(&v, 0) & 0x00FF_FFFF_FFFF_FFFF, + block: le64(&v, 8), + }); + } + Ok(out) + } + + fn attrs(&self, fs: &mut Fs, id: u64) -> Res<Attrs> { + let mut c = self.records(fs, id, J_XATTR)?; + let mut out = Attrs::default(); + while let Some((k, v)) = c.next(fs, &self.tree)? { + if key_of(&k) != (id, J_XATTR) { + break; + } + // The key: the id and type, a length, the name and its NUL. + let len = usize::from(le16(&k, 8)); + let name = crate::bytes::at(&k, 10, len).unwrap_or_default(); + let name = name.strip_suffix(&[0]).unwrap_or(name); + // The value: flags, a length, then the bytes; or, flagged as a + // stream, the stream's id and its size. + let stream = le16(&v, 0) & 1 != 0; + let held = || { + let len = usize::from(le16(&v, 2)); + crate::bytes::at(&v, 4, len).map(<[u8]>::to_vec) + }; + if name == decmpfs::ATTR.as_bytes() { + out.decmpfs = match stream { + true => self.stream(fs, le64(&v, 4), le64(&v, 12), ATTR_MAX).ok(), + false => held(), + }; + } else if name == decmpfs::RESOURCE_FORK_ATTR.as_bytes() && stream { + out.fork = Some((le64(&v, 4), le64(&v, 12))); + } + } + Ok(out) + } + + /// The `size` bytes of the data stream `id`, if they are at most `bound`. + fn stream(&self, fs: &mut Fs, id: u64, size: u64, bound: u64) -> Res<Vec<u8>> { + if size > bound { + return Err(Bad); + } + let mut out = Vec::new(); + let mut extents = self.extents(fs, id)?; + match read_extents(fs, &mut extents, size, &mut out) { + true => Ok(out), + false => Err(Bad), + } + } + + /// The bytes of file `id`: held when they are at most `cap`, else read as + /// they go. A compressed file is unpacked, and is not read as it goes. + fn file<'x>(&self, fs: &'x mut Fs<'_, '_>, id: u64, cap: u64) -> Got<'x> { + let Ok(inode) = self.inode(fs, id) else { + return Got::Why("APFS file whose inode could not be read"); + }; + if inode.flags & decmpfs::COMPRESSED_FLAG != 0 { + let attrs = self.attrs(fs, id).unwrap_or_default(); + if let Some(attr) = attrs.decmpfs { + let fork = decmpfs::header(&attr) + .filter(|h| decmpfs::in_fork(h.kind)) + .and(attrs.fork) + // A fork holds the bytes in blocks, a little over their number. + .and_then(|(stream, size)| { + let bound = cap.saturating_add(cap / 32).saturating_add(4096); + self.stream(fs, stream, size, bound).ok() + }); + return decmpfs::unpack(&attr, fork.as_deref(), cap); + } + if inode.size == 0 { + return Got::Why("compressed file (decmpfs) without its attribute"); + } + } + let Ok(mut extents) = self.extents(fs, inode.stream) else { + return Got::Why("APFS file whose extents could not be read"); + }; + extents.sort_by_key(|e| e.at); + let plan = Spans { + extents: extents.into_iter(), + pending: None, + block: fs.block, + size: inode.size, + at: 0, + }; + read_file(&mut *fs.v, Pieces::new(plan, inode.size), inode.size, cap) + } +} + +/// The stretches of a file, from its extents in order of their place in it: a +/// hole between them or after the last reads as zeros. +struct Spans { + extents: std::vec::IntoIter<Extent>, + /// An extent held back while the hole before it is given. + pending: Option<Extent>, + block: u64, + size: u64, + /// How many bytes of the file the stretches given so far cover. + at: u64, +} + +impl Plan for Spans { + fn next(&mut self, _: &mut Vol) -> Next { + loop { + if self.at >= self.size { + return Next::End; + } + let Some(e) = self.pending.take().or_else(|| self.extents.next()) else { + let rest = self.size.saturating_sub(self.at); + self.at = self.size; + return Next::Piece(Piece::Zeros(rest)); + }; + if e.at > self.at { + let gap = + e.at.saturating_sub(self.at) + .min(self.size.saturating_sub(self.at)); + self.at = self.at.saturating_add(gap); + self.pending = Some(e); + return Next::Piece(Piece::Zeros(gap)); + } + // An extent that overlaps what was given gives only the rest. + let skip = self.at.saturating_sub(e.at); + if skip >= e.len { + continue; + } + let take = e + .len + .saturating_sub(skip) + .min(self.size.saturating_sub(self.at)); + self.at = self.at.saturating_add(take); + return match e.block { + 0 => Next::Piece(Piece::Zeros(take)), + block => match block + .checked_mul(self.block) + .and_then(|b| b.checked_add(skip)) + { + Some(at) => Next::Piece(Piece::Disk(at, take)), + None => Next::Broken, + }, + }; + } + } +} + +/// Append the first `size` bytes of a file to `out`: the extents in order of +/// their place in the file, a hole between them or after the last reading as +/// zeros. False when a block cannot be read; what was read stays. +fn read_extents(fs: &mut Fs, extents: &mut [Extent], size: u64, out: &mut Vec<u8>) -> bool { + extents.sort_by_key(|e| e.at); + let zeros = |out: &mut Vec<u8>, n: u64| { + out.resize(out.len().saturating_add(crate::bytes::to_usize(n)), 0); + }; + let mut pos = 0u64; + for e in extents.iter() { + if pos >= size { + break; + } + if e.at > pos { + let gap = e.at.saturating_sub(pos).min(size.saturating_sub(pos)); + zeros(out, gap); + pos = pos.saturating_add(gap); + if pos >= size { + break; + } + } + // An extent that overlaps what was read gives only the rest. + let skip = pos.saturating_sub(e.at); + if skip >= e.len { + continue; + } + let take = e.len.saturating_sub(skip).min(size.saturating_sub(pos)); + match e.block { + 0 => zeros(out, take), + block => { + let Some(at) = block + .checked_mul(fs.block) + .and_then(|b| b.checked_add(skip)) + else { + return false; + }; + if !fs.v.append(at, crate::bytes::to_usize(take), out) { + return false; + } + } + } + pos = pos.saturating_add(take); + } + zeros(out, size.saturating_sub(pos)); + true +} + +/// The folders and files the directory records of a volume name. +#[derive(Default)] +struct Names { + folders: HashMap<u64, (u64, String)>, + files: Vec<Named>, +} + +impl Names { + /// The path of `name` in folder `parent`, from the root. A folder whose + /// chain does not reach the root (a record missing, or a loop) stands + /// where the chain broke, by its id. + fn path(&self, parent: u64, name: &str) -> String { + let mut parts = vec![name]; + let mut at = parent; + for _ in 0..MAX_PATH_DEPTH { + if at == ROOT { + break; + } + let Some((up, folder)) = self.folders.get(&at) else { + break; + }; + parts.push(folder); + at = *up; + } + let lost = (at != ROOT).then(|| format!("<apfs-folder-{at}>")); + parts.extend(lost.as_deref()); + parts.reverse(); + format!("/{}", parts.join("/")) + } +} + +/// The container: its newest superblock and its object map. +struct Container { + sb: Vec<u8>, + omap: Omap, +} + +fn open(fs: &mut Fs) -> Option<Container> { + // The block size is in the first block, whose size it gives. + fs.block = 4096; + let first = fs.raw(0)?; + let size = le32(&first, 36); + if le32(&first, 32) != NX_MAGIC || !(4096..=65536).contains(&size) || !size.is_power_of_two() { + return None; + } + fs.block = u64::from(size); + let mut sb = fs.raw(0)?; + // The newest superblock of the checkpoint area, if it has one the first + // block does not: that block is only the one written at format time. One + // whose checksum holds is preferred to a newer one whose does not, a write + // that was cut short, as the system that mounts the container does. + let rank = |b: &[u8]| (sum_holds(b), le64(b, 16)); + let (base, blocks) = (le64(&sb, 112), u64::from(le32(&sb, 104))); + if base >> 63 == 0 { + for n in 0..blocks.min(MAX_CHECKPOINT_BLOCKS) { + let Some(b) = base.checked_add(n).and_then(|n| fs.raw(n)) else { + continue; + }; + let is_sb = le32(&b, 24) & 0xFFFF == 1 && le32(&b, 32) == NX_MAGIC; + if is_sb && le32(&b, 36) == size && rank(&b) > rank(&sb) { + sb = b; + } + } + } + if fs.strict && !sum_holds(&sb) { + return None; + } + // Nothing is read from past the container the superblock describes. + fs.v.len = fs.v.len.min(le64(&sb, 40).saturating_mul(fs.block)); + let omap = load_omap(fs, le64(&sb, 160), le64(&sb, 16))?; + Some(Container { sb, omap }) +} + +/// Whether `head`, the start of a volume, holds the superblock of an APFS +/// container: its magic, and a block size that is a power of two from 4 KiB to +/// 64 KiB. +pub(crate) fn is_apfs(head: &[u8]) -> bool { + le32(head, 32) == NX_MAGIC + && (4096..=65536).contains(&le32(head, 36)) + && le32(head, 36).is_power_of_two() +} + +/// Walk the APFS container `disk`, which starts at its first block, handing +/// each file to `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + let len = disk + .seek(SeekFrom::End(0)) + .map_err(|e| LimitHit::corrupt(format!("APFS container: {e}")))?; + let max = crate::bytes::to_usize(budget.limits().max_buffer_bytes); + let mut v = Vol { + disk: &mut disk, + len, + max, + }; + let mut fs = Fs { + v: &mut v, + block: 4096, + cache: HashMap::new(), + strict: budget.should_verify_checksums(), + }; + let Some(container) = open(&mut fs) else { + return Err(LimitHit::corrupt( + "apfs open: the container superblock or its object map is unreadable".into(), + )); + }; + + // The volumes, each by the block its id maps to. + let listed = (0..le32(&container.sb, 180).min(100) as usize) + .map(|i| le64(&container.sb, 184usize.saturating_add(i.saturating_mul(8)))) + .filter(|&id| id != 0) + .collect::<Vec<_>>(); + let multiple = listed.len() > 1; + for (n, id) in listed.into_iter().enumerate() { + let sb = container + .omap + .get(&id) + .copied() + .flatten() + .and_then(|block| fs.block(block)) + .filter(|sb| le32(sb, 32) == APSB_MAGIC); + let Some(sb) = sb else { + budget.count_entry()?; + let note = Entry::unsupported( + format!("<apfs-volume-{n}>"), + 0, + false, + "APFS volume that could not be read", + ); + if let Some(r) = visit(Found::Held(note), budget) { + return Ok(Some(r)); + } + continue; + }; + if let Some(r) = walk_one(&mut fs, &sb, multiple, n, budget, &mut *visit)? { + return Ok(Some(r)); + } + } + Ok(None) +} + +/// Walk the volume whose superblock is `sb`. +fn walk_one<R>( + fs: &mut Fs, + sb: &[u8], + multiple: bool, + n: usize, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + // The name is 256 bytes, ended by a NUL, 704 bytes in. + let name = sb.get(704..960).unwrap_or_default(); + let name = name.split(|&b| b == 0).next().unwrap_or_default(); + let name = String::from_utf8_lossy(name).into_owned(); + let name = if name.is_empty() { + format!("volume-{n}") + } else { + name + }; + if le64(sb, 264) & FS_UNENCRYPTED == 0 { + budget.count_entry()?; + let note = Entry::unsupported( + format!("<apfs-volume:{name}>"), + 0, + true, + "encrypted APFS volume; its files were not read", + ); + return Ok(visit(Found::Held(note), budget)); + } + let prefix = if multiple { + format!("/{name}") + } else { + String::new() + }; + let unreadable = |budget: &mut Budget, visit: Emit<R>| { + budget.count_entry()?; + let note = Entry::unsupported( + format!("<apfs-volume:{name}>"), + 0, + false, + "APFS volume whose file-system tree could not be read", + ); + Ok::<_, LimitHit>(visit(Found::Held(note), budget)) + }; + + // The volume's object map, as of its own transaction, gives the tree. + let Some(omap) = load_omap(fs, le64(sb, 128), le64(sb, 16)) else { + return unreadable(budget, visit); + }; + let Some(root) = omap.get(&le64(sb, 136)).copied().flatten() else { + return unreadable(budget, visit); + }; + let volume = Volume { + tree: Tree { + root, + fixed: None, + omap: Some(&omap), + }, + }; + + // The directory records: every folder and file, up to the member limit. + let limit = crate::bytes::to_usize(budget.limits().max_members); + let mut names = Names::default(); + let mut capped = false; + let mut intact = true; + match volume.records(fs, 0, 0) { + Err(Bad) => intact = false, + Ok(mut c) => loop { + match c.next(fs, &volume.tree) { + Err(Bad) => { + intact = false; + break; + } + Ok(None) => break, + Ok(Some((k, v))) => { + let (parent, ty) = key_of(&k); + if ty != J_DREC { + continue; + } + if names.files.len().saturating_add(names.folders.len()) >= limit { + capped = true; + break; + } + let Some(name) = drec_name(&k) else { continue }; + let (id, kind) = (le64(&v, 0), le16(&v, 16) & 0xF); + match kind { + DT_DIR => { + names.folders.insert(id, (parent, name)); + } + DT_LNK => {} + _ => names.files.push(Named { id, parent, name }), + } + } + } + }, + } + + for f in &names.files { + let path = format!("{prefix}{}", names.path(f.parent, &f.name)); + budget.count_entry()?; + let cap = budget.reserve()?; + let got = volume.file(fs, f.id, cap); + if let Some(r) = hand(budget, &mut *visit, path, VolAttrs::default(), got) { + return Ok(Some(r)); + } + } + if capped { + return Err(LimitHit::of_kind( + crate::LimitKind::MaxFiles, + format!("file count > {limit}"), + )); + } + if !intact { + return Err(LimitHit::corrupt( + "apfs walk: file-system tree damaged; the files before the damage were scanned".into(), + )); + } + Ok(None) +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::super::vol::collected; + use super::*; + use crate::Limits; + use std::io::{Cursor, Write}; + + const BS: usize = 4096; + + fn put16(b: &mut [u8], at: usize, v: u16) { + b[at..at + 2].copy_from_slice(&v.to_le_bytes()); + } + + fn put32(b: &mut [u8], at: usize, v: u32) { + b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + } + + fn put64(b: &mut [u8], at: usize, v: u64) { + b[at..at + 8].copy_from_slice(&v.to_le_bytes()); + } + + /// The block with its checksum. + fn seal(mut b: Vec<u8>) -> Vec<u8> { + let sum = fletcher64(&b[8..]); + b[..8].copy_from_slice(&sum.to_le_bytes()); + b + } + + /// A node of a B-tree. Keys and values are in the order given; a tree with + /// `fixed` sizes has only keys and values of those. + fn bnode( + flags: u16, + level: u16, + fixed: Option<(usize, usize)>, + recs: &[(Vec<u8>, Vec<u8>)], + ) -> Vec<u8> { + let mut b = vec![0u8; BS]; + let fixed_flag = if fixed.is_some() { FIXED_NODE } else { 0 }; + put16(&mut b, 32, flags | fixed_flag); + put16(&mut b, 34, level); + put32(&mut b, 36, recs.len() as u32); + let entry = if fixed.is_some() { 4 } else { 8 }; + put16(&mut b, 42, (recs.len() * entry) as u16); + let keys = 56 + recs.len() * entry; + let vals = if flags & ROOT_NODE != 0 { BS - 40 } else { BS }; + let (mut k_at, mut v_back) = (0, 0); + for (i, (k, v)) in recs.iter().enumerate() { + b[keys + k_at..keys + k_at + k.len()].copy_from_slice(k); + v_back += v.len(); + b[vals - v_back..vals - v_back + v.len()].copy_from_slice(v); + let e = 56 + i * entry; + put16(&mut b, e, k_at as u16); + if fixed.is_some() { + put16(&mut b, e + 2, v_back as u16); + } else { + put16(&mut b, e + 2, k.len() as u16); + put16(&mut b, e + 4, v_back as u16); + put16(&mut b, e + 6, v.len() as u16); + } + k_at += k.len(); + } + seal(b) + } + + /// An object-map record: the block `oid` is at, from transaction `xid`. + fn mapping(oid: u64, xid: u64, block: u64, flags: u32) -> (Vec<u8>, Vec<u8>) { + let mut k = oid.to_le_bytes().to_vec(); + k.extend_from_slice(&xid.to_le_bytes()); + let mut v = flags.to_le_bytes().to_vec(); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&block.to_le_bytes()); + (k, v) + } + + /// The blocks of a container being laid out; block 0 is left for its + /// superblock. + struct Cont { + blocks: Vec<Vec<u8>>, + } + + impl Cont { + fn new() -> Cont { + Cont { + blocks: vec![vec![0; BS]], + } + } + + fn push(&mut self, b: Vec<u8>) -> u64 { + self.blocks.push(b); + self.blocks.len() as u64 - 1 + } + + /// File bytes in blocks of their own: the first block. + fn data(&mut self, bytes: &[u8]) -> u64 { + let first = self.blocks.len() as u64; + for c in bytes.chunks(BS) { + let mut b = c.to_vec(); + b.resize(BS, 0); + self.blocks.push(b); + } + first + } + + /// An object map holding `recs`: the block of its object. + fn omap(&mut self, recs: &[(Vec<u8>, Vec<u8>)]) -> u64 { + let root = self.push(bnode(ROOT_NODE | LEAF_NODE, 0, Some((16, 16)), recs)); + let mut o = vec![0u8; BS]; + put64(&mut o, 48, root); + self.push(seal(o)) + } + + /// The file-system tree of `recs` (in key order) in two leaves under + /// a root: the root's id, and the object map that gives its nodes. + fn fs_tree(&mut self, recs: &[(Vec<u8>, Vec<u8>)], xid: u64) -> (u64, u64) { + let (a, b) = recs.split_at(recs.len() / 2); + let leaf_a = self.push(bnode(LEAF_NODE, 0, None, a)); + let leaf_b = self.push(bnode(LEAF_NODE, 0, None, b)); + let index = [ + (a[0].0.clone(), 1030u64.to_le_bytes().to_vec()), + (b[0].0.clone(), 1031u64.to_le_bytes().to_vec()), + ]; + let root = self.push(bnode(ROOT_NODE, 1, None, &index)); + let omap = self.omap(&[ + // An older mapping of the first leaf, to a block that is no tree. + mapping(1030, xid - 1, 0, 0), + mapping(1030, xid, leaf_a, 0), + mapping(1031, xid, leaf_b, 0), + mapping(1032, xid, root, 0), + ]); + (1032, omap) + } + + /// A volume over `recs`: the block of its superblock. + fn volume(&mut self, name: &str, flags: u64, recs: &[(Vec<u8>, Vec<u8>)]) -> u64 { + let (root_oid, omap) = self.fs_tree(recs, 10); + let mut sb = vec![0u8; BS]; + put64(&mut sb, 16, 10); + put32(&mut sb, 24, 13); + put32(&mut sb, 32, APSB_MAGIC); + put64(&mut sb, 128, omap); + put64(&mut sb, 136, root_oid); + put64(&mut sb, 264, flags); + sb[704..704 + name.len()].copy_from_slice(name.as_bytes()); + self.push(seal(sb)) + } + + /// The container: its blocks, and a superblock over the volumes + /// (each by the block of its superblock). + fn finish(mut self, volumes: &[u64]) -> Vec<u8> { + let ids: Vec<u64> = (0..volumes.len() as u64).map(|i| 1100 + i).collect(); + let omap = self.omap( + &ids.iter() + .zip(volumes) + .map(|(&id, &b)| mapping(id, 10, b, 0)) + .collect::<Vec<_>>(), + ); + self.blocks[0] = self.superblock(10, omap, &ids, 0, 0); + self.blocks.concat() + } + + fn superblock(&self, xid: u64, omap: u64, ids: &[u64], desc: u64, descs: u32) -> Vec<u8> { + let mut sb = vec![0u8; BS]; + put64(&mut sb, 16, xid); + put32(&mut sb, 24, 1); + put32(&mut sb, 32, NX_MAGIC); + put32(&mut sb, 36, BS as u32); + put64(&mut sb, 40, self.blocks.len() as u64 + 8); + put32(&mut sb, 104, descs); + put64(&mut sb, 112, desc); + put64(&mut sb, 160, omap); + put32(&mut sb, 180, 100); + for (i, id) in ids.iter().enumerate() { + put64(&mut sb, 184 + 8 * i, *id); + } + seal(sb) + } + } + + /// The key of a record of type `ty` of object `id`, then `tail`. + fn key(id: u64, ty: u8, tail: &[u8]) -> Vec<u8> { + let mut k = (id | (u64::from(ty) << 60)).to_le_bytes().to_vec(); + k.extend_from_slice(tail); + k + } + + /// An inode: its parent, the id its extents are keyed by, its mode, its + /// flags and its size (in a data stream field). + fn inode(id: u64, parent: u64, stream: u64, flags: u32, size: u64) -> (Vec<u8>, Vec<u8>) { + let mut v = vec![0u8; 92]; + put64(&mut v, 0, parent); + put64(&mut v, 8, stream); + put32(&mut v, 68, flags); + put16(&mut v, 80, 0o100644); + v.extend_from_slice(&[1, 0, 48, 0, 8, 0, 40, 0]); + let mut d = size.to_le_bytes().to_vec(); + d.resize(40, 0); + v.extend(d); + (key(id, J_INODE, &[]), v) + } + + /// A directory record: `parent` holds `name`, the object `id` of kind `dt`. + fn drec(parent: u64, name: &str, id: u64, dt: u16) -> (Vec<u8>, Vec<u8>) { + let mut tail = (name.len() as u32 + 1).to_le_bytes().to_vec(); + tail.extend_from_slice(name.as_bytes()); + tail.push(0); + let mut v = id.to_le_bytes().to_vec(); + v.extend_from_slice(&[0; 8]); + v.extend_from_slice(&dt.to_le_bytes()); + (key(parent, J_DREC, &tail), v) + } + + /// An extent of the stream `id`: `len` bytes at `at` in the file, in + /// blocks from `block` (0: a hole). + fn extent(id: u64, at: u64, len: u64, block: u64) -> (Vec<u8>, Vec<u8>) { + let mut v = len.to_le_bytes().to_vec(); + v.extend_from_slice(&block.to_le_bytes()); + v.extend_from_slice(&[0; 8]); + (key(id, J_FILE_EXTENT, &at.to_le_bytes()), v) + } + + /// An attribute of object `id`, held in its record, or as a stream. + fn xattr(id: u64, name: &str, value: &[u8], stream: Option<(u64, u64)>) -> (Vec<u8>, Vec<u8>) { + let mut tail = (name.len() as u16 + 1).to_le_bytes().to_vec(); + tail.extend_from_slice(name.as_bytes()); + tail.push(0); + let mut v = Vec::new(); + match stream { + None => { + v.extend_from_slice(&2u16.to_le_bytes()); + v.extend_from_slice(&(value.len() as u16).to_le_bytes()); + v.extend_from_slice(value); + } + Some((obj, size)) => { + v.extend_from_slice(&1u16.to_le_bytes()); + v.extend_from_slice(&48u16.to_le_bytes()); + v.extend_from_slice(&obj.to_le_bytes()); + v.extend_from_slice(&size.to_le_bytes()); + v.resize(52, 0); + } + } + (key(id, J_XATTR, &tail), v) + } + + /// Records in the order of the tree: by object, then type; those of one + /// object and type stay as given. + fn sorted(mut recs: Vec<(Vec<u8>, Vec<u8>)>) -> Vec<(Vec<u8>, Vec<u8>)> { + recs.sort_by_key(|(k, _)| key_of(k)); + recs + } + + fn run(img: &[u8], limits: Limits) -> (Vec<Entry>, Result<(), LimitHit>) { + let mut budget = Budget::new(limits); + let mut out = Vec::new(); + let end = walk_volume(Cursor::new(img.to_vec()), &mut budget, &mut |e, _| { + out.push(e.whole()); + None::<()> + }); + (out, end.map(|_| ())) + } + + fn named(entries: &[Entry]) -> Vec<(&str, &[u8])> { + entries.iter().map(|e| (&e.name[..], &e.data[..])).collect() + } + + const DT_REG: u16 = 8; + const PACKED: u32 = 0x20; + + /// Folders and files, with a file in a hole-ridden stream and a clone. + fn tree_of_folders() -> Vec<u8> { + let mut c = Cont::new(); + let (a, b, d) = (c.data(b"in root"), c.data(&[b'B'; BS]), c.data(&[b'D'; BS])); + let sub = c.data(b"in sub"); + let recs = sorted(vec![ + drec(2, "a.txt", 20, DT_REG), + drec(2, "dir", 16, DT_DIR), + drec(2, "link", 23, DT_LNK), + drec(16, "b.txt", 21, DT_REG), + drec(16, "sub", 17, DT_DIR), + // A clone: its inode names another stream. + drec(17, "c.txt", 22, DT_REG), + inode(20, 2, 20, 0, 7), + extent(20, 0, BS as u64, a), + // Two blocks with a hole between, and a hole at the end. + inode(21, 16, 21, 0, 4 * BS as u64), + extent(21, 0, BS as u64, b), + extent(21, 2 * BS as u64, BS as u64, d), + inode(22, 17, 77, 0, 6), + extent(77, 0, BS as u64, sub), + ]); + let v = c.volume("Data", FS_UNENCRYPTED, &recs); + c.finish(&[v]) + } + + #[test] + fn the_files_of_a_tree_of_folders_come_out_under_their_paths() { + let (entries, end) = run(&tree_of_folders(), Limits::default()); + assert!(end.is_ok()); + let mut hole = vec![b'B'; BS]; + hole.extend_from_slice(&[0; BS]); + hole.extend_from_slice(&[b'D'; BS]); + hole.extend_from_slice(&[0; BS]); + assert_eq!( + named(&entries), + [ + ("/a.txt", &b"in root"[..]), + ("/dir/b.txt", &hole[..]), + ("/dir/sub/c.txt", b"in sub"), + ] + ); + } + + #[test] + fn compressed_files_are_unpacked_from_an_attribute_or_a_stream_fork() { + let text = b"compressed text, compressed text, compressed text".to_vec(); + let z = |data: &[u8]| { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + }; + let attr = |kind: u32, payload: &[u8]| { + let mut a = b"fpmc".to_vec(); + a.extend_from_slice(&kind.to_le_bytes()); + a.extend_from_slice(&(text.len() as u64).to_le_bytes()); + a.extend_from_slice(payload); + a + }; + // Type 4: one zlib block, the table 4 bytes into the fork. + let block = z(&text); + let mut fork = vec![0, 0, 0, 0, 1, 0, 0, 0, 12, 0, 0, 0]; + fork.extend_from_slice(&(block.len() as u32).to_le_bytes()); + fork.extend_from_slice(&block); + + let mut c = Cont::new(); + let fork_at = c.data(&fork); + let plain = c.data(b"plain"); + let recs = sorted(vec![ + drec(2, "inline", 30, DT_REG), + drec(2, "forked", 31, DT_REG), + drec(2, "plain", 32, DT_REG), + inode(30, 2, 30, PACKED, 0), + xattr(30, "com.apple.FinderInfo", b"zz", None), + xattr(30, "com.apple.decmpfs", &attr(3, &z(&text)), None), + inode(31, 2, 31, PACKED, 0), + xattr(31, "com.apple.decmpfs", &attr(4, &[]), None), + xattr( + 31, + "com.apple.ResourceFork", + &[], + Some((900, fork.len() as u64)), + ), + extent(900, 0, BS as u64, fork_at), + // Not marked: its attribute is not looked for. + inode(32, 2, 32, 0, 5), + xattr(32, "com.apple.decmpfs", &attr(3, &z(b"wrong")), None), + extent(32, 0, BS as u64, plain), + ]); + let v = c.volume("V", FS_UNENCRYPTED, &recs); + let (entries, end) = run(&c.finish(&[v]), Limits::default()); + assert!(end.is_ok()); + assert_eq!( + named(&entries), + [ + ("/inline", &text[..]), + ("/forked", &text[..]), + ("/plain", b"plain") + ] + ); + assert!(entries.iter().all(|e| e.unsupported.is_none())); + } + + #[test] + fn every_volume_is_walked_and_an_encrypted_one_is_reported() { + let mut c = Cont::new(); + let one = c.data(b"one"); + let two = c.data(b"two"); + let first = sorted(vec![ + drec(2, "f", 20, DT_REG), + inode(20, 2, 20, 0, 3), + extent(20, 0, BS as u64, one), + ]); + let second = sorted(vec![ + drec(2, "f", 20, DT_REG), + inode(20, 2, 20, 0, 3), + extent(20, 0, BS as u64, two), + ]); + let (a, b) = (c.volume("First", 1, &first), c.volume("Second", 1, &second)); + let locked = c.volume("Locked", 0, &first); + let (entries, end) = run(&c.finish(&[a, b, locked]), Limits::default()); + assert!(end.is_ok()); + assert_eq!( + named(&entries[..2]), + [("/First/f", &b"one"[..]), ("/Second/f", b"two")] + ); + assert_eq!(entries[2].name, "<apfs-volume:Locked>"); + assert!(entries[2].encrypted && entries[2].unsupported.is_some()); + assert_eq!(entries.len(), 3); + } + + #[test] + fn what_the_volume_cannot_give_of_a_file_is_reported() { + let mut c = Cont::new(); + let recs = sorted(vec![ + drec(2, "no-inode", 20, DT_REG), + drec(2, "no-blocks", 21, DT_REG), + inode(21, 2, 21, 0, 100), + // Past the end of the container. + drec(2, "outside", 22, DT_REG), + inode(22, 2, 22, 0, 100), + extent(22, 0, 100, 5000), + ]); + let v = c.volume("V", 1, &recs); + let (entries, end) = run(&c.finish(&[v]), Limits::default()); + assert!(end.is_ok()); + let got: Vec<_> = entries + .iter() + .map(|e| (&e.name[..], e.unsupported.is_some(), e.data.len())) + .collect(); + // A file with no extents is a hole as long as its size says. + assert_eq!( + got, + [ + ("/no-inode", true, 0), + ("/no-blocks", false, 100), + ("/outside", true, 0) + ] + ); + assert!(entries[0].unsupported.unwrap().contains("inode")); + assert_eq!( + entries[2].unsupported, + Some("file could not be read from the volume") + ); + } + + #[test] + fn the_newest_superblock_of_the_checkpoint_area_is_the_one() { + let mut c = Cont::new(); + let old = c.data(b"old"); + let new = c.data(b"new"); + let make = |c: &mut Cont, block: u64| { + let recs = sorted(vec![ + drec(2, "f", 20, DT_REG), + inode(20, 2, 20, 0, 3), + extent(20, 0, BS as u64, block), + ]); + c.volume("V", 1, &recs) + }; + let (v_old, v_new) = (make(&mut c, old), make(&mut c, new)); + // The container's first block names the old volume; the area, two + // blocks later, holds a superblock of a later transaction naming the new. + let omap = c.omap(&[mapping(1100, 10, v_old, 0), mapping(1101, 11, v_new, 0)]); + let area = c.blocks.len() as u64; + let newer = c.superblock(11, omap, &[1101], 0, 0); + c.blocks.push(vec![0; BS]); + c.blocks.push(newer); + c.blocks[0] = c.superblock(10, omap, &[1100], area, 2); + let (entries, end) = run(&c.blocks.concat(), Limits::default()); + assert!(end.is_ok()); + assert_eq!(named(&entries), [("/f", &b"new"[..])]); + } + + /// A superblock newer than the others whose checksum does not hold is a + /// write that was cut short: the one before it is the container's. With none + /// that holds, the newest is. + #[test] + fn a_newer_superblock_that_was_cut_short_is_passed_over() { + let mut c = Cont::new(); + let (old, new) = (c.data(b"old"), c.data(b"new")); + let make = |c: &mut Cont, block: u64| { + let recs = sorted(vec![ + drec(2, "f", 20, DT_REG), + inode(20, 2, 20, 0, 3), + extent(20, 0, BS as u64, block), + ]); + c.volume("V", 1, &recs) + }; + let (v_old, v_new) = (make(&mut c, old), make(&mut c, new)); + let omap = c.omap(&[mapping(1100, 10, v_old, 0), mapping(1101, 11, v_new, 0)]); + let area = c.blocks.len() as u64; + let newer = c.superblock(11, omap, &[1101], 0, 0); + c.blocks.push(vec![0; BS]); + c.blocks.push(newer); + c.blocks[0] = c.superblock(10, omap, &[1100], area, 2); + let mut img = c.blocks.concat(); + let at = usize::try_from(area).unwrap() + 1; + img[at * BS] ^= 0xff; + let (entries, _) = run(&img, Limits::default()); + assert_eq!(named(&entries), [("/f", &b"old"[..])]); + // The first block's does not hold either: the newest is the best there is. + img[0] ^= 0xff; + let (entries, _) = run(&img, Limits::default()); + assert_eq!(named(&entries), [("/f", &b"new"[..])]); + } + + #[test] + fn files_past_the_buffer_limit_are_read_as_they_go() { + let img = tree_of_folders(); + let mut budget = Budget::new(Limits { + max_buffer_bytes: 3, + ..Limits::default() + }); + let (e, end) = collected(|emit| walk_volume(Cursor::new(img.to_vec()), &mut budget, emit)); + end.unwrap(); + // The same bytes as held, holes included, and none of them held. + let (held, end) = run(&img, Limits::default()); + assert!(end.is_ok()); + assert_eq!(e.len(), held.len()); + assert!(e.iter().any(|(m, _)| m.data.contains(&0)), "a hole"); + for ((m, streamed), h) in e.iter().zip(&held) { + assert_eq!((&m.name, &m.data), (&h.name, &h.data)); + assert_eq!(*streamed, m.data.len() > 3, "{}", m.name); + } + } + + #[test] + fn a_tree_larger_than_the_member_limit_is_walked_to_it_and_then_stops() { + let limits = Limits { + max_members: 2, + ..Limits::default() + }; + let (entries, end) = run(&tree_of_folders(), limits); + // The directory records are "a.txt" and "dir", which are kept, and + // "link", which is over. + assert_eq!(named(&entries), [("/a.txt", &b"in root"[..])]); + assert_eq!(end.expect_err("the limit").kind, crate::LimitKind::MaxFiles); + } + + /// The block of the root of the tree of `img`'s last volume; its leaves + /// are the two before it. + fn root_of(img: &[u8]) -> usize { + img.chunks(BS) + .rposition(|b| le16(b, 32) == ROOT_NODE && le16(b, 34) == 1) + .unwrap() + } + + #[test] + fn a_damaged_leaf_ends_the_walk_after_the_files_before_it() { + // Ten directory records and two inodes, which two leaves split in + // half: the second leaf has the last four of the names. + let mut recs: Vec<_> = (0..10) + .map(|i| drec(2, &format!("f{i}"), 20 + i, DT_REG)) + .collect(); + recs.push(inode(40, 2, 40, 0, 0)); + recs.push(inode(41, 2, 41, 0, 0)); + let mut c = Cont::new(); + let v = c.volume("V", 1, &sorted(recs)); + let mut img = c.finish(&[v]); + let root = root_of(&img); + + // A node that says it has more keys than it has room for, its checksum + // made good again. + let spoil = |img: &mut Vec<u8>, block: usize| { + let b = &mut img[block * BS..(block + 1) * BS]; + put32(b, 36, u32::MAX); + let sum = fletcher64(&b[8..]); + b[..8].copy_from_slice(&sum.to_le_bytes()); + }; + + // The second leaf spoiled: the names of the first are walked, and + // their inodes, which are in the second, are not found. + let mut bad = img.clone(); + spoil(&mut bad, root - 1); + let (entries, end) = run(&bad, Limits::default()); + assert!(end.expect_err("damage").is_corrupt()); + assert_eq!(entries.len(), 6); + assert!(entries.iter().all(|e| e.unsupported.is_some())); + + // The first spoiled: the tree cannot be entered. + spoil(&mut img, root - 2); + let (entries, end) = run(&img, Limits::default()); + assert!(end.expect_err("damage").is_corrupt()); + assert!(entries.is_empty()); + } + + /// Ten files and the leaves that hold them, and the block of the root of + /// their tree. + fn ten_files() -> (Vec<u8>, usize) { + let mut recs: Vec<_> = (0..10) + .map(|i| drec(2, &format!("f{i}"), 20 + i, DT_REG)) + .collect(); + recs.push(inode(40, 2, 40, 0, 0)); + recs.push(inode(41, 2, 41, 0, 0)); + let mut c = Cont::new(); + let v = c.volume("V", 1, &sorted(recs)); + let img = c.finish(&[v]); + let root = root_of(&img); + (img, root) + } + + /// A checksum that does not hold keeps nothing from being scanned: the + /// block is read as it is. + #[test] + fn a_checksum_that_does_not_hold_is_not_a_reason_not_to_read() { + let (mut img, root) = ten_files(); + let (clean, _) = run(&img, Limits::default()); + for block in [root, root - 1, root - 2, 0] { + img[block * BS] ^= 0xff; + } + let (entries, end) = run(&img, Limits::default()); + assert!(end.is_ok()); + assert_eq!(named(&entries), named(&clean)); + assert_eq!(entries.len(), 10); + } + + /// With checksums checked, the same blocks are refused. + #[cfg(feature = "checksums")] + #[test] + fn a_checksum_that_does_not_hold_is_refused_when_checksums_are_checked() { + let strict = |img: &[u8]| { + let mut budget = Budget::new(Limits::default()); + budget.set_verify_checksums(true); + let mut out = Vec::new(); + let end = walk_volume(Cursor::new(img.to_vec()), &mut budget, &mut |e, _| { + out.push(e.whole()); + None::<()> + }); + (out, end) + }; + let (mut img, root) = ten_files(); + assert_eq!(strict(&img).0.len(), 10); + let mut bad = img.clone(); + bad[(root - 1) * BS + 100] ^= 0xff; + let (entries, end) = strict(&bad); + assert!(end.expect_err("damage").is_corrupt()); + assert_eq!(entries.len(), 6); + img[0] ^= 0xff; + assert!(strict(&img).1.is_err()); + } + + #[test] + fn nothing_is_a_container_but_a_checked_superblock_with_an_object_map() { + let good = tree_of_folders(); + // The magic, the block size, the object map's block: each in turn, + // the checksum made good again, so that only it is wrong. + for (at, byte) in [(32, b'X'), (37, 0x20), (161, 0xff)] { + let mut bad = good.clone(); + bad[at] ^= byte; + let sum = fletcher64(&bad[8..BS]); + bad[..8].copy_from_slice(&sum.to_le_bytes()); + let (entries, end) = run(&bad, Limits::default()); + assert!(entries.is_empty(), "byte {at}"); + assert!(end.expect_err("not a container").is_corrupt(), "byte {at}"); + } + assert!(run(&good[..BS - 1], Limits::default()).1.is_err()); + } + + /// Whatever a container says, the walk ends: every byte of the trees + /// changed, in turn, to each of a few values, the checksums made good. + #[test] + fn damaged_containers_end_the_walk_without_a_panic() { + let good = tree_of_folders(); + for at in (0..good.len()).step_by(7) { + for byte in [0x00, 0x01, 0x7f, 0xff] { + let mut bad = good.clone(); + bad[at] = byte; + let block = at / BS * BS; + let sum = fletcher64(&bad[block + 8..block + BS]); + bad[block..block + 8].copy_from_slice(&sum.to_le_bytes()); + let _ = run(&bad, Limits::default()); + } + } + } + + /// A directory record's key has the name behind a length and a hash, or + /// behind a length alone; either way the name is NUL-ended and ends the key. + #[test] + fn a_directory_records_name_is_read_from_either_key() { + let hashed = |len_and_hash: u32, name: &[u8]| { + key(2, J_DREC, &[&len_and_hash.to_le_bytes()[..], name].concat()) + }; + let plain = + |len: u16, name: &[u8]| key(2, J_DREC, &[&len.to_le_bytes()[..], name].concat()); + // The hash is the 22 bits above the length. + assert_eq!( + drec_name(&hashed(0xABCD_E000 | 6, b"hello\0")).as_deref(), + Some("hello") + ); + assert_eq!(drec_name(&plain(6, b"hello\0")).as_deref(), Some("hello")); + assert_eq!(drec_name(&plain(1, b"\0")).as_deref(), Some("")); + // A length that is not the rest of the key is no name. + assert_eq!(drec_name(&hashed(9, b"hello\0")), None); + assert_eq!(drec_name(&plain(7, b"hello\0")), None); + assert_eq!(drec_name(&[0; 8]), None); + // Not UTF-8: kept as far as it can be. + assert_eq!( + drec_name(&plain(3, &[b'a', 0xff, 0])).as_deref(), + Some("a\u{fffd}") + ); + } + + #[test] + fn the_checksum_is_apples() { + // Worked by hand: words 1 and 2. + let m = 0xFFFF_FFFFu64; + assert_eq!(fletcher64(&[1, 0, 0, 0, 2, 0, 0, 0]), 4 << 32 | (m - 7)); + // A block with the checksum it carries reads; with one byte changed, not. + let b = seal((0..BS).map(|i| (i % 251) as u8).collect()); + assert_eq!(le64(&b, 0), fletcher64(&b[8..])); + } + + #[test] + fn the_object_map_gives_the_newest_mapping_not_past_the_transaction() { + let mut c = Cont::new(); + let omap = c.omap(&[ + mapping(7, 3, 100, 0), + mapping(7, 5, 200, 0), + mapping(7, 9, 300, 0), + mapping(8, 4, 400, 0), + mapping(8, 6, 400, 1), + mapping(9, 2, 500, 0), + ]); + let img = c.blocks.concat(); + let mut cur = Cursor::new(img); + let mut v = Vol { + disk: &mut cur, + len: (c.blocks.len() * BS) as u64, + max: 1 << 20, + }; + let mut fs = Fs { + v: &mut v, + block: BS as u64, + cache: HashMap::new(), + strict: false, + }; + let m = load_omap(&mut fs, omap, 6).unwrap(); + // 7: the newest not past 6 is 5. 8: its newest, 6, is a deletion. + assert_eq!(m.get(&7), Some(&Some(200))); + assert_eq!(m.get(&8), Some(&None)); + assert_eq!(m.get(&9), Some(&Some(500))); + } +} diff --git a/crates/exav-unpack/src/formats/ar.rs b/crates/exav-unpack/src/formats/ar.rs index c75f7e4d..e7e68c9b 100644 --- a/crates/exav-unpack/src/formats/ar.rs +++ b/crates/exav-unpack/src/formats/ar.rs @@ -3,14 +3,23 @@ //! `.deb` files are `ar` archives whose members are usually `control.tar.*` and //! `data.tar.*`; emitting those members lets the engine recurse into them. +use crate::stream::Region; use crate::*; use std::io::{Read, Seek, SeekFrom}; const MAGIC: &[u8] = b"!<arch>\n"; -fn parse_decimal(field: &[u8]) -> u64 { - let s = std::str::from_utf8(field).unwrap_or("").trim(); - s.parse().unwrap_or(0) +/// A header's decimal field as `ar` and `dpkg-deb` read it: leading white space +/// skipped, then the digits, and whatever follows them (spaces, a stray byte) +/// ignored. `None` when there are no digits. +fn parse_decimal(field: &[u8]) -> Option<u64> { + let digits: Vec<u8> = field + .iter() + .copied() + .skip_while(|b| b.is_ascii_whitespace()) + .take_while(u8::is_ascii_digit) + .collect(); + std::str::from_utf8(&digits).ok()?.parse().ok() } /// Walk an `ar` archive, each member streamed from where it lies. @@ -32,7 +41,7 @@ pub(crate) fn walk<T>( pub(crate) fn stream_offsets<R: Read + Seek>( source: &mut R, max_buffer: u64, -) -> Result<Vec<(String, u64, u64)>, LimitHit> { +) -> Result<Vec<Region>, LimitHit> { let mut magic = [0u8; 8]; source .seek(SeekFrom::Start(0)) @@ -41,21 +50,38 @@ pub(crate) fn stream_offsets<R: Read + Seek>( if magic != MAGIC { return Err(LimitHit::new("ar: bad magic".to_string())); } - let mut out = Vec::new(); + let mut out: Vec<Region> = Vec::new(); let mut name_table: Vec<u8> = Vec::new(); let mut pos = MAGIC.len() as u64; + let len = source + .seek(SeekFrom::End(0)) + .map_err(|e| LimitHit::corrupt(format!("ar: {e}")))?; loop { - if source.seek(SeekFrom::Start(pos)).is_err() { + if pos >= len { break; } + source + .seek(SeekFrom::Start(pos)) + .map_err(|e| LimitHit::corrupt(format!("ar: {e}")))?; let mut hdr = [0u8; 60]; - if source.read_exact(&mut hdr).is_err() { - break; - } - if &hdr[58..60] != b"`\n" { + let got = source.read(&mut hdr).unwrap_or(0); + let size = (got == 60 && &hdr[58..60] == b"`\n") + .then(|| parse_decimal(&hdr[48..58])) + .flatten(); + let Some(size) = size else { + // Not a header, with bytes left. `ar` and `dpkg-deb` do not all + // agree about what a damaged one is, so what is left is scanned as + // it lies, unless it is only the padding a writer leaves. + let rest = crate::read_at(source, pos, 4096)?; + if rest.iter().any(|b| !matches!(b, b'\n' | b' ' | 0)) { + out.push(Region::Member( + format!("<ar-from-{pos}>"), + pos, + len.saturating_sub(pos), + )); + } break; - } - let size = parse_decimal(&hdr[48..58]); + }; let body = pos + 60; let name = resolve_name(&hdr[0..16], &name_table); match name.as_str() { @@ -70,9 +96,9 @@ pub(crate) fn stream_offsets<R: Read + Seek>( source.read_exact(&mut name_table).ok(); } "/" | "/SYM64/" | "__.SYMDEF" => {} - _ => out.push((name, body, size)), + _ => out.push(Region::Member(name, body, size)), } - pos = body + size + (size & 1); + pos = body.saturating_add(size).saturating_add(size & 1); } Ok(out) } @@ -87,17 +113,16 @@ fn resolve_name(raw: &[u8], name_table: &[u8]) -> String { return trimmed.to_string(); } // GNU long name: "/<offset>" into the string table. - if let Some(rest) = trimmed.strip_prefix('/') { - if let Ok(off) = rest.parse::<usize>() { - if off < name_table.len() { - let end = name_table[off..] - .iter() - .position(|&b| b == b'/' || b == b'\n') - .map(|p| off + p) - .unwrap_or(name_table.len()); - return String::from_utf8_lossy(&name_table[off..end]).into_owned(); - } - } + if let Some(rest) = trimmed.strip_prefix('/') + && let Ok(off) = rest.parse::<usize>() + && off < name_table.len() + { + let end = name_table[off..] + .iter() + .position(|&b| b == b'/' || b == b'\n') + .map(|p| off + p) + .unwrap_or(name_table.len()); + return String::from_utf8_lossy(&name_table[off..end]).into_owned(); } // GNU short name: trailing '/'. trimmed.trim_end_matches('/').to_string() diff --git a/crates/exav-unpack/src/formats/arc.rs b/crates/exav-unpack/src/formats/arc.rs index e72746d7..5f5f8754 100644 --- a/crates/exav-unpack/src/formats/arc.rs +++ b/crates/exav-unpack/src/formats/arc.rs @@ -240,12 +240,12 @@ fn lzw(src: &[u8], max_width: u32, cap: usize) -> Option<Vec<u8>> { } out.extend(stack.iter().rev()); - if let Some(p) = prev { - if (next as usize) < table_cap { - prefix[next as usize] = p; - suffix[next as usize] = *stack.last()?; - next += 1; - } + if let Some(p) = prev + && (next as usize) < table_cap + { + prefix[next as usize] = p; + suffix[next as usize] = *stack.last()?; + next += 1; } prev = Some(code); diff --git a/crates/exav-unpack/src/formats/arj.rs b/crates/exav-unpack/src/formats/arj.rs index 04c99453..fb90bca1 100644 --- a/crates/exav-unpack/src/formats/arj.rs +++ b/crates/exav-unpack/src/formats/arj.rs @@ -53,8 +53,9 @@ pub(crate) fn extract_arj<R>( // unsupported compression method is different: the member is there // and the victim's extractor will unpack it, so report rather than // step over it. - if !supported && !matches!(header.file_type, ArjFileType::Directory) { - if let Some(r) = visit( + if !supported + && !matches!(header.file_type, ArjFileType::Directory) + && let Some(r) = visit( Entry::unsupported( header.name.clone(), header.compressed_size as u64, @@ -62,9 +63,9 @@ pub(crate) fn extract_arj<R>( "unsupported ARJ compression method", ), budget, - ) { - return Ok(Some(r)); - } + ) + { + return Ok(Some(r)); } if !arc.skip(&header) { return Err(LimitHit::corrupt("arj: truncated entry".to_string())); @@ -162,7 +163,7 @@ pub(crate) fn extract_arj<R>( #[cfg(test)] mod tests { - use crate::{extract, Budget, Format, Limits}; + use crate::{Budget, Format, Limits, extract}; /// A garbled (encrypted, `arj -g`) member is decrypted with a pool password /// and its plaintext recovered — identical to the unencrypted `sample.arj`. diff --git a/crates/exav-unpack/src/formats/arj_parse/arj_archive.rs b/crates/exav-unpack/src/formats/arj_parse/arj_archive.rs index e7059efe..a09e967c 100644 --- a/crates/exav-unpack/src/formats/arj_parse/arj_archive.rs +++ b/crates/exav-unpack/src/formats/arj_parse/arj_archive.rs @@ -198,12 +198,10 @@ impl ArjArchive { &buf, header.original_size as usize, &header.compression_method, - ) { - if out.len() == header.original_size as usize - && crc32fast::hash(&out) == header.original_crc32 - { - return Some(out); - } + ) && out.len() == header.original_size as usize + && crc32fast::hash(&out) == header.original_crc32 + { + return Some(out); } } return None; diff --git a/crates/exav-unpack/src/formats/arj_parse/macros.rs b/crates/exav-unpack/src/formats/arj_parse/macros.rs index 74dbe7d3..337f0720 100644 --- a/crates/exav-unpack/src/formats/arj_parse/macros.rs +++ b/crates/exav-unpack/src/formats/arj_parse/macros.rs @@ -3,7 +3,7 @@ // than the fields it claims to hold is ordinary attacker input, and the caller // returns `None` for it rather than panicking out through the decoder. macro_rules! convert_u32 { - ( $t:ident, $x:expr ) => { + ( $t:ident, $x:expr_2021 ) => { if $x.len() < 4 { return None; } @@ -16,7 +16,7 @@ macro_rules! convert_u32 { } macro_rules! convert_u16 { - ( $t:ident, $x:expr ) => { + ( $t:ident, $x:expr_2021 ) => { if $x.len() < 2 { return None; } @@ -29,7 +29,7 @@ macro_rules! convert_u16 { } macro_rules! convert_u8 { - ( $t:ident, $x:expr ) => { + ( $t:ident, $x:expr_2021 ) => { if $x.is_empty() { return None; } @@ -42,7 +42,7 @@ macro_rules! convert_u8 { } macro_rules! convert_string { - ( $t:ident, $x:expr ) => { + ( $t:ident, $x:expr_2021 ) => { let mut $t = String::new(); while !$x.is_empty() && $x[0] != 0 { $t.push($x[0] as char); @@ -57,7 +57,7 @@ macro_rules! convert_string { } macro_rules! skip { - ( $x:expr, $t:expr ) => { + ( $x:expr_2021, $t:expr_2021 ) => { #[allow(unused_assignments)] { let t = $t; diff --git a/crates/exav-unpack/src/formats/autoit.rs b/crates/exav-unpack/src/formats/autoit.rs index 540533a5..cf9deb59 100644 --- a/crates/exav-unpack/src/formats/autoit.rs +++ b/crates/exav-unpack/src/formats/autoit.rs @@ -672,7 +672,7 @@ mod tests { d.extend_from_slice(MARKER_EA05); d.extend_from_slice(&[0u8; 16]); // checksum region d.extend_from_slice(&mt_apply(b"FILE", KEY_FILE_TAG)); // tag - // subtype string + // subtype string d.extend_from_slice(&((subtype.len() as u32) ^ KEY_SUBTYPE_LEN).to_le_bytes()); d.extend_from_slice(&mt_apply( subtype, @@ -979,8 +979,10 @@ mod tests { let _ = extract(Format::Autoit, &&full[..cut.min(full.len())], &mut budget).unwrap(); } let mut budget = Budget::new(Limits::default()); - assert!(extract(Format::Autoit, b"not autoit", &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Autoit, b"not autoit", &mut budget) + .unwrap() + .is_empty() + ); } } diff --git a/crates/exav-unpack/src/formats/azo.rs b/crates/exav-unpack/src/formats/azo.rs index 4dc816af..dea5bf1f 100644 --- a/crates/exav-unpack/src/formats/azo.rs +++ b/crates/exav-unpack/src/formats/azo.rs @@ -51,11 +51,7 @@ const SCALE_N: u32 = 8; /// `x == 0` cannot occur at any call site (every argument is `n - 1` for /// `n >= 2`, or a positive quotient), but it is defined rather than panicking. fn log2(x: u32) -> u32 { - if x == 0 { - 0 - } else { - 31 - x.leading_zeros() - } + if x == 0 { 0 } else { 31 - x.leading_zeros() } } /// Reads individual bits, MSB-first within each byte. diff --git a/crates/exav-unpack/src/formats/biff/book.rs b/crates/exav-unpack/src/formats/biff/book.rs new file mode 100644 index 00000000..302427bf --- /dev/null +++ b/crates/exav-unpack/src/formats/biff/book.rs @@ -0,0 +1,345 @@ +//! What a BIFF workbook's global records say that formulas need: the code +//! page, the sheets, the defined names and the external references their +//! tokens point at. + +use super::formula::{Ctx, read_chars}; +use super::record::Rec; +use crate::bytes::Cur; +use crate::formats::charset; + +/// A BOUNDSHEET record. +pub struct BoundSheet { + /// Offset of the sheet's BOF in the stream. + pub pos: usize, + pub name: String, + pub state: u8, + /// 0 worksheet, 1 Excel 4.0 macro sheet, 2 chart, 6 Visual Basic module. + pub kind: u8, +} + +/// A NAME record. +pub struct Name { + pub name: String, + /// 1-based sheet of a local name. + pub sheet: u16, + pub hidden: bool, + pub rgce: Vec<u8>, + /// What follows `rgce`: the data its array constants keep. + pub extra: Vec<u8>, +} + +/// A BIFF8 SUPBOOK record. +#[derive(Default, Clone, Debug)] +pub struct Supbook { + pub internal: bool, + pub addin: bool, + /// An external workbook's number among them, from 1. + pub ext_index: usize, + pub sheets: Vec<String>, + pub names: Vec<String>, +} + +/// A BIFF5 EXTERNSHEET entry. +#[derive(Clone, Debug)] +pub enum ExtSheet5 { + /// A sheet of this workbook (by name), or the workbook itself. + Own, + /// An external document's sheet: its number among external documents. + External { doc: usize, sheet: String }, + /// Add-in functions: the names that follow. + Names(Vec<String>), +} + +/// The workbook-wide tables, filled by [`Book::record`] from the records of +/// the globals substream (all of a BIFF2 to BIFF4 stream's records). +pub struct Book { + /// 2, 3, 4, 5 or 8. + pub ver: u8, + pub codepage: u16, + pub sheets: Vec<BoundSheet>, + pub names: Vec<Name>, + /// BIFF8 EXTERNSHEET entries: (supbook, first sheet, last sheet). + pub xti: Vec<(u16, i16, i16)>, + pub supbooks: Vec<Supbook>, + /// BIFF5 globals' EXTERNSHEET entries (for names). + pub ext5: Vec<ExtSheet5>, + /// The names of `sheets`, which formulas refer to; set by [`Book::finish`] + /// or by a reader that knows better. + pub sheet_names: Vec<String>, + /// The names of `names`; set by [`Book::finish`]. + pub name_texts: Vec<String>, + /// External documents, numbered from 1 by their place here. + ext_docs: Vec<String>, +} + +impl Book { + pub fn new(ver: u8) -> Book { + Book { + ver, + codepage: if ver >= 8 { 1200 } else { 1252 }, + sheets: vec![], + names: vec![], + xti: vec![], + supbooks: vec![], + ext5: vec![], + sheet_names: vec![], + name_texts: vec![], + ext_docs: vec![], + } + } + + /// Takes the record if it is one of the tables'. Returns whether it was. + pub fn record(&mut self, r: &Rec) -> bool { + let ver = self.ver; + let mut c = Cur::new(&r.data); + match r.id { + 0x0042 => { + self.codepage = c.u16(); + if ver >= 8 { + self.codepage = 1200; + } + } + 0x0085 => { + let pos = c.u32() as usize; + let state = c.u8(); + let kind = c.u8(); + let name = self.string(&mut c, 1); + self.sheets.push(BoundSheet { + pos, + name, + state, + kind, + }); + } + 0x0018 | 0x0218 => { + let n = self.name(r); + self.names.push(n); + } + 0x0017 if ver >= 8 => { + let n = c.u16() as usize; + for _ in 0..n { + let sb = c.u16(); + let a = c.i16(); + let b = c.i16(); + if c.short { + break; + } + self.xti.push((sb, a, b)); + } + } + 0x0017 => { + let e = self.ext_sheet5(&r.data); + self.ext5.push(e); + } + 0x01AE => { + let ctab = c.u16(); + let cch = c.u16(); + let mut sb = Supbook::default(); + if cch == 0x0401 { + sb.internal = true; + } else if cch == 0x3A01 { + sb.addin = true; + } else { + let mut c2 = Cur::at(&r.data, 2); + let url = self.string(&mut c2, 2); + for _ in 0..ctab.min(4096) { + if c2.left() == 0 { + break; + } + sb.sheets.push(self.string(&mut c2, 2)); + } + sb.ext_index = self.ext_doc(&url); + } + self.supbooks.push(sb); + } + 0x0023 => { + if ver >= 8 { + let _flags = c.u16(); + c.u32(); + let name = self.string(&mut c, 1); + if let Some(sb) = self.supbooks.last_mut() { + sb.names.push(name); + } + } else if ver == 5 { + c.skip(6); + let n = c.u8() as usize; + let name = charset::decode(c.bytes(n), self.codepage); + if let Some(ExtSheet5::Names(v)) = self.ext5.last_mut() { + v.push(name); + } + } + } + _ => return false, + } + true + } + + /// Fills `sheet_names` and `name_texts` from the records read. + pub fn finish(&mut self) { + self.sheet_names = self.sheets.iter().map(|s| s.name.clone()).collect(); + self.name_texts = self.names.iter().map(|n| n.name.clone()).collect(); + } + + /// What a formula needs to name what it refers to. `ext5` is the + /// EXTERNSHEET entries in scope (a BIFF5 sheet's, or the globals'). + pub fn ctx<'a>(&'a self, ext5: &'a [ExtSheet5]) -> Ctx<'a> { + Ctx { + ver: self.ver, + sheets: &self.sheet_names, + names: &self.name_texts, + xti: &self.xti, + supbooks: &self.supbooks, + ext5, + codepage: self.codepage, + } + } + + /// A string in the BIFF5-and-older code page, or BIFF8's Unicode, with + /// a length of `len_bytes` (1 or 2) before it. + pub fn string(&self, c: &mut Cur, len_bytes: u8) -> String { + let n = if len_bytes == 1 { + c.u8() as usize + } else { + c.u16() as usize + }; + if self.ver >= 8 { + let f = c.u8(); + read_chars(c, n, f & 1 != 0) + } else { + charset::decode(c.bytes(n), self.codepage) + } + } + + /// The number of an external document among those the workbook names. + pub fn ext_doc(&mut self, doc: &str) -> usize { + match self.ext_docs.iter().position(|d| d == doc) { + Some(i) => i + 1, + None => { + self.ext_docs.push(doc.to_string()); + self.ext_docs.len() + } + } + } + + /// A BIFF5 EXTERNSHEET record's entry. + pub fn ext_sheet5(&mut self, d: &[u8]) -> ExtSheet5 { + let n = d.first().copied().unwrap_or(0) as usize; + let body = d.get(1..).unwrap_or(&[]); + match body.first() { + Some(0x02..=0x04) | None => ExtSheet5::Own, + Some(&0x3A) if n == 1 => ExtSheet5::Names(vec![]), + Some(_) => { + let s = charset::decode(&body[..n.min(body.len())], self.codepage); + let s = s.strip_prefix('\u{1}').unwrap_or(&s).to_string(); + let (doc, sheet) = match s.rfind(']') { + Some(i) => (s[..i].replace('[', ""), s[i + 1..].to_string()), + None => (s.clone(), String::new()), + }; + let doc: String = doc.chars().filter(|c| (*c as u32) >= 0x20).collect(); + ExtSheet5::External { + doc: self.ext_doc(&doc), + sheet, + } + } + } + } + + fn name(&self, r: &Rec) -> Name { + let ver = self.ver; + let mut c = Cur::new(&r.data); + let (flags, cch, cce, sheet, name); + if ver == 2 { + flags = u16::from(c.u8()) & 0xFFFE; + c.skip(2); + cch = c.u8() as usize; + cce = c.u8() as usize; + sheet = 0; + name = charset::decode(c.bytes(cch), self.codepage); + } else { + flags = c.u16(); + c.u8(); + cch = c.u8() as usize; + cce = c.u16() as usize; + if ver >= 5 { + c.u16(); + sheet = c.u16(); + c.skip(4); + } else { + sheet = 0; + } + name = if ver >= 8 { + let f = c.u8(); + read_chars(&mut c, cch, f & 1 != 0) + } else { + charset::decode(c.bytes(cch), self.codepage) + }; + } + let builtin = flags & 0x20 != 0; + let name = if builtin { + let b = match name.chars().next().map_or(0xFF, |c| c as u32) { + 0 => "Consolidate_Area", + 1 => "Auto_Open", + 2 => "Auto_Close", + 3 => "Extract", + 4 => "Database", + 5 => "Criteria", + 6 => "Print_Area", + 7 => "Print_Titles", + 8 => "Recorder", + 9 => "Data_Form", + 10 => "Auto_Activate", + 11 => "Auto_Deactivate", + 12 => "Sheet_Title", + 13 => "_FilterDatabase", + _ => "Unknown", + }; + format!("_xlnm.{b}") + } else if name == "_FilterDatabase" { + "_xlnm._FilterDatabase".into() + } else { + name + }; + let rgce = c.bytes(cce).to_vec(); + let extra = r.data[c.p.min(r.data.len())..].to_vec(); + Name { + name, + sheet, + hidden: flags & 1 != 0, + rgce, + extra, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::borrow::Cow; + + fn rec(id: u16, data: Vec<u8>) -> Rec<'static> { + Rec { + id, + pos: 0, + data: Cow::Owned(data), + breaks: vec![], + } + } + + #[test] + fn sheets_and_a_builtin_name_are_read() { + let mut b = Book::new(8); + // BOUNDSHEET: a macro sheet "Macro1" at 0x100. + let mut d = 0x100u32.to_le_bytes().to_vec(); + d.extend([0, 1, 6, 0]); + d.extend(b"Macro1"); + assert!(b.record(&rec(0x0085, d))); + // NAME: the built-in Auto_Open (code 1) with no formula. + let d = vec![0x20, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + assert!(b.record(&rec(0x0018, d))); + assert!(!b.record(&rec(0x0203, vec![0; 14]))); + b.finish(); + assert_eq!((b.sheets[0].kind, b.sheets[0].pos), (1, 0x100)); + assert_eq!(b.sheet_names, ["Macro1"]); + assert_eq!(b.name_texts, ["_xlnm.Auto_Open"]); + } +} diff --git a/crates/exav-unpack/src/formats/biff/formula.rs b/crates/exav-unpack/src/formats/biff/formula.rs new file mode 100644 index 00000000..30d626c4 --- /dev/null +++ b/crates/exav-unpack/src/formats/biff/formula.rs @@ -0,0 +1,1034 @@ +//! BIFF formula tokens ([MS-XLS] 2.5.198 Rgce; BIFF2 to BIFF5 as +//! OpenOffice.org's Excel format documentation, chapter 3, describes them) +//! to Excel A1 formula text. + +use super::book::{ExtSheet5, Supbook}; +use super::ftab::function as ftab; +use crate::bytes::Cur; +use crate::formats::charset; + +/// Formula text past this many characters is refused: Excel's own limit is +/// 8192. +const MAX_TEXT: usize = 16_384; + +/// What a formula needs from the workbook to name what it refers to. +pub struct Ctx<'a> { + pub ver: u8, + pub sheets: &'a [String], + /// Defined names, 1-based in tokens. + pub names: &'a [String], + /// BIFF8 EXTERNSHEET entries: (supbook, first sheet, last sheet). + pub xti: &'a [(u16, i16, i16)], + pub supbooks: &'a [Supbook], + /// BIFF5: the EXTERNSHEET entries in scope (a sheet's, or the globals'). + pub ext5: &'a [ExtSheet5], + pub codepage: u16, +} + +/// The name of a BIFF error code ([MS-XLS] 2.5.97 BErr), as XLSX writes it. +pub fn error_name(code: u8) -> Option<&'static str> { + Some(match code { + 0x00 => "#NULL!", + 0x07 => "#DIV/0!", + 0x0F => "#VALUE!", + 0x17 => "#REF!", + 0x1D => "#NAME?", + 0x24 => "#NUM!", + 0x2A => "#N/A", + 0x2B => "#GETTING_DATA", + _ => return None, + }) +} + +fn error_or_na(code: u8) -> &'static str { + error_name(code).unwrap_or("#N/A") +} + +/// Functions Excel 2007 added, stored in XLS as `_xlfn.` add-in calls: in +/// XLSX they are native, written without the prefix. +const XL2007: &[&str] = &[ + "AVERAGEIF", + "AVERAGEIFS", + "COUNTIFS", + "IFERROR", + "SUMIFS", + "CUBEKPIMEMBER", + "CUBEMEMBER", + "CUBEMEMBERPROPERTY", + "CUBERANKEDMEMBER", + "CUBESET", + "CUBESETCOUNT", + "CUBEVALUE", +]; + +/// A sheet name as a formula writes it, quoted when it must be. +pub fn quote_sheet(name: &str) -> String { + let plain = !name.is_empty() + && name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '.') + && !name.starts_with(|c: char| c.is_ascii_digit() || c == '.') + && !looks_like_ref(name); + if plain { + name.to_string() + } else { + format!("'{}'", name.replace('\'', "''")) + } +} + +/// A sheet of external workbook `n`: `[1]Sheet1`, quoted when the sheet's +/// name must be. +pub fn quote_external(n: usize, sheet: &str) -> String { + if quote_sheet(sheet) == sheet { + format!("[{n}]{sheet}") + } else { + format!("'[{n}]{}'", sheet.replace('\'', "''")) + } +} + +fn looks_like_ref(s: &str) -> bool { + let up = s.to_ascii_uppercase(); + let letters = up.chars().take_while(char::is_ascii_alphabetic).count(); + let rest = &up[letters..]; + ((1..=3).contains(&letters) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())) + || up == "TRUE" + || up == "FALSE" + || (up.starts_with('R') + && up[1..].contains('C') + && up[1..].chars().all(|c| c.is_ascii_digit() || c == 'C')) +} + +/// The name of a column: 0 is `A`, 26 is `AA`. +pub fn col_name(col: u32) -> String { + let mut n = u64::from(col) + 1; + let mut s = Vec::new(); + while n > 0 { + let r = (n - 1) % 26; + s.push(b'A' + r as u8); + n = (n - 1) / 26; + } + s.reverse(); + String::from_utf8(s).unwrap_or_default() +} + +/// A number as a spreadsheet shows it and writes it in a formula: at most 15 +/// significant digits. +pub fn number(v: f64) -> String { + if !v.is_finite() { + return "0".into(); + } + if v == v.trunc() && v.abs() < 1e15 { + return format!("{}", v as i64); + } + let e = format!("{v:.14e}"); + let (mant, exp) = e.split_once('e').unwrap_or((&e, "0")); + let exp: i32 = exp.parse().unwrap_or(0); + let neg = mant.starts_with('-'); + let digits: String = mant.chars().filter(char::is_ascii_digit).collect(); + let digits = digits.trim_end_matches('0'); + let digits = if digits.is_empty() { "0" } else { digits }; + let body = if (-5..15).contains(&exp) { + if exp >= 0 { + let int_len = exp as usize + 1; + if digits.len() <= int_len { + format!("{}{}", digits, "0".repeat(int_len - digits.len())) + } else { + format!("{}.{}", &digits[..int_len], &digits[int_len..]) + } + } else { + format!("0.{}{}", "0".repeat((-exp - 1) as usize), digits) + } + } else { + let m = if digits.len() > 1 { + format!("{}.{}", &digits[..1], &digits[1..]) + } else { + digits.to_string() + }; + format!("{}E{}{}", m, if exp < 0 { "-" } else { "+" }, exp.abs()) + }; + if neg { format!("-{body}") } else { body } +} + +pub struct Decoded { + pub text: String, + /// The formula is a reference to a shared or array formula: its cell. + pub exp: Option<(u32, u32)>, + /// A data table's cell. + pub table: bool, +} + +/// Fixed argument counts of functions whose count changed after BIFF2 +/// (FIXED after BIFF3): a PtgFunc in an older file carries them. +fn old_fixed(i: u16, ver: u8) -> Option<usize> { + match (i, ver) { + (14, 2..=3) => Some(2), + (70, 2) | (197, 2) => Some(1), + (101, 2) | (102, 2) => Some(3), + (220, 2) => Some(2), + _ => None, + } +} + +/// A formula under construction. The operands stay apart until the formula is +/// written, so that each character is copied once, not once per operator +/// above it: a chain of thousands of operators costs no more than its text. +#[derive(Default)] +struct Tree { + /// Each node with the length of the text it will write. + nodes: Vec<(Node, usize)>, +} + +enum Node { + Text(String), + Binary(&'static str, usize, usize), + Prefix(&'static str, usize), + Suffix(&'static str, usize), + Paren(usize), + Call(String, Vec<usize>), +} + +impl Tree { + fn add(&mut self, node: Node, len: usize) -> Result<usize, String> { + if len > MAX_TEXT { + return Err("formula too long".into()); + } + self.nodes.push((node, len)); + Ok(self.nodes.len() - 1) + } + + fn len(&self, i: usize) -> usize { + self.nodes[i].1 + } + + fn text(&mut self, s: impl Into<String>) -> Result<usize, String> { + let s = s.into(); + let n = s.len(); + self.add(Node::Text(s), n) + } + + fn binary(&mut self, op: &'static str, a: usize, b: usize) -> Result<usize, String> { + let n = self.len(a) + op.len() + self.len(b); + self.add(Node::Binary(op, a, b), n) + } + + fn prefix(&mut self, op: &'static str, a: usize) -> Result<usize, String> { + let n = op.len() + self.len(a); + self.add(Node::Prefix(op, a), n) + } + + fn suffix(&mut self, op: &'static str, a: usize) -> Result<usize, String> { + let n = self.len(a) + op.len(); + self.add(Node::Suffix(op, a), n) + } + + fn paren(&mut self, a: usize) -> Result<usize, String> { + let n = self.len(a) + 2; + self.add(Node::Paren(a), n) + } + + fn call(&mut self, name: String, args: Vec<usize>) -> Result<usize, String> { + let n = name.len() + + 2 + + args.len().saturating_sub(1) + + args.iter().map(|&a| self.len(a)).sum::<usize>(); + self.add(Node::Call(name, args), n) + } + + /// The text of node `root`. Iterative: a hostile formula nests as deep as + /// it has tokens. + fn write(&self, root: usize) -> String { + enum Piece<'a> { + Node(usize), + Lit(&'a str), + } + let mut out = String::with_capacity(self.len(root)); + let mut work = vec![Piece::Node(root)]; + while let Some(p) = work.pop() { + match p { + Piece::Lit(s) => out.push_str(s), + Piece::Node(i) => match &self.nodes[i].0 { + Node::Text(s) => out.push_str(s), + Node::Binary(op, a, b) => { + work.push(Piece::Node(*b)); + work.push(Piece::Lit(op)); + work.push(Piece::Node(*a)); + } + Node::Prefix(op, a) => { + work.push(Piece::Node(*a)); + work.push(Piece::Lit(op)); + } + Node::Suffix(op, a) => { + work.push(Piece::Lit(op)); + work.push(Piece::Node(*a)); + } + Node::Paren(a) => { + work.push(Piece::Lit(")")); + work.push(Piece::Node(*a)); + work.push(Piece::Lit("(")); + } + Node::Call(name, args) => { + work.push(Piece::Lit(")")); + for (k, &a) in args.iter().enumerate().rev() { + work.push(Piece::Node(a)); + if k > 0 { + work.push(Piece::Lit(",")); + } + } + work.push(Piece::Lit("(")); + work.push(Piece::Lit(name)); + } + }, + } + } + out + } +} + +fn pop(st: &mut Vec<usize>) -> Result<usize, String> { + st.pop().ok_or_else(|| "stack underflow".to_string()) +} + +/// Decodes `rgce` (and `extra`, the data its array constants keep after +/// it). `row` and `col` are the formula's cell, which relative references +/// in shared formulas, conditional formats and validations are offsets +/// from. +pub fn decode(ctx: &Ctx, rgce: &[u8], extra: &[u8], row: u32, col: u32) -> Result<Decoded, String> { + decode_with(ctx, rgce, extra, row, col, false) +} + +/// A conditional format's or a validation's formula: its references are +/// offsets from (`row`, `col`), the top left of its ranges (the "method B" +/// of OpenOffice.org's documentation, 3.9), whether written as RefN and +/// AreaN tokens or as Ref, Area, Ref3d and Area3d ones with the relative +/// flags set. +pub fn decode_relative(ctx: &Ctx, rgce: &[u8], row: u32, col: u32) -> Result<Decoded, String> { + decode_with(ctx, rgce, &[], row, col, true) +} + +/// What [`decode_partial`] could write of a formula. +pub struct Partial { + /// The operands built before the formula was refused, in order and + /// separated by a space: whole calls and constants, never a half of one. + pub text: String, + /// Why the rest was not read: `None` for a formula read to its end. + pub error: Option<String>, +} + +/// Like [`decode`], but keeps what was read before a token it cannot read: +/// the scanner wants every function and string of a formula a writer made +/// unreadable on purpose. +pub fn decode_partial(ctx: &Ctx, rgce: &[u8], extra: &[u8], row: u32, col: u32) -> Partial { + let mut t = Tree::default(); + let mut st: Vec<usize> = Vec::new(); + let error = match walk(ctx, rgce, extra, row, col, false, &mut t, &mut st) { + // A reference to a shared formula or a data table: no text of its own. + Ok(Some(_)) => { + return Partial { + text: String::new(), + error: None, + }; + } + Ok(None) if st.len() == 1 => None, + Ok(None) => Some("operands left over".to_string()), + Err(e) => Some(e), + }; + let mut text = String::new(); + for &i in &st { + if text.len() + t.len(i) > MAX_TEXT { + break; + } + if !text.is_empty() { + text.push(' '); + } + text.push_str(&t.write(i)); + } + Partial { text, error } +} + +fn decode_with( + ctx: &Ctx, + rgce: &[u8], + extra: &[u8], + row: u32, + col: u32, + relative: bool, +) -> Result<Decoded, String> { + let mut t = Tree::default(); + let mut st: Vec<usize> = Vec::new(); + if let Some(d) = walk(ctx, rgce, extra, row, col, relative, &mut t, &mut st)? { + return Ok(d); + } + let root = st.pop().ok_or("empty formula")?; + if !st.is_empty() { + return Err("operands left over".into()); + } + Ok(Decoded { + text: t.write(root), + exp: None, + table: false, + }) +} + +/// Reads the tokens onto `st`. `Some` is a formula that is only a reference +/// to a shared formula or a data table; `None`, tokens read to the end. +#[allow(clippy::too_many_arguments)] +fn walk( + ctx: &Ctx, + rgce: &[u8], + extra: &[u8], + row: u32, + col: u32, + relative: bool, + t: &mut Tree, + st: &mut Vec<usize>, +) -> Result<Option<Decoded>, String> { + let ver = ctx.ver; + let mut c = Cur::new(rgce); + let mut ex = Cur::new(extra); + let w2 = if ver >= 3 { 2 } else { 1 }; + while c.left() > 0 { + let ptg = c.u8(); + let base = if ptg >= 0x20 { + (ptg & 0x1F) | 0x20 + } else { + ptg + }; + match base { + 0x01 => { + let r = c.u16() as u32; + let cc = if ver >= 3 { + c.u16() as u32 + } else { + c.u8() as u32 + }; + return Ok(Some(Decoded { + text: String::new(), + exp: Some((r, cc)), + table: false, + })); + } + 0x02 => { + return Ok(Some(Decoded { + text: String::new(), + exp: None, + table: true, + })); + } + 0x03..=0x11 => { + let b = pop(st)?; + let a = pop(st)?; + let op = match base { + 0x03 => "+", + 0x04 => "-", + 0x05 => "*", + 0x06 => "/", + 0x07 => "^", + 0x08 => "&", + 0x09 => "<", + 0x0A => "<=", + 0x0B => "=", + 0x0C => ">=", + 0x0D => ">", + 0x0E => "<>", + 0x0F => " ", + 0x10 => ",", + _ => ":", + }; + st.push(t.binary(op, a, b)?); + } + 0x12 => { + let a = pop(st)?; + st.push(t.prefix("+", a)?); + } + 0x13 => { + let a = pop(st)?; + st.push(t.prefix("-", a)?); + } + 0x14 => { + let a = pop(st)?; + st.push(t.suffix("%", a)?); + } + 0x15 => { + let a = pop(st)?; + st.push(t.paren(a)?); + } + 0x16 => st.push(t.text("")?), + 0x17 => { + let s = if ver >= 8 { + read_short_unicode(&mut c) + } else { + read_byte_string(&mut c, ctx.codepage) + }; + st.push(t.text(format!("\"{}\"", s.replace('"', "\"\"")))?); + } + 0x18 => return Err("extended token".into()), + 0x19 => { + let grbit = c.u8(); + if grbit & 0x04 != 0 { + // attrChoose: a jump table of its argument count plus one. + let n = if ver >= 3 { + c.u16() as usize + } else { + c.u8() as usize + }; + c.skip((n + 1) * w2); + } else if grbit & 0x10 != 0 { + c.skip(w2); + let a = pop(st)?; + st.push(t.call("SUM".into(), vec![a])?); + } else { + c.skip(w2); + } + } + 0x1C => st.push(t.text(error_or_na(c.u8()))?), + 0x1D => st.push(t.text(if c.u8() != 0 { "TRUE" } else { "FALSE" })?), + 0x1E => st.push(t.text(c.u16().to_string())?), + 0x1F => st.push(t.text(number(c.f64()))?), + 0x20 => { + c.skip(if ver >= 5 { 7 } else { 6 }); + st.push(t.text(read_array(&mut ex, ver, ctx.codepage))?); + } + 0x21 => { + let i = if ver >= 4 { c.u16() } else { u16::from(c.u8()) }; + let (name, n) = ftab(i).ok_or_else(|| format!("unknown function {i}"))?; + let n = match (n, old_fixed(i, ver)) { + (_, Some(k)) => k, + (n, None) if n >= 0 => n as usize, + _ => return Err(format!("variable function {name} in PtgFunc")), + }; + if st.len() < n { + return Err("stack underflow".into()); + } + let args = st.split_off(st.len() - n); + st.push(t.call(name.to_string(), args)?); + } + 0x22 => { + let argc = (c.u8() & 0x7F) as usize; + let i = if ver >= 4 { c.u16() } else { u16::from(c.u8()) }; + let ce = i & 0x8000 != 0; + let i = i & 0x7FFF; + if st.len() < argc { + return Err("stack underflow".into()); + } + let mut args = st.split_off(st.len() - argc); + let name = if ce { + // An Excel 4.0 macro command, by its name where it has one. + ftab(i | 0x8000).map_or_else(|| format!("CMD{i}"), |f| f.0.to_string()) + } else if i == 255 { + // An add-in or newer function: its name is the first operand. + if args.is_empty() { + return Err("add-in call without a name".into()); + } + let n = t.write(args.remove(0)); + let bare = n.strip_prefix("_xlfn.").unwrap_or(&n).to_string(); + if XL2007.contains(&bare.to_ascii_uppercase().as_str()) { + bare + } else { + n + } + } else { + ftab(i) + .map(|f| f.0.to_string()) + .ok_or_else(|| format!("unknown function {i}"))? + }; + st.push(t.call(name, args)?); + } + 0x23 => { + let idx = if ver >= 8 { + c.u32() as usize + } else if ver == 5 { + let i = c.u16(); + c.skip(12); + i as usize + } else { + let i = c.u16(); + c.skip(if ver == 4 { 10 } else { 5 }); + i as usize + }; + st.push( + t.text( + ctx.names + .get(idx.wrapping_sub(1)) + .map_or("#NAME?", String::as_str), + )?, + ); + } + 0x24 | 0x2C => { + let l = loc(&mut c, ver, row, col, relative || base == 0x2C); + st.push(t.text(a1(l))?); + } + 0x25 | 0x2D => { + let a = area(&mut c, ver, row, col, relative || base == 0x2D); + st.push(t.text(a)?); + } + 0x26..=0x28 => c.skip(if ver >= 5 { 6 } else { 5 }), + 0x29 => c.skip(w2), + 0x2A => { + c.skip(if ver >= 8 { 4 } else { 3 }); + st.push(t.text("#REF!")?); + } + 0x2B => { + c.skip(if ver >= 8 { 8 } else { 6 }); + st.push(t.text("#REF!")?); + } + 0x39 => { + let text = if ver >= 8 { + let ixti = c.u16() as usize; + let ni = c.u32() as usize; + name_x(ctx, ixti, ni) + } else { + let ixals = c.i16(); + c.skip(8); + let ni = c.u16() as usize; + c.skip(12); + if ixals < 0 { + ctx.names + .get(ni.wrapping_sub(1)) + .cloned() + .unwrap_or_else(|| "#NAME?".into()) + } else { + match ctx.ext5.get((ixals as usize).wrapping_sub(1)) { + Some(ExtSheet5::Names(n)) => n + .get(ni.wrapping_sub(1)) + .cloned() + .unwrap_or_else(|| "#NAME?".into()), + _ => "#NAME?".into(), + } + } + }; + st.push(t.text(text)?); + } + 0x3A..=0x3D => { + let prefix = sheet_prefix(ctx, &mut c); + let text = match base { + 0x3A => format!("{prefix}{}", a1(loc(&mut c, ver, row, col, relative))), + 0x3B => format!("{prefix}{}", area(&mut c, ver, row, col, relative)), + 0x3C => { + c.skip(if ver >= 8 { 4 } else { 3 }); + "#REF!".into() + } + _ => { + c.skip(if ver >= 8 { 8 } else { 6 }); + "#REF!".into() + } + }; + st.push(t.text(text)?); + } + _ => return Err(format!("unknown token {ptg:#x}")), + } + if c.short { + return Err("truncated formula".into()); + } + } + Ok(None) +} + +fn name_x(ctx: &Ctx, ixti: usize, ni: usize) -> String { + let Some(&(sb, _, _)) = ctx.xti.get(ixti) else { + return "#NAME?".into(); + }; + let Some(book) = ctx.supbooks.get(sb as usize) else { + return "#NAME?".into(); + }; + if book.internal { + return ctx + .names + .get(ni.wrapping_sub(1)) + .cloned() + .unwrap_or_else(|| "#NAME?".into()); + } + let n = book + .names + .get(ni.wrapping_sub(1)) + .cloned() + .unwrap_or_else(|| "#NAME?".into()); + if book.addin { + n + } else { + format!("[{}]!{n}", book.ext_index) + } +} + +fn sheet_prefix(ctx: &Ctx, c: &mut Cur) -> String { + let range = |names: &[String], first: i64, last: i64, ext: Option<usize>| -> String { + let get = + |i: i64| -> Option<&String> { usize::try_from(i).ok().and_then(|i| names.get(i)) }; + let prefix = ext.map(|n| format!("[{n}]")).unwrap_or_default(); + match (get(first), get(last)) { + (Some(a), Some(_)) if first == last => match ext { + Some(n) => format!("{}!", quote_external(n, a)), + None => format!("{}!", quote_sheet(a)), + }, + (Some(a), Some(b)) if quote_sheet(a) == *a && quote_sheet(b) == *b => { + format!("{prefix}{a}:{b}!") + } + (Some(a), Some(b)) => format!( + "'{prefix}{}:{}'!", + a.replace('\'', "''"), + b.replace('\'', "''") + ), + _ => "#REF!".into(), + } + }; + if ctx.ver >= 8 { + let ixti = c.u16() as usize; + let Some(&(sb, first, last)) = ctx.xti.get(ixti) else { + return "#REF!".into(); + }; + match ctx.supbooks.get(sb as usize) { + Some(b) if !b.internal => { + range(&b.sheets, first.into(), last.into(), Some(b.ext_index)) + } + _ => range(ctx.sheets, first.into(), last.into(), None), + } + } else { + let ixals = c.i16(); + c.skip(8); + let first = c.i16(); + let last = c.i16(); + if ixals < 0 { + range(ctx.sheets, first.into(), last.into(), None) + } else { + match ctx.ext5.get((ixals as usize).wrapping_sub(1)) { + Some(ExtSheet5::External { doc, sheet }) => { + format!("{}!", quote_external(*doc, sheet)) + } + _ => "#REF!".into(), + } + } + } +} + +/// A cell location with its relative flags. +#[derive(Clone, Copy)] +struct Loc { + r: u32, + c: u32, + rrel: bool, + crel: bool, +} + +fn maxrow(ver: u8) -> i64 { + if ver >= 8 { 0x10000 } else { 0x4000 } +} + +impl Loc { + /// BIFF8: a row, and a column word with the relative flags. + fn biff8(r: u32, cw: u16) -> Loc { + Loc { + r, + c: (cw & 0x3FFF) as u32, + rrel: cw & 0x8000 != 0, + crel: cw & 0x4000 != 0, + } + } + + /// BIFF2 to BIFF7: a row word with the relative flags, and a column. + fn biff5(rw: u16, c: u32) -> Loc { + Loc { + r: (rw & 0x3FFF) as u32, + c, + rrel: rw & 0x8000 != 0, + crel: rw & 0x4000 != 0, + } + } +} + +/// `offsets`: relative parts are offsets from (row, col), as in RefN. +fn resolve(l: Loc, row: u32, col: u32, offsets: bool, ver: u8) -> Loc { + let mut out = l; + if offsets { + if l.rrel { + let off = if ver >= 8 { + i64::from(l.r as u16 as i16) + } else { + (i64::from(l.r) << 50) >> 50 + }; + out.r = (i64::from(row) + off).rem_euclid(maxrow(ver)) as u32; + } + if l.crel { + let off = i64::from(l.c as u8 as i8); + out.c = (i64::from(col) + off).rem_euclid(256) as u32; + } + } + out +} + +fn loc(c: &mut Cur, ver: u8, row: u32, col: u32, offsets: bool) -> Loc { + let l = if ver >= 8 { + let r = c.u16() as u32; + Loc::biff8(r, c.u16()) + } else { + let rw = c.u16(); + Loc::biff5(rw, c.u8() as u32) + }; + resolve(l, row, col, offsets, ver) +} + +fn a1(l: Loc) -> String { + format!( + "{}{}{}{}", + if l.crel { "" } else { "$" }, + col_name(l.c), + if l.rrel { "" } else { "$" }, + u64::from(l.r) + 1 + ) +} + +fn area(c: &mut Cur, ver: u8, row: u32, col: u32, offsets: bool) -> String { + let (a, b) = if ver >= 8 { + let r1 = c.u16() as u32; + let r2 = c.u16() as u32; + let c1 = c.u16(); + let c2 = c.u16(); + ( + resolve(Loc::biff8(r1, c1), row, col, offsets, ver), + resolve(Loc::biff8(r2, c2), row, col, offsets, ver), + ) + } else { + let r1 = c.u16(); + let r2 = c.u16(); + let c1 = c.u8() as u32; + let c2 = c.u8() as u32; + ( + resolve(Loc::biff5(r1, c1), row, col, offsets, ver), + resolve(Loc::biff5(r2, c2), row, col, offsets, ver), + ) + }; + let lastrow = if ver >= 8 { 0xFFFF } else { 0x3FFF }; + // Whole columns and rows, as Excel writes them. + if a.r == 0 && b.r == lastrow && !a.rrel && !b.rrel { + return format!( + "{}{}:{}{}", + if a.crel { "" } else { "$" }, + col_name(a.c), + if b.crel { "" } else { "$" }, + col_name(b.c) + ); + } + if a.c == 0 && b.c == 0xFF && !a.crel && !b.crel { + return format!( + "{}{}:{}{}", + if a.rrel { "" } else { "$" }, + u64::from(a.r) + 1, + if b.rrel { "" } else { "$" }, + u64::from(b.r) + 1 + ); + } + format!("{}:{}", a1(a), a1(b)) +} + +fn read_short_unicode(c: &mut Cur) -> String { + let n = c.u8() as usize; + let flags = c.u8(); + read_chars(c, n, flags & 1 != 0) +} + +/// `n` characters, two bytes each or one byte each (Latin-1). +pub fn read_chars(c: &mut Cur, n: usize, wide: bool) -> String { + if wide { + let b = c.bytes(n.saturating_mul(2)); + let u: Vec<u16> = b + .as_chunks::<2>() + .0 + .iter() + .map(|x| u16::from_le_bytes(*x)) + .collect(); + String::from_utf16_lossy(&u) + } else { + c.bytes(n).iter().map(|&b| b as char).collect() + } +} + +fn read_byte_string(c: &mut Cur, cp: u16) -> String { + let n = c.u8() as usize; + charset::decode(c.bytes(n), cp) +} + +/// Columns less one, then rows less one, in every version; strings differ. +fn read_array(ex: &mut Cur, ver: u8, cp: u16) -> String { + let (cols, rows) = (ex.u8() as usize + 1, ex.u16() as usize + 1); + let mut out = String::from("{"); + 'rows: for r in 0..rows { + if r > 0 { + out.push(';'); + } + for k in 0..cols { + if k > 0 { + out.push(','); + } + match ex.u8() { + 0x01 => out.push_str(&number(ex.f64())), + 0x02 => { + let s = if ver >= 8 { + let n = ex.u16() as usize; + let f = ex.u8(); + read_chars(ex, n, f & 1 != 0) + } else { + let n = ex.u8() as usize; + charset::decode(ex.bytes(n), cp) + }; + out.push('"'); + out.push_str(&s.replace('"', "\"\"")); + out.push('"'); + } + 0x04 => { + let b = ex.u8(); + ex.skip(7); + out.push_str(if b != 0 { "TRUE" } else { "FALSE" }); + } + 0x10 => { + let e = ex.u8(); + ex.skip(7); + out.push_str(error_or_na(e)); + } + _ => ex.skip(8), + } + if ex.short || out.len() > MAX_TEXT { + break 'rows; + } + } + } + out.push('}'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> Ctx<'static> { + Ctx { + ver: 8, + sheets: &[], + names: &[], + xti: &[], + supbooks: &[], + ext5: &[], + codepage: 1252, + } + } + + #[test] + fn decodes_operators_functions_and_constants() { + // =SUM(A1:B2)*2 with an attrSum, then <> TRUE, as Excel stores it. + let mut r = vec![0x25, 0, 0, 1, 0, 0, 0xC0, 1, 0xC0]; + r.extend_from_slice(&[0x19, 0x10, 0, 0]); + r.extend_from_slice(&[0x1E, 2, 0, 0x05]); + r.extend_from_slice(&[0x1D, 1, 0x0E]); + let d = decode(&ctx(), &r, &[], 0, 0).unwrap(); + assert_eq!(d.text, "SUM(A1:B2)*2<>TRUE"); + // #N/A stays an error constant. + assert_eq!( + decode(&ctx(), &[0x1C, 0x2A], &[], 0, 0).unwrap().text, + "#N/A" + ); + // ROUND(1.5,0): a fixed-argument function. + let mut r = vec![0x1F]; + r.extend_from_slice(&1.5f64.to_le_bytes()); + r.extend_from_slice(&[0x1E, 0, 0, 0x21, 27, 0]); + assert_eq!(decode(&ctx(), &r, &[], 0, 0).unwrap().text, "ROUND(1.5,0)"); + } + + #[test] + fn an_excel_4_macro_call_is_written_as_the_formula_it_is() { + // ALERT("hi"): the string, then a PtgFuncVar of one argument whose id + // has the command bit. + let mut r = vec![0x17, 2, 0, b'h', b'i']; + r.extend_from_slice(&[0x42, 1, 0x76, 0x80]); + assert_eq!(decode(&ctx(), &r, &[], 0, 0).unwrap().text, "ALERT(\"hi\")"); + // HALT(): no arguments. + let r = [0x42, 0, 54, 0]; + assert_eq!(decode(&ctx(), &r, &[], 0, 0).unwrap().text, "HALT()"); + } + + #[test] + fn relative_offsets_wrap() { + // RefN one row up and one column left of B2. + let r = [0x2C, 0xFF, 0xFF, 0xFF, 0xC0]; + assert_eq!(decode(&ctx(), &r, &[], 1, 1).unwrap().text, "A1"); + } + + #[test] + fn conditional_formats_count_from_their_range() { + // An Area, relative, rows -1 to +1 and column +3 from B2: E1:E3. + // Read as a cell formula, the same token names rows 65536 and 2. + let r = [0x25, 0xFF, 0xFF, 1, 0, 3, 0xC0, 3, 0xC0]; + assert_eq!(decode_relative(&ctx(), &r, 1, 1).unwrap().text, "E1:E3"); + assert_eq!(decode(&ctx(), &r, &[], 1, 1).unwrap().text, "D65536:D2"); + // Absolute parts stay where they are. + let r = [0x24, 4, 0, 2, 0x00]; + assert_eq!(decode_relative(&ctx(), &r, 1, 1).unwrap().text, "$C$5"); + } + + #[test] + fn hostile_formulas_are_refused_not_panicking() { + assert!(decode(&ctx(), &[0x03], &[], 0, 0).is_err()); + assert!(decode(&ctx(), &[0x22, 0x7F, 0xFF, 0x00], &[], 0, 0).is_err()); + // A long chain of concatenations stops at the text limit. + let mut r = vec![0x17, 200, 0]; + r.extend(std::iter::repeat_n(b'x', 200)); + for _ in 0..200 { + r.extend_from_slice(&[0x17, 200, 0]); + r.extend(std::iter::repeat_n(b'x', 200)); + r.push(0x08); + } + assert_eq!( + decode(&ctx(), &r, &[], 0, 0).err().as_deref(), + Some("formula too long") + ); + assert!( + decode( + &ctx(), + &[0x20, 0, 0, 0, 0, 0, 0, 0], + &[0xFF, 0xFF, 0xFF], + 0, + 0 + ) + .is_ok() + ); + } + + #[test] + #[cfg_attr(target_family = "wasm", ignore = "needs a thread")] + fn a_chain_of_operators_is_written_without_recursion() { + // 1 followed by 16000 percent signs, in a thread whose stack could + // not hold that many nested calls. + let mut r = vec![0x1E, 1, 0]; + r.extend(std::iter::repeat_n(0x14u8, 16_000)); + let text = std::thread::Builder::new() + .stack_size(128 * 1024) + .spawn(move || decode(&ctx(), &r, &[], 0, 0).unwrap().text) + .unwrap() + .join() + .unwrap(); + assert_eq!(text.len(), 16_001); + assert!(text.starts_with("1%%") && text.ends_with("%%")); + } + + #[test] + fn biff2_fixed_counts() { + let c = Ctx { ver: 2, ..ctx() }; + // FIXED(1,2) with a BIFF2 PtgFunc. + let r = [0x1E, 1, 0, 0x1E, 2, 0, 0x21, 14]; + assert_eq!(decode(&c, &r, &[], 0, 0).unwrap().text, "FIXED(1,2)"); + } + + #[test] + fn numbers() { + assert_eq!(number(0.1 + 0.2), "0.3"); + assert_eq!(number(-2.0), "-2"); + assert_eq!(number(1e20), "1E+20"); + assert_eq!(number(0.000012), "0.000012"); + assert_eq!(number(1.5e-7), "1.5E-7"); + assert_eq!(number(f64::NAN), "0"); + } + + #[test] + fn columns_are_named_in_letters() { + assert_eq!(col_name(0), "A"); + assert_eq!(col_name(25), "Z"); + assert_eq!(col_name(26), "AA"); + assert_eq!(col_name(16383), "XFD"); + } +} diff --git a/crates/exav-unpack/src/formats/biff/ftab.rs b/crates/exav-unpack/src/formats/biff/ftab.rs new file mode 100644 index 00000000..f9cb736d --- /dev/null +++ b/crates/exav-unpack/src/formats/biff/ftab.rs @@ -0,0 +1,820 @@ +//! The built-in functions that `ptgFunc` and `ptgFuncVar` formula tokens name +//! by number (`EXEC`, `CALL` and the rest of an Excel 4.0 macro sheet are +//! numbers in the file, invisible to a scan of its bytes). + +/// (function id, name, argument count when it is fixed, else -1), sorted by id. +/// Ids with the 0x8000 bit are the command equivalents of Excel 4.0 macros. +#[rustfmt::skip] +const FUNCTIONS: &[(u16, &str, i8)] = &[ + (0, "COUNT", -1), + (1, "IF", -1), + (2, "ISNA", 1), + (3, "ISERROR", 1), + (4, "SUM", -1), + (5, "AVERAGE", -1), + (6, "MIN", -1), + (7, "MAX", -1), + (8, "ROW", -1), + (9, "COLUMN", -1), + (10, "NA", 0), + (11, "NPV", -1), + (12, "STDEV", -1), + (13, "DOLLAR", -1), + (14, "FIXED", -1), + (15, "SIN", 1), + (16, "COS", 1), + (17, "TAN", 1), + (18, "ATAN", 1), + (19, "PI", 0), + (20, "SQRT", 1), + (21, "EXP", 1), + (22, "LN", 1), + (23, "LOG10", 1), + (24, "ABS", 1), + (25, "INT", 1), + (26, "SIGN", 1), + (27, "ROUND", 2), + (28, "LOOKUP", -1), + (29, "INDEX", -1), + (30, "REPT", 2), + (31, "MID", 3), + (32, "LEN", 1), + (33, "VALUE", 1), + (34, "TRUE", 0), + (35, "FALSE", 0), + (36, "AND", -1), + (37, "OR", -1), + (38, "NOT", 1), + (39, "MOD", 2), + (40, "DCOUNT", 3), + (41, "DSUM", 3), + (42, "DAVERAGE", 3), + (43, "DMIN", 3), + (44, "DMAX", 3), + (45, "DSTDEV", 3), + (46, "VAR", -1), + (47, "DVAR", 3), + (48, "TEXT", 2), + (49, "LINEST", -1), + (50, "TREND", -1), + (51, "LOGEST", -1), + (52, "GROWTH", -1), + (53, "GOTO", 1), + (54, "HALT", -1), + (55, "RETURN", -1), + (56, "PV", -1), + (57, "FV", -1), + (58, "NPER", -1), + (59, "PMT", -1), + (60, "RATE", -1), + (61, "MIRR", 3), + (62, "IRR", -1), + (63, "RAND", 0), + (64, "MATCH", -1), + (65, "DATE", 3), + (66, "TIME", 3), + (67, "DAY", 1), + (68, "MONTH", 1), + (69, "YEAR", 1), + (70, "WEEKDAY", -1), + (71, "HOUR", 1), + (72, "MINUTE", 1), + (73, "SECOND", 1), + (74, "NOW", 0), + (75, "AREAS", 1), + (76, "ROWS", 1), + (77, "COLUMNS", 1), + (78, "OFFSET", -1), + (79, "ABSREF", 2), + (80, "RELREF", 2), + (81, "ARGUMENT", -1), + (82, "SEARCH", -1), + (83, "TRANSPOSE", 1), + (84, "ERROR", -1), + (85, "STEP", 0), + (86, "TYPE", 1), + (87, "ECHO", -1), + (88, "SET.NAME", -1), + (89, "CALLER", 0), + (90, "DEREF", 1), + (91, "WINDOWS", -1), + (92, "SERIES", -1), + (93, "DOCUMENTS", -1), + (94, "ACTIVE.CELL", 0), + (95, "SELECTION", 0), + (96, "RESULT", -1), + (97, "ATAN2", 2), + (98, "ASIN", 1), + (99, "ACOS", 1), + (100, "CHOOSE", -1), + (101, "HLOOKUP", -1), + (102, "VLOOKUP", -1), + (103, "LINKS", -1), + (104, "INPUT", -1), + (105, "ISREF", 1), + (106, "GET.FORMULA", 1), + (107, "GET.NAME", -1), + (108, "SET.VALUE", 2), + (109, "LOG", -1), + (110, "EXEC", -1), + (111, "CHAR", 1), + (112, "LOWER", 1), + (113, "UPPER", 1), + (114, "PROPER", 1), + (115, "LEFT", -1), + (116, "RIGHT", -1), + (117, "EXACT", 2), + (118, "TRIM", 1), + (119, "REPLACE", 4), + (120, "SUBSTITUTE", -1), + (121, "CODE", 1), + (122, "NAMES", -1), + (123, "DIRECTORY", -1), + (124, "FIND", -1), + (125, "CELL", -1), + (126, "ISERR", 1), + (127, "ISTEXT", 1), + (128, "ISNUMBER", 1), + (129, "ISBLANK", 1), + (130, "T", 1), + (131, "N", 1), + (132, "FOPEN", -1), + (133, "FCLOSE", 1), + (134, "FSIZE", 1), + (135, "FREADLN", 1), + (136, "FREAD", 2), + (137, "FWRITELN", 2), + (138, "FWRITE", 2), + (139, "FPOS", -1), + (140, "DATEVALUE", 1), + (141, "TIMEVALUE", 1), + (142, "SLN", 3), + (143, "SYD", 4), + (144, "DDB", -1), + (145, "GET.DEF", -1), + (146, "REFTEXT", -1), + (147, "TEXTREF", -1), + (148, "INDIRECT", -1), + (149, "REGISTER", -1), + (150, "CALL", -1), + (151, "ADD.BAR", -1), + (152, "ADD.MENU", -1), + (153, "ADD.COMMAND", -1), + (154, "ENABLE.COMMAND", -1), + (155, "CHECK.COMMAND", -1), + (156, "RENAME.COMMAND", -1), + (157, "SHOW.BAR", -1), + (158, "DELETE.MENU", -1), + (159, "DELETE.COMMAND", -1), + (160, "GET.CHART.ITEM", -1), + (161, "DIALOG.BOX", 1), + (162, "CLEAN", 1), + (163, "MDETERM", 1), + (164, "MINVERSE", 1), + (165, "MMULT", 2), + (166, "FILES", -1), + (167, "IPMT", -1), + (168, "PPMT", -1), + (169, "COUNTA", -1), + (170, "CANCEL.KEY", -1), + (171, "FOR", -1), + (172, "WHILE", 1), + (173, "BREAK", 0), + (174, "NEXT", 0), + (175, "INITIATE", 2), + (176, "REQUEST", 2), + (177, "POKE", 3), + (178, "EXECUTE", 2), + (179, "TERMINATE", 1), + (180, "RESTART", -1), + (181, "HELP", -1), + (182, "GET.BAR", -1), + (183, "PRODUCT", -1), + (184, "FACT", 1), + (185, "GET.CELL", -1), + (186, "GET.WORKSPACE", 1), + (187, "GET.WINDOW", -1), + (188, "GET.DOCUMENT", -1), + (189, "DPRODUCT", 3), + (190, "ISNONTEXT", 1), + (191, "GET.NOTE", -1), + (192, "NOTE", -1), + (193, "STDEVP", -1), + (194, "VARP", -1), + (195, "DSTDEVP", 3), + (196, "DVARP", 3), + (197, "TRUNC", -1), + (198, "ISLOGICAL", 1), + (199, "DCOUNTA", 3), + (200, "DELETE.BAR", 1), + (201, "UNREGISTER", 1), + (204, "USDOLLAR", -1), + (205, "FINDB", -1), + (206, "SEARCHB", -1), + (207, "REPLACEB", 4), + (208, "LEFTB", -1), + (209, "RIGHTB", -1), + (210, "MIDB", 3), + (211, "LENB", 1), + (212, "ROUNDUP", 2), + (213, "ROUNDDOWN", 2), + (214, "ASC", 1), + (215, "DBCS", 1), + (216, "RANK", -1), + (219, "ADDRESS", -1), + (220, "DAYS360", -1), + (221, "TODAY", 0), + (222, "VDB", -1), + (223, "ELSE", 0), + (224, "ELSE.IF", 1), + (225, "END.IF", 0), + (226, "FOR.CELL", -1), + (227, "MEDIAN", -1), + (228, "SUMPRODUCT", -1), + (229, "SINH", 1), + (230, "COSH", 1), + (231, "TANH", 1), + (232, "ASINH", 1), + (233, "ACOSH", 1), + (234, "ATANH", 1), + (235, "DGET", 3), + (236, "CREATE.OBJECT", -1), + (237, "VOLATILE", -1), + (238, "LAST.ERROR", 0), + (239, "CUSTOM.UNDO", -1), + (240, "CUSTOM.REPEAT", -1), + (241, "FORMULA.CONVERT", -1), + (242, "GET.LINK.INFO", -1), + (243, "TEXT.BOX", -1), + (244, "INFO", 1), + (245, "GROUP", 0), + (246, "GET.OBJECT", -1), + (247, "DB", -1), + (248, "PAUSE", -1), + (251, "RESUME", -1), + (252, "FREQUENCY", 2), + (253, "ADD.TOOLBAR", -1), + (254, "DELETE.TOOLBAR", 1), + (255, "User Defined Function", -1), + (256, "RESET.TOOLBAR", 1), + (257, "EVALUATE", 1), + (258, "GET.TOOLBAR", -1), + (259, "GET.TOOL", -1), + (260, "SPELLING.CHECK", -1), + (261, "ERROR.TYPE", 1), + (262, "APP.TITLE", -1), + (263, "WINDOW.TITLE", -1), + (264, "SAVE.TOOLBAR", -1), + (265, "ENABLE.TOOL", 3), + (266, "PRESS.TOOL", 3), + (267, "REGISTER.ID", -1), + (268, "GET.WORKBOOK", -1), + (269, "AVEDEV", -1), + (270, "BETADIST", -1), + (271, "GAMMALN", 1), + (272, "BETAINV", -1), + (273, "BINOMDIST", 4), + (274, "CHIDIST", 2), + (275, "CHIINV", 2), + (276, "COMBIN", 2), + (277, "CONFIDENCE", 3), + (278, "CRITBINOM", 3), + (279, "EVEN", 1), + (280, "EXPONDIST", 3), + (281, "FDIST", 3), + (282, "FINV", 3), + (283, "FISHER", 1), + (284, "FISHERINV", 1), + (285, "FLOOR", 2), + (286, "GAMMADIST", 4), + (287, "GAMMAINV", 3), + (288, "CEILING", 2), + (289, "HYPGEOMDIST", 4), + (290, "LOGNORMDIST", 3), + (291, "LOGINV", 3), + (292, "NEGBINOMDIST", 3), + (293, "NORMDIST", 4), + (294, "NORMSDIST", 1), + (295, "NORMINV", 3), + (296, "NORMSINV", 1), + (297, "STANDARDIZE", 3), + (298, "ODD", 1), + (299, "PERMUT", 2), + (300, "POISSON", 3), + (301, "TDIST", 3), + (302, "WEIBULL", 4), + (303, "SUMXMY2", 2), + (304, "SUMX2MY2", 2), + (305, "SUMX2PY2", 2), + (306, "CHITEST", 2), + (307, "CORREL", 2), + (308, "COVAR", 2), + (309, "FORECAST", 3), + (310, "FTEST", 2), + (311, "INTERCEPT", 2), + (312, "PEARSON", 2), + (313, "RSQ", 2), + (314, "STEYX", 2), + (315, "SLOPE", 2), + (316, "TTEST", 4), + (317, "PROB", -1), + (318, "DEVSQ", -1), + (319, "GEOMEAN", -1), + (320, "HARMEAN", -1), + (321, "SUMSQ", -1), + (322, "KURT", -1), + (323, "SKEW", -1), + (324, "ZTEST", -1), + (325, "LARGE", 2), + (326, "SMALL", 2), + (327, "QUARTILE", 2), + (328, "PERCENTILE", 2), + (329, "PERCENTRANK", -1), + (330, "MODE", -1), + (331, "TRIMMEAN", 2), + (332, "TINV", 2), + (334, "MOVIE.COMMAND", -1), + (335, "GET.MOVIE", -1), + (336, "CONCATENATE", -1), + (337, "POWER", 2), + (338, "PIVOT.ADD.DATA", -1), + (339, "GET.PIVOT.TABLE", -1), + (340, "GET.PIVOT.FIELD", -1), + (341, "GET.PIVOT.ITEM", -1), + (342, "RADIANS", 1), + (343, "DEGREES", 1), + (344, "SUBTOTAL", -1), + (345, "SUMIF", -1), + (346, "COUNTIF", 2), + (347, "COUNTBLANK", 1), + (348, "SCENARIO.GET", -1), + (349, "OPTIONS.LISTS.GET", 1), + (350, "ISPMT", 4), + (351, "DATEDIF", 3), + (352, "DATESTRING", 1), + (353, "NUMBERSTRING", 2), + (354, "ROMAN", -1), + (355, "OPEN.DIALOG", -1), + (356, "SAVE.DIALOG", -1), + (357, "VIEW.GET", -1), + (358, "GETPIVOTDATA", -1), + (359, "HYPERLINK", -1), + (360, "PHONETIC", 1), + (361, "AVERAGEA", -1), + (362, "MAXA", -1), + (363, "MINA", -1), + (364, "STDEVPA", -1), + (365, "VARPA", -1), + (366, "STDEVA", -1), + (367, "VARA", -1), + (368, "BAHTTEXT", 1), + (369, "THAIDAYOFWEEK", 1), + (370, "THAIDIGIT", 1), + (371, "THAIMONTHOFYEAR", 1), + (372, "THAINUMSOUND", 1), + (373, "THAINUMSTRING", 1), + (374, "THAISTRINGLENGTH", 1), + (375, "ISTHAIDIGIT", 1), + (376, "ROUNDBAHTDOWN", 1), + (377, "ROUNDBAHTUP", 1), + (378, "THAIYEAR", 1), + (379, "RTD", -1), + (480, "IFERROR", -1), + (32768, "BEEP", -1), + (32769, "OPEN", -1), + (32770, "OPEN.LINKS", -1), + (32771, "CLOSE.ALL", -1), + (32772, "SAVE", -1), + (32773, "SAVE.AS", -1), + (32774, "FILE.DELETE", -1), + (32775, "PAGE.SETUP", -1), + (32776, "PRINT", -1), + (32777, "PRINTER.SETUP", -1), + (32778, "QUIT", -1), + (32779, "NEW.WINDOW", -1), + (32780, "ARRANGE.ALL", -1), + (32781, "WINDOW.SIZE", -1), + (32782, "WINDOW.MOVE", -1), + (32783, "FULL", -1), + (32784, "CLOSE", -1), + (32785, "RUN", -1), + (32790, "SET.PRINT.AREA", -1), + (32791, "SET.PRINT.TITLES", -1), + (32792, "SET.PAGE.BREAK", -1), + (32793, "REMOVE.PAGE.BREAK", -1), + (32794, "FONT", -1), + (32795, "DISPLAY", -1), + (32796, "PROTECT.DOCUMENT", -1), + (32797, "PRECISION", -1), + (32798, "A1.R1C1", -1), + (32799, "CALCULATE.NOW", -1), + (32800, "CALCULATION", -1), + (32802, "DATA.FIND", -1), + (32803, "EXTRACT", -1), + (32804, "DATA.DELETE", -1), + (32805, "SET.DATABASE", -1), + (32806, "SET.CRITERIA", -1), + (32807, "SORT", -1), + (32808, "DATA.SERIES", -1), + (32809, "TABLE", -1), + (32810, "FORMAT.NUMBER", -1), + (32811, "ALIGNMENT", -1), + (32812, "STYLE", -1), + (32813, "BORDER", -1), + (32814, "CELL.PROTECTION", -1), + (32815, "COLUMN.WIDTH", -1), + (32816, "UNDO", -1), + (32817, "CUT", -1), + (32818, "COPY", -1), + (32819, "PASTE", -1), + (32820, "CLEAR", -1), + (32821, "PASTE.SPECIAL", -1), + (32822, "EDIT.DELETE", -1), + (32823, "INSERT", -1), + (32824, "FILL.RIGHT", -1), + (32825, "FILL.DOWN", -1), + (32829, "DEFINE.NAME", -1), + (32830, "CREATE.NAMES", -1), + (32831, "FORMULA.GOTO", -1), + (32832, "FORMULA.FIND", -1), + (32833, "SELECT.LAST.CELL", -1), + (32834, "SHOW.ACTIVE.CELL", -1), + (32835, "GALLERY.AREA", -1), + (32836, "GALLERY.BAR", -1), + (32837, "GALLERY.COLUMN", -1), + (32838, "GALLERY.LINE", -1), + (32839, "GALLERY.PIE", -1), + (32840, "GALLERY.SCATTER", -1), + (32841, "COMBINATION", -1), + (32842, "PREFERRED", -1), + (32843, "ADD.OVERLAY", -1), + (32844, "GRIDLINES", -1), + (32845, "SET.PREFERRED", -1), + (32846, "AXES", -1), + (32847, "LEGEND", -1), + (32848, "ATTACH.TEXT", -1), + (32849, "ADD.ARROW", -1), + (32850, "SELECT.CHART", -1), + (32851, "SELECT.PLOT.AREA", -1), + (32852, "PATTERNS", -1), + (32853, "MAIN.CHART", -1), + (32854, "OVERLAY", -1), + (32855, "SCALE", -1), + (32856, "FORMAT.LEGEND", -1), + (32857, "FORMAT.TEXT", -1), + (32858, "EDIT.REPEAT", -1), + (32859, "PARSE", -1), + (32860, "JUSTIFY", -1), + (32861, "HIDE", -1), + (32862, "UNHIDE", -1), + (32863, "WORKSPACE", -1), + (32864, "FORMULA", -1), + (32865, "FORMULA.FILL", -1), + (32866, "FORMULA.ARRAY", -1), + (32867, "DATA.FIND.NEXT", -1), + (32868, "DATA.FIND.PREV", -1), + (32869, "FORMULA.FIND.NEXT", -1), + (32870, "FORMULA.FIND.PREV", -1), + (32871, "ACTIVATE", -1), + (32872, "ACTIVATE.NEXT", -1), + (32873, "ACTIVATE.PREV", -1), + (32874, "UNLOCKED.NEXT", -1), + (32875, "UNLOCKED.PREV", -1), + (32876, "COPY.PICTURE", -1), + (32877, "SELECT", -1), + (32878, "DELETE.NAME", -1), + (32879, "DELETE.FORMAT", -1), + (32880, "VLINE", -1), + (32881, "HLINE", -1), + (32882, "VPAGE", -1), + (32883, "HPAGE", -1), + (32884, "VSCROLL", -1), + (32885, "HSCROLL", -1), + (32886, "ALERT", -1), + (32887, "NEW", -1), + (32888, "CANCEL.COPY", -1), + (32889, "SHOW.CLIPBOARD", -1), + (32890, "MESSAGE", -1), + (32892, "PASTE.LINK", -1), + (32893, "APP.ACTIVATE", -1), + (32894, "DELETE.ARROW", -1), + (32895, "ROW.HEIGHT", -1), + (32896, "FORMAT.MOVE", -1), + (32897, "FORMAT.SIZE", -1), + (32898, "FORMULA.REPLACE", -1), + (32899, "SEND.KEYS", -1), + (32900, "SELECT.SPECIAL", -1), + (32901, "APPLY.NAMES", -1), + (32902, "REPLACE.FONT", -1), + (32903, "FREEZE.PANES", -1), + (32904, "SHOW.INFO", -1), + (32905, "SPLIT", -1), + (32906, "ON.WINDOW", -1), + (32907, "ON.DATA", -1), + (32908, "DISABLE.INPUT", -1), + (32910, "OUTLINE", -1), + (32911, "LIST.NAMES", -1), + (32912, "FILE.CLOSE", -1), + (32913, "SAVE.WORKBOOK", -1), + (32914, "DATA.FORM", -1), + (32915, "COPY.CHART", -1), + (32916, "ON.TIME", -1), + (32917, "WAIT", -1), + (32918, "FORMAT.FONT", -1), + (32919, "FILL.UP", -1), + (32920, "FILL.LEFT", -1), + (32921, "DELETE.OVERLAY", -1), + (32923, "SHORT.MENUS", -1), + (32927, "SET.UPDATE.STATUS", -1), + (32929, "COLOR.PALETTE", -1), + (32930, "DELETE.STYLE", -1), + (32931, "WINDOW.RESTORE", -1), + (32932, "WINDOW.MAXIMIZE", -1), + (32934, "CHANGE.LINK", -1), + (32935, "CALCULATE.DOCUMENT", -1), + (32936, "ON.KEY", -1), + (32937, "APP.RESTORE", -1), + (32938, "APP.MOVE", -1), + (32939, "APP.SIZE", -1), + (32940, "APP.MINIMIZE", -1), + (32941, "APP.MAXIMIZE", -1), + (32942, "BRING.TO.FRONT", -1), + (32943, "SEND.TO.BACK", -1), + (32953, "MAIN.CHART.TYPE", -1), + (32954, "OVERLAY.CHART.TYPE", -1), + (32955, "SELECT.END", -1), + (32956, "OPEN.MAIL", -1), + (32957, "SEND.MAIL", -1), + (32958, "STANDARD.FONT", -1), + (32959, "CONSOLIDATE", -1), + (32960, "SORT.SPECIAL", -1), + (32961, "GALLERY.3D.AREA", -1), + (32962, "GALLERY.3D.COLUMN", -1), + (32963, "GALLERY.3D.LINE", -1), + (32964, "GALLERY.3D.PIE", -1), + (32965, "VIEW.3D", -1), + (32966, "GOAL.SEEK", -1), + (32967, "WORKGROUP", -1), + (32968, "FILL.GROUP", -1), + (32969, "UPDATE.LINK", -1), + (32970, "PROMOTE", -1), + (32971, "DEMOTE", -1), + (32972, "SHOW.DETAIL", -1), + (32974, "UNGROUP", -1), + (32975, "OBJECT.PROPERTIES", -1), + (32976, "SAVE.NEW.OBJECT", -1), + (32977, "SHARE", -1), + (32978, "SHARE.NAME", -1), + (32979, "DUPLICATE", -1), + (32980, "APPLY.STYLE", -1), + (32981, "ASSIGN.TO.OBJECT", -1), + (32982, "OBJECT.PROTECTION", -1), + (32983, "HIDE.OBJECT", -1), + (32984, "SET.EXTRACT", -1), + (32985, "CREATE.PUBLISHER", -1), + (32986, "SUBSCRIBE.TO", -1), + (32987, "ATTRIBUTES", -1), + (32988, "SHOW.TOOLBAR", -1), + (32990, "PRINT.PREVIEW", -1), + (32991, "EDIT.COLOR", -1), + (32992, "SHOW.LEVELS", -1), + (32993, "FORMAT.MAIN", -1), + (32994, "FORMAT.OVERLAY", -1), + (32995, "ON.RECALC", -1), + (32996, "EDIT.SERIES", -1), + (32997, "DEFINE.STYLE", -1), + (33008, "LINE.PRINT", -1), + (33011, "ENTER.DATA", -1), + (33017, "GALLERY.RADAR", -1), + (33018, "MERGE.STYLES", -1), + (33019, "EDITION.OPTIONS", -1), + (33020, "PASTE.PICTURE", -1), + (33021, "PASTE.PICTURE.LINK", -1), + (33022, "SPELLING", -1), + (33024, "ZOOM", -1), + (33027, "INSERT.OBJECT", -1), + (33028, "WINDOW.MINIMIZE", -1), + (33033, "SOUND.NOTE", -1), + (33034, "SOUND.PLAY", -1), + (33035, "FORMAT.SHAPE", -1), + (33036, "EXTEND.POLYGON", -1), + (33037, "FORMAT.AUTO", -1), + (33040, "GALLERY.3D.BAR", -1), + (33041, "GALLERY.3D.SURFACE", -1), + (33042, "FILL.AUTO", -1), + (33044, "CUSTOMIZE.TOOLBAR", -1), + (33045, "ADD.TOOL", -1), + (33046, "EDIT.OBJECT", -1), + (33047, "ON.DOUBLECLICK", -1), + (33048, "ON.ENTRY", -1), + (33049, "WORKBOOK.ADD", -1), + (33050, "WORKBOOK.MOVE", -1), + (33051, "WORKBOOK.COPY", -1), + (33052, "WORKBOOK.OPTIONS", -1), + (33053, "SAVE.WORKSPACE", -1), + (33056, "CHART.WIZARD", -1), + (33057, "DELETE.TOOL", -1), + (33058, "MOVE.TOOL", -1), + (33059, "WORKBOOK.SELECT", -1), + (33060, "WORKBOOK.ACTIVATE", -1), + (33061, "ASSIGN.TO.TOOL", -1), + (33063, "COPY.TOOL", -1), + (33064, "RESET.TOOL", -1), + (33065, "CONSTRAIN.NUMERIC", -1), + (33066, "PASTE.TOOL", -1), + (33070, "WORKBOOK.NEW", -1), + (33073, "SCENARIO.CELLS", -1), + (33074, "SCENARIO.DELETE", -1), + (33075, "SCENARIO.ADD", -1), + (33076, "SCENARIO.EDIT", -1), + (33077, "SCENARIO.SHOW", -1), + (33078, "SCENARIO.SHOW.NEXT", -1), + (33079, "SCENARIO.SUMMARY", -1), + (33080, "PIVOT.TABLE.WIZARD", -1), + (33081, "PIVOT.FIELD.PROPERTIES", -1), + (33082, "PIVOT.FIELD", -1), + (33083, "PIVOT.ITEM", -1), + (33084, "PIVOT.ADD.FIELDS", -1), + (33086, "OPTIONS.CALCULATION", -1), + (33087, "OPTIONS.EDIT", -1), + (33088, "OPTIONS.VIEW", -1), + (33089, "ADDIN.MANAGER", -1), + (33090, "MENU.EDITOR", -1), + (33091, "ATTACH.TOOLBARS", -1), + (33092, "VBAActivate", -1), + (33093, "OPTIONS.CHART", -1), + (33096, "VBA.INSERT.FILE", -1), + (33098, "VBA.PROCEDURE.DEFINITION", -1), + (33104, "ROUTING.SLIP", -1), + (33106, "ROUTE.DOCUMENT", -1), + (33107, "MAIL.LOGON", -1), + (33110, "INSERT.PICTURE", -1), + (33111, "EDIT.TOOL", -1), + (33112, "GALLERY.DOUGHNUT", -1), + (33118, "CHART.TREND", -1), + (33120, "PIVOT.ITEM.PROPERTIES", -1), + (33122, "WORKBOOK.INSERT", -1), + (33123, "OPTIONS.TRANSITION", -1), + (33124, "OPTIONS.GENERAL", -1), + (33138, "FILTER.ADVANCED", -1), + (33141, "MAIL.ADD.MAILER", -1), + (33142, "MAIL.DELETE.MAILER", -1), + (33143, "MAIL.REPLY", -1), + (33144, "MAIL.REPLY.ALL", -1), + (33145, "MAIL.FORWARD", -1), + (33146, "MAIL.NEXT.LETTER", -1), + (33147, "DATA.LABEL", -1), + (33148, "INSERT.TITLE", -1), + (33149, "FONT.PROPERTIES", -1), + (33150, "MACRO.OPTIONS", -1), + (33151, "WORKBOOK.HIDE", -1), + (33152, "WORKBOOK.UNHIDE", -1), + (33153, "WORKBOOK.DELETE", -1), + (33154, "WORKBOOK.NAME", -1), + (33156, "GALLERY.CUSTOM", -1), + (33158, "ADD.CHART.AUTOFORMAT", -1), + (33159, "DELETE.CHART.AUTOFORMAT", -1), + (33160, "CHART.ADD.DATA", -1), + (33161, "AUTO.OUTLINE", -1), + (33162, "TAB.ORDER", -1), + (33163, "SHOW.DIALOG", -1), + (33164, "SELECT.ALL", -1), + (33165, "UNGROUP.SHEETS", -1), + (33166, "SUBTOTAL.CREATE", -1), + (33167, "SUBTOTAL.REMOVE", -1), + (33168, "RENAME.OBJECT", -1), + (33180, "WORKBOOK.SCROLL", -1), + (33181, "WORKBOOK.NEXT", -1), + (33182, "WORKBOOK.PREV", -1), + (33183, "WORKBOOK.TAB.SPLIT", -1), + (33184, "FULL.SCREEN", -1), + (33185, "WORKBOOK.PROTECT", -1), + (33188, "SCROLLBAR.PROPERTIES", -1), + (33189, "PIVOT.SHOW.PAGES", -1), + (33190, "TEXT.TO.COLUMNS", -1), + (33191, "FORMAT.CHARTTYPE", -1), + (33192, "LINK.FORMAT", -1), + (33193, "TRACER.DISPLAY", -1), + (33198, "TRACER.NAVIGATE", -1), + (33199, "TRACER.CLEAR", -1), + (33200, "TRACER.ERROR", -1), + (33201, "PIVOT.FIELD.GROUP", -1), + (33202, "PIVOT.FIELD.UNGROUP", -1), + (33203, "CHECKBOX.PROPERTIES", -1), + (33204, "LABEL.PROPERTIES", -1), + (33205, "LISTBOX.PROPERTIES", -1), + (33206, "EDITBOX.PROPERTIES", -1), + (33207, "PIVOT.REFRESH", -1), + (33208, "LINK.COMBO", -1), + (33209, "OPEN.TEXT", -1), + (33210, "HIDE.DIALOG", -1), + (33211, "SET.DIALOG.FOCUS", -1), + (33212, "ENABLE.OBJECT", -1), + (33213, "PUSHBUTTON.PROPERTIES", -1), + (33214, "SET.DIALOG.DEFAULT", -1), + (33215, "FILTER", -1), + (33216, "FILTER.SHOW.ALL", -1), + (33217, "CLEAR.OUTLINE", -1), + (33218, "FUNCTION.WIZARD", -1), + (33219, "ADD.LIST.ITEM", -1), + (33220, "SET.LIST.ITEM", -1), + (33221, "REMOVE.LIST.ITEM", -1), + (33222, "SELECT.LIST.ITEM", -1), + (33223, "SET.CONTROL.VALUE", -1), + (33224, "SAVE.COPY.AS", -1), + (33226, "OPTIONS.LISTS.ADD", -1), + (33227, "OPTIONS.LISTS.DELETE", -1), + (33228, "SERIES.AXES", -1), + (33229, "SERIES.X", -1), + (33230, "SERIES.Y", -1), + (33231, "ERRORBAR.X", -1), + (33232, "ERRORBAR.Y", -1), + (33233, "FORMAT.CHART", -1), + (33234, "SERIES.ORDER", -1), + (33235, "MAIL.LOGOFF", -1), + (33236, "CLEAR.ROUTING.SLIP", -1), + (33237, "APP.ACTIVATE.MICROSOFT", -1), + (33238, "MAIL.EDIT.MAILER", -1), + (33239, "ON.SHEET", -1), + (33240, "STANDARD.WIDTH", -1), + (33241, "SCENARIO.MERGE", -1), + (33242, "SUMMARY.INFO", -1), + (33243, "FIND.FILE", -1), + (33244, "ACTIVE.CELL.FONT", -1), + (33245, "ENABLE.TIPWIZARD", -1), + (33246, "VBA.MAKE.ADDIN", -1), + (33248, "INSERTDATATABLE", -1), + (33249, "WORKGROUP.OPTIONS", -1), + (33250, "MAIL.SEND.MAILER", -1), + (33253, "AUTOCORRECT", -1), + (33257, "POST.DOCUMENT", -1), + (33259, "PICKLIST", -1), + (33261, "VIEW.SHOW", -1), + (33262, "VIEW.DEFINE", -1), + (33263, "VIEW.DELETE", -1), + (33277, "SHEET.BACKGROUND", -1), + (33278, "INSERT.MAP.OBJECT", -1), + (33279, "OPTIONS.MENONO", -1), + (33285, "MSOCHECKS", -1), + (33286, "NORMAL", -1), + (33287, "LAYOUT", -1), + (33288, "RM.PRINT.AREA", -1), + (33289, "CLEAR.PRINT.AREA", -1), + (33290, "ADD.PRINT.AREA", -1), + (33291, "MOVE.BRK", -1), + (33313, "HIDECURR.NOTE", -1), + (33314, "HIDEALL.NOTES", -1), + (33315, "DELETE.NOTE", -1), + (33316, "TRAVERSE.NOTES", -1), + (33317, "ACTIVATE.NOTES", -1), + (33388, "PROTECT.REVISIONS", -1), + (33389, "UNPROTECT.REVISIONS", -1), + (33415, "OPTIONS.ME", -1), + (33421, "WEB.PUBLISH", -1), + (33435, "NEWWEBQUERY", -1), + (33441, "PIVOT.TABLE.CHART", -1), + (33521, "OPTIONS.SAVE", -1), + (33523, "OPTIONS.SPELL", -1), + (33576, "HIDEALL.INKANNOTS", -1), +]; + +/// The built-in function of id `id` ([MS-XLS] 2.5.198.17 and the Excel 4.0 +/// macro functions): its name, and its argument count when the id (a +/// `ptgFunc`) fixes it, else -1. +pub fn function(id: u16) -> Option<(&'static str, i8)> { + FUNCTIONS + .binary_search_by_key(&id, |e| e.0) + .ok() + .map(|k| (FUNCTIONS[k].1, FUNCTIONS[k].2)) +} + +/// The name of the function of a `ptgFunc` or `ptgFuncVar` token. A command +/// equivalent (an Excel 4.0 macro command) has the 0x8000 bit; it is looked up +/// as it is, and then with the bit the other way. +pub fn function_name(id: u16) -> Option<&'static str> { + function(id).or_else(|| function(id ^ 0x8000)).map(|f| f.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_table_is_sorted_by_id_for_the_search() { + assert!(FUNCTIONS.array_windows().all(|[a, b]| a.0 < b.0)); + } + + #[test] + fn macro_functions_are_found_by_either_form_of_the_id() { + assert_eq!(function_name(110), Some("EXEC")); + assert_eq!(function_name(150), Some("CALL")); + assert_eq!(function_name(32886), Some("ALERT")); + // A command equivalent the table does not list takes the name of the + // function without the bit. + assert_eq!(function(32786), None); + assert_eq!(function_name(32786), Some("ATAN")); + assert_eq!(function(4), Some(("SUM", -1))); + assert_eq!(function(2), Some(("ISNA", 1))); + assert_eq!(function(0x7777), None); + } +} diff --git a/crates/exav-unpack/src/formats/biff/mod.rs b/crates/exav-unpack/src/formats/biff/mod.rs new file mode 100644 index 00000000..3aba1dc6 --- /dev/null +++ b/crates/exav-unpack/src/formats/biff/mod.rs @@ -0,0 +1,19 @@ +//! BIFF, the record format of Excel files up to 2003: the one reader of its +//! records, workbook-wide tables and formula tokens, which the scanner (to +//! surface the Excel 4.0 macros of a macro sheet) and exav-render (to convert +//! a workbook) both read through. +//! +//! - [`records`] splits a stream into records, joining CONTINUE records; +//! - [`Book`] collects what formulas need from the workbook records: code +//! page, sheet and defined names, external references; +//! - [`formula`] writes a token array as Excel's formula text. + +mod book; +pub mod formula; +mod ftab; +mod record; + +pub use book::{Book, BoundSheet, ExtSheet5, Name, Supbook}; +pub use formula::{Ctx, Decoded, error_name}; +pub use ftab::{function, function_name}; +pub use record::{BOF_IDS, CONTINUE, Rec, Records, bof_type, bof_version, records}; diff --git a/crates/exav-unpack/src/formats/biff/record.rs b/crates/exav-unpack/src/formats/biff/record.rs new file mode 100644 index 00000000..00a846e8 --- /dev/null +++ b/crates/exav-unpack/src/formats/biff/record.rs @@ -0,0 +1,154 @@ +//! The BIFF record stream: a compound file's "Workbook" or "Book" stream, or +//! a bare BIFF stream, as records, with the payload of the CONTINUE records +//! of the records that are continued joined to them. + +use crate::bytes::u16_at; +use std::borrow::Cow; + +pub struct Rec<'a> { + pub id: u16, + /// Offset of the record header in the stream (BOUNDSHEET points there). + pub pos: usize, + /// Borrowed from the stream unless CONTINUE records were joined to it. + pub data: Cow<'a, [u8]>, + /// Offsets in `data` where a CONTINUE record's payload starts. + pub breaks: Vec<usize>, +} + +pub const CONTINUE: u16 = 0x003C; +/// The BOF record of BIFF8 and BIFF5, BIFF4, BIFF3 and BIFF2. +pub const BOF_IDS: [u16; 4] = [0x0809, 0x0409, 0x0209, 0x0009]; + +/// Records whose CONTINUE records carry the rest of their payload. +fn continued(id: u16) -> bool { + matches!( + id, + 0x00FC // SST + | 0x01B6 // TXO + | 0x00EB // MSODRAWINGGROUP + | 0x00EC // MSODRAWING + | 0x0018 // NAME + | 0x0006 // FORMULA + | 0x0221 // ARRAY + | 0x04BC // SHRFMLA + | 0x01B8 // HLINK + | 0x01BE // DV + | 0x01B1 // CF + | 0x087A // CF12 + | 0x0023 // EXTERNNAME + | 0x0017 // EXTERNSHEET + | 0x01AE // SUPBOOK + | 0x0207 // STRING + ) +} + +/// The records of `stream`, read as they are asked for: a stream of +/// four-byte records costs nothing until a reader keeps them. A record cut +/// short by the end of the stream keeps what is there. +pub fn records(stream: &[u8]) -> Records<'_> { + Records { stream, p: 0 } +} + +pub struct Records<'a> { + stream: &'a [u8], + p: usize, +} + +impl Records<'_> { + /// The header at `p`: (id, payload range), the payload cut at the end of the stream. + fn header(&self, p: usize) -> Option<(u16, std::ops::Range<usize>)> { + if p + 4 > self.stream.len() { + return None; + } + let len = u16_at(self.stream, p + 2) as usize; + Some(( + u16_at(self.stream, p), + p + 4..(p + 4 + len).min(self.stream.len()), + )) + } +} + +impl<'a> Iterator for Records<'a> { + type Item = Rec<'a>; + + fn next(&mut self) -> Option<Rec<'a>> { + let (id, range) = self.header(self.p)?; + let pos = self.p; + self.p = range.end; + let mut data = Cow::Borrowed(&self.stream[range]); + let mut breaks = vec![]; + if continued(id) { + while let Some((CONTINUE, more)) = self.header(self.p) { + breaks.push(data.len()); + data.to_mut().extend_from_slice(&self.stream[more.clone()]); + self.p = more.end; + } + } + Some(Rec { + id, + pos, + data, + breaks, + }) + } +} + +/// BIFF version of a BOF record: 2, 3, 4, 5 or 8 (0 for no BOF). +pub fn bof_version(rec: &Rec) -> u8 { + match rec.id { + 0x0009 => 2, + 0x0209 => 3, + 0x0409 => 4, + 0x0809 => match u16_at(&rec.data, 0) { + 0x0600 => 8, + 0x0500 | 0x0400 | 0x0300 | 0x0200 => 5, + // Writers that leave the version out or write garbage: by length. + _ if rec.data.len() >= 16 => 8, + _ => 5, + }, + _ => 0, + } +} + +/// BOF substream type: 0x05 globals, 0x10 worksheet, 0x20 chart, 0x40 macro +/// sheet, 0x100 workspace. BIFF2 to BIFF4 keep it at the same place. +pub fn bof_type(rec: &Rec) -> u16 { + u16_at(&rec.data, 2) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rec(id: u16, body: &[u8]) -> Vec<u8> { + let mut v = id.to_le_bytes().to_vec(); + v.extend((body.len() as u16).to_le_bytes()); + v.extend_from_slice(body); + v + } + + #[test] + fn continue_records_are_joined_to_the_records_that_take_them() { + let mut s = rec(0x0006, b"ab"); + s.extend(rec(CONTINUE, b"cd")); + s.extend(rec(0x0203, b"ef")); + // A CONTINUE after a record that is not continued stands alone. + s.extend(rec(CONTINUE, b"gh")); + let r: Vec<_> = records(&s).collect(); + assert_eq!(r.len(), 3); + assert_eq!( + (r[0].data.as_ref(), r[0].breaks.as_slice()), + (&b"abcd"[..], &[2][..]) + ); + assert!(matches!(r[1].data, Cow::Borrowed(_))); + assert_eq!(r[2].id, CONTINUE); + } + + #[test] + fn a_record_cut_short_keeps_what_is_there() { + let mut s = rec(0x0203, b"abcd"); + s.truncate(s.len() - 2); + let r: Vec<_> = records(&s).collect(); + assert_eq!(r[0].data.as_ref(), b"ab"); + } +} diff --git a/crates/exav-unpack/src/formats/binhex.rs b/crates/exav-unpack/src/formats/binhex.rs index 5dd15d71..8f8f098c 100644 --- a/crates/exav-unpack/src/formats/binhex.rs +++ b/crates/exav-unpack/src/formats/binhex.rs @@ -272,7 +272,7 @@ mod tests { hdr.extend_from_slice(&0u16.to_be_bytes()); // headerCRC hdr.extend_from_slice(data); // data fork hdr.extend_from_slice(&0u16.to_be_bytes()); // data-fork CRC - // rsrcLen == 0, so no resource fork bytes. + // rsrcLen == 0, so no resource fork bytes. // RLE90 identity (no 0x90 in `hdr` by construction), then 6-bit encode. let encoded = sixbit_encode(&hdr); diff --git a/crates/exav-unpack/src/formats/bzip2.rs b/crates/exav-unpack/src/formats/bzip2.rs index ca9b2c04..50f40f26 100644 --- a/crates/exav-unpack/src/formats/bzip2.rs +++ b/crates/exav-unpack/src/formats/bzip2.rs @@ -1,6 +1,6 @@ //! bzip2: one member, every concatenated stream decoded in turn as it is read. use crate::source::{ByteSource, Reader}; -use crate::stream::{emit_stream, single_meta, Visit}; +use crate::stream::{Visit, emit_stream, single_meta}; use crate::{Budget, LimitHit}; use std::io::Read; @@ -34,9 +34,7 @@ pub(crate) fn walk<T>( /// content streamed the way it turned out. pub(crate) fn content_reader(data: &dyn ByteSource, cap: u64) -> Box<dyn Read + '_> { let starts = stream_starts(data); - let one = || -> Box<dyn Read + '_> { - Box::new(super::bzip2_rs::DecoderReader::new(Reader::new(data))) - }; + let one = || -> Box<dyn Read + '_> { Box::new(Recovering::new(data, 0, data.len())) }; if starts.len() <= 1 { return one(); } @@ -55,21 +53,122 @@ pub(crate) fn content_reader(data: &dyn ByteSource, cap: u64) -> Box<dyn Read + .map_or_else(|e| crate::is_checksum_mismatch(&e), |_| true) }); if !each_decodes { - return one(); + // Either one stream with a `BZh` in its compressed data, or streams of + // which a later one is cut short or damaged. The first is read whole as + // one stream. The ones behind the first are read as far as they go in + // either case, so that what a later stream holds is scanned and a + // false split costs only a stream that decodes to nothing. + let mut parts: std::collections::VecDeque<Box<dyn Read + '_>> = + std::collections::VecDeque::new(); + parts.push_back(one()); + for &(s, e) in bounds.iter().skip(1) { + parts.push_back(Box::new(AsFarAsItGoes(Recovering::new(data, s, e)))); + } + return Box::new(Streams { + parts, + mismatch: None, + }); } Box::new(Streams { parts: bounds .into_iter() - .map(|(s, e)| { - Box::new(super::bzip2_rs::DecoderReader::new(Reader::range( - data, s, e, - ))) as Box<dyn Read + '_> - }) + .map(|(s, e)| Box::new(Recovering::new(data, s, e)) as Box<dyn Read + '_>) .collect(), mismatch: None, }) } +/// A reader that ends where the one inside it fails. +struct AsFarAsItGoes<R>(R); + +impl<R: Read> Read for AsFarAsItGoes<R> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + Ok(self.0.read(buf).unwrap_or(0)) + } +} + +/// One stream of `data[start..end]`, decoded; a stream cut short, whose last +/// block the decoder holds back because it looks ahead past the end of the +/// input (a cut inside the ten bytes of the trailer loses the whole block, and +/// `bzip2 -dc` writes it), is decoded again behind zeros that stand for the +/// missing bytes, from where the first pass stopped, and the cut is reported +/// once the block is out. +struct Recovering<'a> { + data: &'a dyn ByteSource, + range: (usize, usize), + first: super::bzip2_rs::DecoderReader<Reader<'a>>, + /// Bytes `first` produced, to skip in the second pass. + produced: u64, + second: Option< + super::bzip2_rs::DecoderReader<std::io::Chain<Reader<'a>, std::io::Take<std::io::Repeat>>>, + >, + skip: u64, + cut: Option<(std::io::ErrorKind, String)>, +} + +impl<'a> Recovering<'a> { + fn new(data: &'a dyn ByteSource, start: usize, end: usize) -> Self { + Recovering { + data, + range: (start, end), + first: super::bzip2_rs::DecoderReader::new(Reader::range(data, start, end)), + produced: 0, + second: None, + skip: 0, + cut: None, + } + } +} + +impl Read for Recovering<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + loop { + if let Some(second) = &mut self.second { + if self.skip > 0 { + let mut scratch = [0u8; 4096]; + let want = scratch.len().min(self.skip as usize); + match second.read(&mut scratch[..want]) { + Ok(n) if n > 0 => { + self.skip -= n as u64; + continue; + } + _ => return Err(self.cut_error()), + } + } + return match second.read(buf) { + Ok(0) | Err(_) => Err(self.cut_error()), + Ok(n) => Ok(n), + }; + } + match self.first.read(buf) { + Ok(n) => { + self.produced += n as u64; + return Ok(n); + } + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + self.cut = Some((e.kind(), e.to_string())); + self.skip = self.produced; + let (start, end) = self.range; + self.second = Some(super::bzip2_rs::DecoderReader::new( + Reader::range(self.data, start, end).chain(std::io::repeat(0).take(16)), + )); + } + Err(e) => return Err(e), + } + } + } +} + +impl Recovering<'_> { + fn cut_error(&mut self) -> std::io::Error { + let (kind, msg) = self.cut.clone().unwrap_or(( + std::io::ErrorKind::UnexpectedEof, + "bzip2: stream cut short".into(), + )); + std::io::Error::new(kind, msg) + } +} + /// Streams read one after another. A checksum mismatch in one is reported /// once all of them are read, so it does not cost the streams after it. struct Streams<'a> { diff --git a/crates/exav-unpack/src/formats/bzip2_rs/decoder/reader.rs b/crates/exav-unpack/src/formats/bzip2_rs/decoder/reader.rs index 743d2909..e8281afd 100644 --- a/crates/exav-unpack/src/formats/bzip2_rs/decoder/reader.rs +++ b/crates/exav-unpack/src/formats/bzip2_rs/decoder/reader.rs @@ -62,7 +62,7 @@ impl<R: Read> Read for DecoderReader<R> { let state = match self.decoder.read(buf) { Ok(state) => state, Err(e) if e.is_truncated() && self.input_ended => { - return Err(io::Error::new(io::ErrorKind::UnexpectedEof, e)) + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, e)); } Err(e) => return Err(e.into()), }; diff --git a/crates/exav-unpack/src/formats/cab.rs b/crates/exav-unpack/src/formats/cab.rs index 4f8a0e62..72fb2ef4 100644 --- a/crates/exav-unpack/src/formats/cab.rs +++ b/crates/exav-unpack/src/formats/cab.rs @@ -23,7 +23,7 @@ pub(crate) fn stream_cab<R: Read + Seek, T>( ) -> Result<Option<T>, LimitHit> { use crate::formats::cab_parse::cabinet::Cabinet; use crate::formats::cab_parse::folder::FolderReader; - use crate::stream::{emit_stream, MemberMeta}; + use crate::stream::{MemberMeta, emit_stream}; let (folder_metas, files) = Cabinet::layout(source).map_err(|e| LimitHit::new(format!("cab: {e}")))?; for (folder_idx, meta) in folder_metas.iter().enumerate() { @@ -124,6 +124,29 @@ pub(crate) fn stream_cab<R: Read + Seek, T>( } } } + // A file in no folder of this cabinet runs from or into another one of its + // set (`iFolder` 0xFFFD to 0xFFFF), or names a folder that is not there. + // Its bytes are not here, and it is not for a scan to pass over it. + for f in files + .iter() + .filter(|f| f.folder_index as usize >= folder_metas.len()) + { + budget.count_entry()?; + let m = MemberMeta { + name: f.name().to_string(), + comp_size: f.uncompressed_size as u64, + size: Some(f.uncompressed_size as u64), + unsupported: Some(if f.folder_index >= 0xFFFD { + "CAB member continues in another cabinet of its set" + } else { + "CAB member names a folder the cabinet does not have" + }), + ..MemberMeta::default() + }; + if let Some(t) = visit(&m, None, budget) { + return Ok(Some(t)); + } + } Ok(None) } diff --git a/crates/exav-unpack/src/formats/cab_join.rs b/crates/exav-unpack/src/formats/cab_join.rs new file mode 100644 index 00000000..c40349af --- /dev/null +++ b/crates/exav-unpack/src/formats/cab_join.rs @@ -0,0 +1,415 @@ +//! Cabinets of a set: one folder, one file, cut across several `.cab` files. +//! +//! A cabinet names the one before it and the one after it. A folder that runs +//! on into the next cabinet ends the first with a block cut short (`cbUncomp` +//! zero, the rest of its bytes the first block of the next cabinet), and a file +//! that straddles the two is listed in both, with the same name, size and +//! offset in the folder, as continued to the next and from the previous. The +//! layout is that of the format's published specification ([MS-CAB]); it was +//! checked on sets built to it by `cabextract`, which reads them as the files +//! they were made from. +//! +//! [`join`] puts such a set back into the one cabinet it was cut from, which +//! [`super::cab`] reads as any other: the folders that run on are one, their +//! blocks in order, and each file is listed once. Nothing is decoded here. No +//! name is turned into a path: [`links`] reports the names a cabinet's header +//! gives, for a caller to match against the names it was given. + +use crate::bytes::at; + +const PREV: u16 = 1; +const NEXT: u16 = 2; +const RESERVE: u16 = 4; + +/// `iFolder` values that say a file runs across cabinets. +const FROM_PREV: u16 = 0xFFFD; +const TO_NEXT: u16 = 0xFFFE; +const BOTH: u16 = 0xFFFF; + +/// What a cabinet's header says of its place in a set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Links { + pub set_id: u16, + /// Zero for the first cabinet of the set. + pub index: u16, + /// The cabinet before it, as its header names it. + pub prev: Option<String>, + /// The cabinet after it. + pub next: Option<String>, +} + +fn u16_at(d: &[u8], o: usize) -> Option<u16> { + Some(u16::from_le_bytes(at(d, o, 2)?.try_into().ok()?)) +} + +fn u32_at(d: &[u8], o: usize) -> Option<u32> { + Some(u32::from_le_bytes(at(d, o, 4)?.try_into().ok()?)) +} + +/// A NUL-terminated string at `o` of at most 255 bytes, and where it ends. +fn cstr(d: &[u8], o: usize) -> Option<(&[u8], usize)> { + let rest = d.get(o..)?; + let n = rest.iter().take(256).position(|&b| b == 0)?; + Some((&rest[..n], o + n + 1)) +} + +struct Header { + links: Links, + flags: u16, + folders: u16, + files: u16, + coff_files: usize, + /// Where the folder records start, and the reserved bytes after each, + /// and after each data block. + folders_at: usize, + cb_folder: usize, + cb_data: usize, +} + +fn header(d: &[u8]) -> Option<Header> { + if at(d, 0, 4)? != b"MSCF" { + return None; + } + let (coff_files, flags) = (u32_at(d, 16)? as usize, u16_at(d, 30)?); + let (mut pos, mut cb_folder, mut cb_data) = (36usize, 0usize, 0usize); + if flags & RESERVE != 0 { + let cb_header = usize::from(u16_at(d, 36)?); + cb_folder = usize::from(*d.get(38)?); + cb_data = usize::from(*d.get(39)?); + pos = 40usize.checked_add(cb_header)?; + } + let mut name = |wanted: bool| -> Option<Option<String>> { + if !wanted { + return Some(None); + } + let (n, after) = cstr(d, pos)?; + // The disk's name follows, and is not wanted. + let (_, after) = cstr(d, after)?; + pos = after; + Some(Some(String::from_utf8_lossy(n).into_owned())) + }; + let prev = name(flags & PREV != 0)?; + let next = name(flags & NEXT != 0)?; + Some(Header { + links: Links { + set_id: u16_at(d, 32)?, + index: u16_at(d, 34)?, + prev, + next, + }, + flags, + folders: u16_at(d, 26)?, + files: u16_at(d, 28)?, + coff_files, + folders_at: pos, + cb_folder, + cb_data, + }) +} + +/// What `data`, a cabinet, says of its place in a set: `None` when it is not +/// a cabinet that is part of one (no cabinet before it or after it). +pub fn links(data: &[u8]) -> Option<Links> { + let h = header(data)?; + (h.flags & (PREV | NEXT) != 0).then_some(h.links) +} + +#[derive(Clone)] +struct Block { + uncompressed: u16, + payload: Vec<u8>, +} + +struct Folder { + kind: u16, + blocks: Vec<Block>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Place { + In(usize), + FromPrev, + ToNext, + Both, +} + +struct File { + size: u32, + offset: u32, + place: Place, + date: u16, + time: u16, + attribs: u16, + name: Vec<u8>, +} + +struct Cabinet { + links: Links, + folders: Vec<Folder>, + files: Vec<File>, +} + +fn parse(d: &[u8]) -> Option<Cabinet> { + let h = header(d)?; + let mut folders = Vec::new(); + let record = 8 + h.cb_folder; + for i in 0..usize::from(h.folders) { + let at_ = h.folders_at.checked_add(i.checked_mul(record)?)?; + let (start, count, kind) = ( + u32_at(d, at_)? as usize, + u16_at(d, at_ + 4)?, + u16_at(d, at_ + 6)?, + ); + let mut pos = start; + let mut blocks = Vec::new(); + for _ in 0..count { + let (len, uncompressed) = (usize::from(u16_at(d, pos + 4)?), u16_at(d, pos + 6)?); + let from = pos.checked_add(8 + h.cb_data)?; + blocks.push(Block { + uncompressed, + payload: at(d, from, len)?.to_vec(), + }); + pos = from.checked_add(len)?; + } + folders.push(Folder { kind, blocks }); + } + let mut files = Vec::new(); + let mut pos = h.coff_files; + for _ in 0..h.files { + let (size, offset, folder) = (u32_at(d, pos)?, u32_at(d, pos + 4)?, u16_at(d, pos + 8)?); + let (name, after) = cstr(d, pos.checked_add(16)?)?; + let place = match folder { + FROM_PREV => Place::FromPrev, + TO_NEXT => Place::ToNext, + BOTH => Place::Both, + i if usize::from(i) < folders.len() => Place::In(usize::from(i)), + _ => return None, + }; + files.push(File { + size, + offset, + place, + date: u16_at(d, pos + 10)?, + time: u16_at(d, pos + 12)?, + attribs: u16_at(d, pos + 14)?, + name: name.to_vec(), + }); + pos = after; + } + Some(Cabinet { + links: h.links, + folders, + files, + }) +} + +/// The cabinets of one set, in order, as the one cabinet they were cut from. +/// `Err` says what does not fit: a part that is not a cabinet, a set whose +/// ends or numbers are wrong, a folder or a file that does not continue. +pub fn join(parts: &[&[u8]]) -> Result<Vec<u8>, String> { + if parts.len() < 2 { + return Err("a set of cabinets has at least two".into()); + } + let mut cabs = Vec::new(); + for (i, p) in parts.iter().enumerate() { + let c = parse(p).ok_or_else(|| format!("part {} is not a readable cabinet", i + 1))?; + let first = i == 0; + let last = i + 1 == parts.len(); + if c.links.prev.is_some() == first || c.links.next.is_some() == last { + return Err(format!( + "part {} is not where its header puts it in the set", + i + 1 + )); + } + let same_set = cabs + .first() + .is_none_or(|f: &Cabinet| f.links.set_id == c.links.set_id); + if usize::from(c.links.index) != i || !same_set { + return Err(format!("part {} is of another set, or out of order", i + 1)); + } + cabs.push(c); + } + let mut iter = cabs.into_iter(); + let mut acc = iter.next().expect("two parts at least"); + for next in iter { + merge(&mut acc, next)?; + } + if acc + .files + .iter() + .any(|f| f.place != Place::In(f.folder_index())) + { + return Err("a file runs on past the last cabinet".into()); + } + write(&acc) +} + +impl File { + fn folder_index(&self) -> usize { + match self.place { + Place::In(i) => i, + _ => usize::MAX, + } + } +} + +fn merge(acc: &mut Cabinet, mut next: Cabinet) -> Result<(), String> { + let spanning = |f: &File| matches!(f.place, Place::ToNext); + let last = acc + .folders + .len() + .checked_sub(1) + .ok_or("a cabinet with no folder")?; + let cut_block = acc.folders[last] + .blocks + .last() + .is_some_and(|b| b.uncompressed == 0); + let continues = !next.folders.is_empty() && (cut_block || acc.files.iter().any(spanning)); + // Where each of the next cabinet's folders ends up. + let mut folders = std::mem::take(&mut next.folders).into_iter(); + let map_from = if continues { + let first = folders.next().expect("one at least"); + let into = &mut acc.folders[last]; + if first.kind != into.kind { + return Err("a folder runs on in a cabinet that compresses it otherwise".into()); + } + let mut rest = first.blocks.into_iter(); + if cut_block && let Some(b) = rest.next() { + let end = into.blocks.last_mut().expect("a cut block"); + end.payload.extend_from_slice(&b.payload); + if end.payload.len() > usize::from(u16::MAX) { + return Err("a block cut across cabinets is longer than a block can be".into()); + } + end.uncompressed = b.uncompressed; + } + into.blocks.extend(rest); + if into.blocks.len() > usize::from(u16::MAX) { + return Err("a folder with more blocks than a cabinet can hold".into()); + } + last + } else { + acc.folders.len() + }; + let shift = |i: usize| if continues { last + i } else { map_from + i }; + acc.folders.extend(folders); + if acc.folders.len() > usize::from(u16::MAX) { + return Err("more folders than a cabinet can hold".into()); + } + // The files left running on at the end of `acc`, each of which the next + // cabinet must continue. + let mut open = acc + .files + .iter() + .filter(|f| f.place == Place::ToNext) + .count(); + for f in next.files { + match f.place { + Place::FromPrev | Place::Both => { + let start = acc + .files + .iter_mut() + .find(|a| { + a.place == Place::ToNext + && a.name == f.name + && a.size == f.size + && a.offset == f.offset + }) + .ok_or("a file continued from the cabinet before is not in it")?; + open -= 1; + // Resolved here, unless it runs on again. + if f.place == Place::FromPrev { + start.place = Place::In(last); + } + } + Place::In(i) => acc.files.push(File { + place: Place::In(shift(i)), + ..f + }), + Place::ToNext => acc.files.push(File { + place: Place::ToNext, + ..f + }), + } + } + if open != 0 { + return Err("a file continued to the next cabinet is not in it".into()); + } + acc.links.next = next.links.next; + Ok(()) +} + +fn write(c: &Cabinet) -> Result<Vec<u8>, String> { + let too_big = || "the joined cabinet is larger than a cabinet can be".to_string(); + let mut files = Vec::new(); + for f in &c.files { + let Place::In(i) = f.place else { + return Err("a file runs on past the last cabinet".into()); + }; + files.extend_from_slice(&f.size.to_le_bytes()); + files.extend_from_slice(&f.offset.to_le_bytes()); + files.extend_from_slice(&u16::try_from(i).map_err(|_| too_big())?.to_le_bytes()); + for v in [f.date, f.time, f.attribs] { + files.extend_from_slice(&v.to_le_bytes()); + } + files.extend_from_slice(&f.name); + files.push(0); + } + let coff_files = 36 + 8 * c.folders.len(); + let data_at = coff_files + files.len(); + let mut records = Vec::new(); + let mut data = Vec::new(); + for fo in &c.folders { + records.extend_from_slice( + &u32::try_from(data_at + data.len()) + .map_err(|_| too_big())? + .to_le_bytes(), + ); + records.extend_from_slice( + &u16::try_from(fo.blocks.len()) + .map_err(|_| too_big())? + .to_le_bytes(), + ); + records.extend_from_slice(&fo.kind.to_le_bytes()); + for b in &fo.blocks { + // No checksum: zero says there is none. + data.extend_from_slice(&0u32.to_le_bytes()); + data.extend_from_slice( + &u16::try_from(b.payload.len()) + .map_err(|_| too_big())? + .to_le_bytes(), + ); + data.extend_from_slice(&b.uncompressed.to_le_bytes()); + data.extend_from_slice(&b.payload); + } + } + let total = u32::try_from(data_at + data.len()).map_err(|_| too_big())?; + let mut out = Vec::with_capacity(total as usize); + out.extend_from_slice(b"MSCF"); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&total.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice( + &u32::try_from(coff_files) + .map_err(|_| too_big())? + .to_le_bytes(), + ); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&[3, 1]); + out.extend_from_slice( + &u16::try_from(c.folders.len()) + .map_err(|_| too_big())? + .to_le_bytes(), + ); + out.extend_from_slice( + &u16::try_from(c.files.len()) + .map_err(|_| too_big())? + .to_le_bytes(), + ); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&c.links.set_id.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend(records); + out.extend(files); + out.extend(data); + Ok(out) +} diff --git a/crates/exav-unpack/src/formats/cab_parse/ctype.rs b/crates/exav-unpack/src/formats/cab_parse/ctype.rs index b6477dee..92d5178c 100644 --- a/crates/exav-unpack/src/formats/cab_parse/ctype.rs +++ b/crates/exav-unpack/src/formats/cab_parse/ctype.rs @@ -83,24 +83,67 @@ pub(crate) enum Decompressor { Quantum(Box<super::qtm::QuantumDecompressor>), } +/// What one CFDATA block gave: the bytes it decoded, and the error that +/// stopped it short of its size, if one did. +pub(crate) struct Decoded { + pub(crate) data: Vec<u8>, + pub(crate) error: Option<io::Error>, +} + +impl Decoded { + pub(crate) fn failed(error: io::Error) -> Self { + Decoded { + data: Vec::new(), + error: Some(error), + } + } + + fn of(result: io::Result<Vec<u8>>) -> Self { + match result { + Ok(data) => Decoded { data, error: None }, + Err(e) => Decoded::failed(e), + } + } +} + impl Decompressor { + /// Decode one block. `cut`: the file ended inside it, so `data` is what + /// was there. Only MSZIP and stored blocks give back a part of a block + /// that failed or was cut: LZX and Quantum hand over a frame whole or not + /// at all. pub(crate) fn decompress( &mut self, data: Vec<u8>, uncompressed_size: usize, - ) -> io::Result<Vec<u8>> { + cut: bool, + ) -> Decoded { + let cut_error = |e: io::Error| { + if cut { + io::Error::new(io::ErrorKind::UnexpectedEof, e) + } else { + e + } + }; match self { - Decompressor::Uncompressed => Ok(data), + Decompressor::Uncompressed => Decoded { + data, + error: cut.then(|| io::ErrorKind::UnexpectedEof.into()), + }, Decompressor::MsZip(decompressor) => { - decompressor.decompress_block(&data, uncompressed_size) - } - Decompressor::Lzx(decompressor) => decompressor - .decompress_next(&data, uncompressed_size) - .map(|slice| slice.to_vec()) - .map_err(io::Error::other), - Decompressor::Quantum(decompressor) => { - decompressor.decompress_block(&data, uncompressed_size) + decompressor.decompress_block(&data, uncompressed_size, cut) } + Decompressor::Lzx(decompressor) => Decoded::of( + decompressor + .decompress_next(&data, uncompressed_size) + .map(|slice| slice.to_vec()) + .map_err(io::Error::other) + .map_err(cut_error), + ), + Decompressor::Quantum(decompressor) => Decoded::of( + decompressor + .decompress_block(&data, uncompressed_size) + .map_err(cut_error), + ), } } } diff --git a/crates/exav-unpack/src/formats/cab_parse/folder.rs b/crates/exav-unpack/src/formats/cab_parse/folder.rs index 3627c4c2..692d3178 100644 --- a/crates/exav-unpack/src/formats/cab_parse/folder.rs +++ b/crates/exav-unpack/src/formats/cab_parse/folder.rs @@ -15,6 +15,9 @@ pub(crate) struct FolderReader<'a, R: Read + Seek> { blocks_left: u16, out: Vec<u8>, out_pos: usize, + /// The error that ended the last block short, raised once what the block + /// did decode has been read. + pending: Option<io::Error>, } impl<'a, R: Read + Seek> FolderReader<'a, R> { @@ -32,25 +35,40 @@ impl<'a, R: Read + Seek> FolderReader<'a, R> { blocks_left: num_data_blocks, out: Vec::new(), out_pos: 0, + pending: None, }) } /// Decompress the next CFDATA block into `out`. Returns `false` at the last - /// block. + /// block. A block that fails, or that the file ends inside, leaves the + /// bytes it did decode in `out` and its error for the call after they are + /// read; nothing is decoded past it. fn refill(&mut self) -> io::Result<bool> { + if let Some(e) = self.pending.take() { + self.blocks_left = 0; + return Err(e); + } if self.blocks_left == 0 { return Ok(false); } let _checksum = self.reader.read_u32::<LittleEndian>()?; let compressed_size = self.reader.read_u16::<LittleEndian>()? as usize; let uncompressed_size = self.reader.read_u16::<LittleEndian>()? as usize; - let mut compressed = vec![0u8; compressed_size]; - self.reader.read_exact(&mut compressed)?; - self.out = self + let mut compressed = Vec::new(); + (&mut *self.reader) + .take(compressed_size as u64) + .read_to_end(&mut compressed)?; + let cut = compressed.len() < compressed_size; + let decoded = self .decompressor - .decompress(compressed, uncompressed_size)?; + .decompress(compressed, uncompressed_size, cut); + self.out = decoded.data; self.out_pos = 0; self.blocks_left -= 1; + // A cut block that decoded in full is only followed by the cut. + self.pending = decoded + .error + .or_else(|| cut.then(|| io::Error::new(io::ErrorKind::UnexpectedEof, "CAB block cut"))); Ok(true) } } diff --git a/crates/exav-unpack/src/formats/cab_parse/macros.rs b/crates/exav-unpack/src/formats/cab_parse/macros.rs index 0266b7a4..e58a9581 100644 --- a/crates/exav-unpack/src/formats/cab_parse/macros.rs +++ b/crates/exav-unpack/src/formats/cab_parse/macros.rs @@ -1,11 +1,11 @@ macro_rules! invalid_data { - ($e:expr) => { + ($e:expr_2021) => { return Err(::std::io::Error::new( ::std::io::ErrorKind::InvalidData, $e, )) }; - ($fmt:expr, $($arg:tt)+) => { + ($fmt:expr_2021, $($arg:tt)+) => { return Err(::std::io::Error::new( ::std::io::ErrorKind::InvalidData, format!($fmt, $($arg)+), @@ -14,13 +14,13 @@ macro_rules! invalid_data { } macro_rules! invalid_input { - ($e:expr) => { + ($e:expr_2021) => { return Err(::std::io::Error::new( ::std::io::ErrorKind::InvalidInput, $e, )) }; - ($fmt:expr, $($arg:tt)+) => { + ($fmt:expr_2021, $($arg:tt)+) => { return Err(::std::io::Error::new( ::std::io::ErrorKind::InvalidInput, format!($fmt, $($arg)+), @@ -30,10 +30,10 @@ macro_rules! invalid_input { #[allow(unused_macros)] // part of the vendored surface; kept with its siblings macro_rules! not_found { - ($e:expr) => { + ($e:expr_2021) => { return Err(::std::io::Error::new(::std::io::ErrorKind::NotFound, $e)) }; - ($fmt:expr, $($arg:tt)+) => { + ($fmt:expr_2021, $($arg:tt)+) => { return Err(::std::io::Error::new( ::std::io::ErrorKind::NotFound, format!($fmt, $($arg)+), diff --git a/crates/exav-unpack/src/formats/cab_parse/mszip.rs b/crates/exav-unpack/src/formats/cab_parse/mszip.rs index ec393920..e6485652 100644 --- a/crates/exav-unpack/src/formats/cab_parse/mszip.rs +++ b/crates/exav-unpack/src/formats/cab_parse/mszip.rs @@ -19,15 +19,32 @@ impl MsZipDecompressor { } } + /// Decode one CFDATA block. `cut`: the file ended inside the block, so + /// `data` is what was there of it. + /// + /// What came out before a failure is returned with it, so that a block + /// damaged or cut part way keeps its decoded start. Once a block has + /// failed the history the next one needs is wrong: the caller stops. pub(crate) fn decompress_block( &mut self, data: &[u8], uncompressed_size: usize, - ) -> io::Result<Vec<u8>> { - if data.len() < MSZIP_SIGNATURE_LEN - || ((data[0] as u16) | ((data[1] as u16) << 8)) != MSZIP_SIGNATURE - { - invalid_data!("MSZIP decompression failed: Invalid block signature"); + cut: bool, + ) -> super::ctype::Decoded { + use super::ctype::Decoded; + let eof = |m: &str| io::Error::new(io::ErrorKind::UnexpectedEof, m.to_string()); + let bad = |m: String| io::Error::new(io::ErrorKind::InvalidData, m); + if data.len() < MSZIP_SIGNATURE_LEN { + return Decoded::failed(if cut { + eof("MSZIP block cut inside its signature") + } else { + bad("MSZIP decompression failed: Invalid block signature".into()) + }); + } + if ((data[0] as u16) | ((data[1] as u16) << 8)) != MSZIP_SIGNATURE { + return Decoded::failed(bad( + "MSZIP decompression failed: Invalid block signature".into() + )); } let data = &data[MSZIP_SIGNATURE_LEN..]; // Each CFDATA block is an independent raw-deflate stream, but back- @@ -59,27 +76,43 @@ impl MsZipDecompressor { let mut combined = Vec::<u8>::with_capacity(dict_len + crate::cap_prealloc(uncompressed_size)); let flush = flate2::FlushDecompress::Finish; - match self + let result = self .decompressor - .decompress_vec(&input, &mut combined, flush) - { - Ok(_) => {} - Err(error) => { - invalid_data!("MSZIP decompression failed: {}", error); - } - } + .decompress_vec(&input, &mut combined, flush); // Drop the dictionary prefix we prepended. if combined.len() < dict_len { - invalid_data!("MSZIP decompression failed: dictionary prefix truncated"); + return Decoded::failed(bad( + "MSZIP decompression failed: dictionary prefix truncated".into(), + )); + } + let mut out = combined.split_off(dict_len); + if let Err(error) = result { + out.truncate(uncompressed_size); + return Decoded { + data: out, + error: Some(bad(format!("MSZIP decompression failed: {error}"))), + }; } - let out = combined.split_off(dict_len); if out.len() != uncompressed_size { - invalid_data!( + let short = out.len() < uncompressed_size; + let message = format!( "MSZIP decompression failed: Incorrect uncompressed size \ (expected {}, was actually {})", uncompressed_size, out.len() ); + out.truncate(uncompressed_size); + // A stream that ends early in a block the file cut is the cut; in + // a whole block it is damage. + let error = if short && cut { + eof(&message) + } else { + bad(message) + }; + return Decoded { + data: out, + error: Some(error), + }; } if out.len() >= DEFLATE_MAX_DICT_LEN { let start = out.len() - DEFLATE_MAX_DICT_LEN; @@ -91,6 +124,9 @@ impl MsZipDecompressor { } self.dictionary.extend_from_slice(&out); } - Ok(out) + Decoded { + data: out, + error: None, + } } } diff --git a/crates/exav-unpack/src/formats/cab_parse/qtm.rs b/crates/exav-unpack/src/formats/cab_parse/qtm.rs index afe2fe5b..b990cdd5 100644 --- a/crates/exav-unpack/src/formats/cab_parse/qtm.rs +++ b/crates/exav-unpack/src/formats/cab_parse/qtm.rs @@ -412,7 +412,7 @@ impl QuantumDecompressor { return Err(io::Error::new( io::ErrorKind::InvalidData, "Quantum: selector out of range", - )) + )); } } } diff --git a/crates/exav-unpack/src/formats/cfb.rs b/crates/exav-unpack/src/formats/cfb.rs new file mode 100644 index 00000000..2ca40b52 --- /dev/null +++ b/crates/exav-unpack/src/formats/cfb.rs @@ -0,0 +1,684 @@ +//! A lenient reader of OLE2 compound files ([MS-CFB]): the container of +//! Word, Excel and PowerPoint 97 to 2003 files, of Publisher and Visio files, +//! of Windows Installer databases, of the package a password-protected OOXML +//! file hides in, and of the objects other files embed. +//! +//! It reads damaged files a strict reader refuses (a partial last sector, +//! header counts that are garbage, a directory tree out of order, chains that +//! end early) and gives what it finds: Office writes such files, and so does +//! malware. Every chain is bounded by the sectors the file has and visits a +//! sector once, so a cycle ends it, and nothing read is larger than the file. +//! +//! This is the only compound file reader of the workspace: the scanner walks +//! [`Compound::streams`] and reads each with a cap, the converters of +//! exav-render ask for the streams they know by name. + +use std::borrow::Cow; + +/// The first eight bytes of a compound file. +pub const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + +const MAXREGSECT: u32 = 0xFFFF_FFFA; +#[cfg(any(test, feature = "test-util"))] +const ENDOFCHAIN: u32 = 0xFFFF_FFFE; +#[cfg(any(test, feature = "test-util"))] +const NOSTREAM: u32 = 0xFFFF_FFFF; +/// Streams under this size live in the mini stream ([MS-CFB] 2.2: the header +/// field MUST be 4096; damaged files have anything there). +const MINI_CUTOFF: u64 = 4096; +/// The longest a directory entry name is, in UTF-16 code units, terminator +/// included. +const NAME_UNITS: usize = 32; + +const STORAGE: u8 = 1; +const STREAM: u8 = 2; +const ROOT: u8 = 5; + +struct Entry { + name: String, + kind: u8, + left: u32, + right: u32, + child: u32, + start: u32, + size: u64, +} + +/// A compound file in memory. +pub struct Compound<'a> { + data: &'a [u8], + sector_size: usize, + mini_size: usize, + fat: Vec<u32>, + minifat: Vec<u32>, + mini: Vec<u8>, + entries: Vec<Entry>, + /// The path of each entry from the root (`/Macros/VBA/dir`) where the tree + /// reaches it, else its bare name. + paths: Vec<String>, + root: usize, +} + +/// One stream of the directory. +#[derive(Clone, Copy, Debug)] +pub struct StreamInfo<'c> { + /// Where [`Compound::read`] finds it. + pub index: usize, + /// Its path from the root, `/` before each name. + pub path: &'c str, + /// Its own name. + pub name: &'c str, + /// The size its directory entry declares. + pub size: u64, +} + +/// What [`Compound::read`] gives. +pub struct Stream<'a> { + /// The stream's bytes, as far as its chain goes and the cap allows. + pub data: Cow<'a, [u8]>, + /// The stream is larger than the cap and `data` is its start. + pub truncated: bool, +} + +fn u16_at(b: &[u8], at: usize) -> u16 { + b.get(at..at + 2) + .map_or(0, |s| u16::from_le_bytes([s[0], s[1]])) +} + +fn u32_at(b: &[u8], at: usize) -> u32 { + b.get(at..at + 4) + .map_or(0, |s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +impl<'a> Compound<'a> { + /// `None` when `data` is not a compound file or nothing of its directory + /// can be read. + pub fn open(data: &'a [u8]) -> Option<Compound<'a>> { + if data.len() < 512 || data[..8] != SIGNATURE { + return None; + } + let shift = u16_at(data, 30); + if !(7..=24).contains(&shift) { + return None; + } + let mini_shift = u16_at(data, 32); + if !(3..=12).contains(&mini_shift) { + return None; + } + let sector_size = 1usize << shift; + let v4 = u16_at(data, 26) >= 4; + let mut c = Compound { + data, + sector_size, + mini_size: 1usize << mini_shift, + fat: vec![], + minifat: vec![], + mini: vec![], + entries: vec![], + paths: vec![], + root: 0, + }; + let sectors = c.sector_count(); + + // The FAT's sectors: the header's 109, then the DIFAT chain. + let mut fat_sectors: Vec<u32> = (0..109) + .map(|i| u32_at(data, 76 + i * 4)) + .filter(|&s| s <= MAXREGSECT && (s as usize) < sectors) + .collect(); + let mut seen = vec![false; sectors]; + let mut difat = u32_at(data, 68); + let per = sector_size / 4; + while difat <= MAXREGSECT && (difat as usize) < sectors && !seen[difat as usize] { + seen[difat as usize] = true; + let Some(sec) = c.sector(difat) else { break }; + for i in 0..per - 1 { + let s = u32_at(sec, i * 4); + if s <= MAXREGSECT && (s as usize) < sectors { + fat_sectors.push(s); + } + } + difat = u32_at(sec, (per - 1) * 4); + } + fat_sectors.truncate(sectors); + let mut fat = Vec::with_capacity(fat_sectors.len().min(sectors) * per); + for &s in &fat_sectors { + if let Some(sec) = c.sector(s) { + fat.extend((0..sec.len() / 4).map(|i| u32_at(sec, i * 4))); + } + if fat.len() >= sectors { + break; + } + } + fat.truncate(sectors); + if fat.is_empty() { + return None; + } + c.fat = fat; + + let dir = c.read_chain(u32_at(data, 48), u64::MAX); + for e in dir.as_chunks::<128>().0 { + let units = (usize::from(u16_at(e, 64)) / 2).min(NAME_UNITS); + let name: Vec<u16> = (0..units) + .map(|i| u16_at(e, i * 2)) + .take_while(|&u| u != 0) + .collect(); + let size = if v4 { + u64::from(u32_at(e, 120)) | u64::from(u32_at(e, 124)) << 32 + } else { + u64::from(u32_at(e, 120)) + }; + c.entries.push(Entry { + name: String::from_utf16_lossy(&name), + kind: e[66], + left: u32_at(e, 68), + right: u32_at(e, 72), + child: u32_at(e, 76), + start: u32_at(e, 116), + size, + }); + } + // The root is entry 0 ([MS-CFB] 2.6.2); its type byte may be damaged. + c.root = c + .entries + .iter() + .position(|e| e.kind == ROOT) + .or((!c.entries.is_empty()).then_some(0))?; + let minifat = c.read_chain(u32_at(data, 60), u64::MAX); + c.minifat = (0..minifat.len() / 4) + .map(|i| u32_at(&minifat, i * 4)) + .collect(); + let root = &c.entries[c.root]; + c.mini = c.read_chain(root.start, root.size); + c.paths = c.tree_paths(); + Some(c) + } + + fn sector_count(&self) -> usize { + (self.data.len() - 1) / self.sector_size + } + + /// Sector `s`, cut short at the end of the file. + fn sector(&self, s: u32) -> Option<&'a [u8]> { + let off = (s as usize).checked_add(1)?.checked_mul(self.sector_size)?; + if off >= self.data.len() { + return None; + } + Some(&self.data[off..(off + self.sector_size).min(self.data.len())]) + } + + /// The stream's bytes in the file when its sectors follow each other + /// there, as writers lay streams out. + fn contiguous(&self, start: u32, size: u64) -> Option<&'a [u8]> { + let size = usize::try_from(size).ok()?; + let count = size.div_ceil(self.sector_size); + let first = (start as usize) + .checked_add(1)? + .checked_mul(self.sector_size)?; + if first.checked_add(size)? > self.data.len() { + return None; + } + let mut s = start as usize; + for _ in 1..count { + let next = *self.fat.get(s)? as usize; + if next != s + 1 { + return None; + } + s = next; + } + Some(&self.data[first..first + size]) + } + + fn read_chain(&self, start: u32, size: u64) -> Vec<u8> { + // Sized once: no stream is longer than the file holds, and growing + // a large one would hold two copies for a while. + let cap = size.min(self.data.len() as u64) as usize + self.sector_size; + let mut out = Vec::with_capacity(cap); + let mut seen = vec![false; self.fat.len()]; + let mut s = start; + while s <= MAXREGSECT && (out.len() as u64) < size { + let i = s as usize; + if i >= self.fat.len() || seen[i] { + break; + } + seen[i] = true; + let Some(sec) = self.sector(s) else { break }; + out.extend_from_slice(sec); + s = self.fat[i]; + } + if (out.len() as u64) > size { + out.truncate(size as usize); + } + out + } + + fn read_mini_chain(&self, start: u32, size: u64) -> Vec<u8> { + let mut out = Vec::new(); + let mut seen = vec![false; self.minifat.len()]; + let mut s = start; + while s <= MAXREGSECT && (out.len() as u64) < size { + let i = s as usize; + if i >= self.minifat.len() || seen[i] { + break; + } + seen[i] = true; + let off = i * self.mini_size; + let Some(sec) = self + .mini + .get(off..(off + self.mini_size).min(self.mini.len())) + else { + break; + }; + if sec.is_empty() { + break; + } + out.extend_from_slice(sec); + s = self.minifat[i]; + } + if (out.len() as u64) > size { + out.truncate(size as usize); + } + out + } + + /// The path of each entry, from the tree: a storage's child and that + /// child's siblings are its members. The sibling order a strict reader + /// insists on does not matter here. An entry the walk does not reach keeps + /// its bare name. + fn tree_paths(&self) -> Vec<String> { + let n = self.entries.len(); + let mut paths: Vec<Option<String>> = vec![None; n]; + let mut seen = vec![false; n]; + seen[self.root] = true; + let mut storages = vec![(self.root, String::new())]; + while let Some((storage, base)) = storages.pop() { + let mut members = vec![self.entries[storage].child]; + while let Some(m) = members.pop() { + let m = m as usize; + if m >= n || seen[m] { + continue; + } + seen[m] = true; + let e = &self.entries[m]; + members.extend([e.left, e.right]); + let path = format!("{base}/{}", e.name); + if e.kind == STORAGE { + storages.push((m, path.clone())); + } + paths[m] = Some(path); + } + } + paths + .into_iter() + .zip(&self.entries) + .map(|(p, e)| p.unwrap_or_else(|| e.name.clone())) + .collect() + } + + /// The root storage's members, by walking its tree; others are found by + /// name only when this misses them. + fn root_members(&self) -> Vec<usize> { + let mut out = vec![]; + let mut seen = vec![false; self.entries.len()]; + let mut stack = vec![self.entries[self.root].child]; + while let Some(i) = stack.pop() { + let i = i as usize; + if i >= self.entries.len() || seen[i] { + continue; + } + seen[i] = true; + out.push(i); + stack.push(self.entries[i].left); + stack.push(self.entries[i].right); + } + out + } + + /// Every stream of the directory, in the order of its entries. + pub fn streams(&self) -> impl Iterator<Item = StreamInfo<'_>> { + self.entries + .iter() + .enumerate() + .filter(|(_, e)| e.kind == STREAM) + .map(|(index, e)| StreamInfo { + index, + path: &self.paths[index], + name: &e.name, + size: e.size, + }) + } + + /// The stream at `index` of the directory ([`StreamInfo::index`]), as far + /// as its chain goes, and no further than `cap` bytes: the file borrowed + /// where the stream's sectors follow each other in it, else a copy. + pub fn read(&self, index: usize, cap: u64) -> Stream<'a> { + let Some(e) = self.entries.get(index) else { + return Stream { + data: Cow::Borrowed(&[]), + truncated: false, + }; + }; + let want = e.size.min(cap); + let data = if e.size < MINI_CUTOFF { + Cow::Owned(self.read_mini_chain(e.start, want)) + } else if let Some(d) = self.contiguous(e.start, want) { + Cow::Borrowed(d) + } else { + Cow::Owned(self.read_chain(e.start, want)) + }; + // A chain that ends early is short of the size, not cut by the cap: + // there is nothing more of it in the file. + let truncated = e.size > cap && (data.len() as u64) < e.size; + Stream { data, truncated } + } + + /// The first of `names` that names a stream at the root (else anywhere), + /// read whole or as far as its chain goes. + pub fn stream(&self, names: &[&str]) -> Option<Vec<u8>> { + self.stream_ref(names).map(Cow::into_owned) + } + + /// As [`Compound::stream`], borrowing the file's bytes when the + /// stream's sectors follow each other in it (no copy of a large + /// stream), else read into a copy. + pub fn stream_ref(&self, names: &[&str]) -> Option<Cow<'a, [u8]>> { + let members = self.root_members(); + for want in names { + let hit = members + .iter() + .copied() + .find(|&i| { + self.entries[i].kind == STREAM + && self.entries[i].name.eq_ignore_ascii_case(want) + }) + .or_else(|| { + self.entries + .iter() + .position(|e| e.kind == STREAM && e.name.eq_ignore_ascii_case(want)) + }) + .or_else(|| { + // A name whose terminator was lost, followed by junk. + self.entries.iter().position(|e| { + e.kind == STREAM + && e.name.len() > want.len() + && e.name.is_char_boundary(want.len()) + && e.name[..want.len()].eq_ignore_ascii_case(want) + && e.name[want.len()..] + .chars() + .all(|c| !c.is_ascii_alphanumeric()) + }) + }); + if let Some(i) = hit { + return Some(self.read(i, u64::MAX).data); + } + } + None + } + + /// Names of the streams and storages, for diagnostics. + pub fn names(&self) -> impl Iterator<Item = &str> { + self.entries + .iter() + .filter(|e| e.kind == STORAGE || e.kind == STREAM) + .map(|e| e.name.as_str()) + } +} + +/// A version 3 compound file holding `streams` at its root, each in the FAT +/// (padded to 4096 bytes so) or the mini stream when shorter: for the tests of +/// what reads compound files. +#[cfg(any(test, feature = "test-util"))] +pub fn build(streams: &[(&str, &[u8])]) -> Vec<u8> { + let ss = 512usize; + let mut sectors: Vec<Vec<u8>> = vec![]; + let mut fat: Vec<u32> = vec![]; + let chain = |sectors: &mut Vec<Vec<u8>>, fat: &mut Vec<u32>, data: &[u8]| -> u32 { + if data.is_empty() { + return ENDOFCHAIN; + } + let first = sectors.len() as u32; + let n = data.len().div_ceil(ss); + for k in 0..n { + let mut s = data[k * ss..((k + 1) * ss).min(data.len())].to_vec(); + s.resize(ss, 0); + sectors.push(s); + fat.push(if k + 1 == n { + ENDOFCHAIN + } else { + first + k as u32 + 1 + }); + } + first + }; + let mut mini = Vec::new(); + let mut minifat: Vec<u32> = vec![]; + let mut dirents: Vec<(String, u8, u32, u64)> = vec![]; + for (name, data) in streams { + if (data.len() as u64) < MINI_CUTOFF { + let first = (mini.len() / 64) as u32; + let n = data.len().div_ceil(64); + for k in 0..n { + let mut s = data[k * 64..((k + 1) * 64).min(data.len())].to_vec(); + s.resize(64, 0); + mini.extend_from_slice(&s); + minifat.push(if k + 1 == n { + ENDOFCHAIN + } else { + first + k as u32 + 1 + }); + } + dirents.push(( + name.to_string(), + STREAM, + if n == 0 { ENDOFCHAIN } else { first }, + data.len() as u64, + )); + } else { + let s = chain(&mut sectors, &mut fat, data); + dirents.push((name.to_string(), STREAM, s, data.len() as u64)); + } + } + let mini_start = chain(&mut sectors, &mut fat, &mini); + let mf: Vec<u8> = minifat.iter().flat_map(|v| v.to_le_bytes()).collect(); + let minifat_start = chain(&mut sectors, &mut fat, &mf); + let mut dir = Vec::new(); + let mut entry = |name: &str, kind: u8, child: u32, right: u32, start: u32, size: u64| { + let mut e = vec![0u8; 128]; + let u: Vec<u16> = name.encode_utf16().chain([0]).collect(); + for (i, x) in u.iter().enumerate() { + e[i * 2..i * 2 + 2].copy_from_slice(&x.to_le_bytes()); + } + e[64..66].copy_from_slice(&((u.len() * 2) as u16).to_le_bytes()); + e[66] = kind; + e[68..72].copy_from_slice(&NOSTREAM.to_le_bytes()); + e[72..76].copy_from_slice(&right.to_le_bytes()); + e[76..80].copy_from_slice(&child.to_le_bytes()); + e[116..120].copy_from_slice(&start.to_le_bytes()); + e[120..124].copy_from_slice(&(size as u32).to_le_bytes()); + dir.extend_from_slice(&e); + }; + entry( + "Root Entry", + ROOT, + if dirents.is_empty() { NOSTREAM } else { 1 }, + NOSTREAM, + mini_start, + mini.len() as u64, + ); + for (k, (name, kind, start, size)) in dirents.iter().enumerate() { + let right = if k + 1 < dirents.len() { + k as u32 + 2 + } else { + NOSTREAM + }; + entry(name, *kind, NOSTREAM, right, *start, *size); + } + let dir_start = chain(&mut sectors, &mut fat, &dir); + // The FAT's own sectors, marked 0xFFFFFFFD. + let n_fat = (fat.len() + 1).div_ceil(128).max(1); + let fat_first = sectors.len() as u32; + fat.extend(std::iter::repeat_n(0xFFFF_FFFD, n_fat)); + let mut fatb: Vec<u8> = fat.iter().flat_map(|v| v.to_le_bytes()).collect(); + fatb.resize(n_fat * ss, 0xFF); + for k in 0..n_fat { + sectors.push(fatb[k * ss..(k + 1) * ss].to_vec()); + } + let mut h = vec![0u8; 512]; + h[..8].copy_from_slice(&SIGNATURE); + h[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); + h[26..28].copy_from_slice(&3u16.to_le_bytes()); + h[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); + h[30..32].copy_from_slice(&9u16.to_le_bytes()); + h[32..34].copy_from_slice(&6u16.to_le_bytes()); + h[44..48].copy_from_slice(&(n_fat as u32).to_le_bytes()); + h[48..52].copy_from_slice(&dir_start.to_le_bytes()); + h[56..60].copy_from_slice(&4096u32.to_le_bytes()); + h[60..64].copy_from_slice(&minifat_start.to_le_bytes()); + h[64..68].copy_from_slice(&(minifat.len().div_ceil(128) as u32).to_le_bytes()); + h[68..72].copy_from_slice(&ENDOFCHAIN.to_le_bytes()); + for i in 0..109 { + let v = if i < n_fat { + fat_first + i as u32 + } else { + NOSTREAM + }; + h[76 + i * 4..80 + i * 4].copy_from_slice(&v.to_le_bytes()); + } + let mut out = h; + for s in sectors { + out.extend_from_slice(&s); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_fat_and_mini_streams() { + let big: Vec<u8> = (0..10_000u32).map(|i| (i % 251) as u8).collect(); + // In the strict reader's order: shorter names first. + let file = build(&[("small", b"hello"), ("Workbook", &big)]); + let c = Compound::open(&file).unwrap(); + assert_eq!(c.stream(&["WORKBOOK"]).unwrap(), big); + assert_eq!(c.stream(&["nope", "Small"]).unwrap(), b"hello"); + assert!(c.stream(&["nope"]).is_none()); + // The strict reader agrees. + let mut s = ::cfb::CompoundFile::open(std::io::Cursor::new(&file)).unwrap(); + let mut v = vec![]; + std::io::Read::read_to_end(&mut s.open_stream("/Workbook").unwrap(), &mut v).unwrap(); + assert_eq!(v, big); + } + + #[test] + fn streams_in_order_are_borrowed() { + let big: Vec<u8> = (0..10_000u32).map(|i| (i % 13) as u8).collect(); + let mut file = build(&[("Pictures", &big)]); + let c = Compound::open(&file).unwrap(); + let s = c.stream_ref(&["Pictures"]).unwrap(); + assert!(matches!(s, Cow::Borrowed(_))); + assert_eq!(s, &big[..]); + // Its chain out of order: a copy of what the chain gives. + let fat_sector = u32_at(&file, 76) as usize; + let off = (fat_sector + 1) * 512; + file[off..off + 4].copy_from_slice(&2u32.to_le_bytes()); + file[off + 8..off + 12].copy_from_slice(&1u32.to_le_bytes()); + file[off + 4..off + 8].copy_from_slice(&3u32.to_le_bytes()); + let c = Compound::open(&file).unwrap(); + let s = c.stream_ref(&["Pictures"]).unwrap(); + assert!(matches!(s, Cow::Owned(_))); + assert_eq!(s[..512], big[..512]); + assert_eq!(s[512..1024], big[1024..1536]); + assert_eq!(s[1024..1536], big[512..1024]); + } + + #[test] + fn damaged_files_give_what_is_there() { + let big: Vec<u8> = (0..10_000u32).map(|i| (i % 7) as u8).collect(); + let mut file = build(&[("Workbook", &big)]); + // Garbage in the header's counts and cutoff. + file[44..48].copy_from_slice(&0x2020_2020u32.to_le_bytes()); + file[56..60].copy_from_slice(&0x2020_2020u32.to_le_bytes()); + let c = Compound::open(&file).unwrap(); + assert_eq!(c.stream(&["Workbook"]).unwrap(), big); + // A FAT chain made a cycle: it ends where it loops. + let mut cyc = file.clone(); + let fat_sector = u32_at(&cyc, 76) as usize; + let off = (fat_sector + 1) * 512; + cyc[off + 4..off + 8].copy_from_slice(&0u32.to_le_bytes()); + let c = Compound::open(&cyc).unwrap(); + assert_eq!(c.stream(&["Workbook"]).unwrap(), big[..1024]); + // Truncated through the stream: no panic, no directory. + for n in [600, 3000, file.len() - 100] { + let _ = Compound::open(&file[..n]).and_then(|c| c.stream(&["Workbook"])); + } + } + + #[test] + fn damaged_root_type_and_unterminated_names() { + let big: Vec<u8> = (0..10_000u32).map(|i| (i % 11) as u8).collect(); + let mut file = build(&[("Workbook", &big)]); + let dir = (u32_at(&file, 48) as usize + 1) * 512; + // The root's type byte damaged. + file[dir + 66] = 0xF8; + // The stream's name without its terminator, padded with junk. + let e = dir + 128; + for i in 8..32 { + file[e + i * 2..e + i * 2 + 2].copy_from_slice(&0x2020u16.to_le_bytes()); + } + file[e + 64..e + 66].copy_from_slice(&64u16.to_le_bytes()); + let c = Compound::open(&file).unwrap(); + assert_eq!(c.stream(&["Workbook"]).unwrap(), big); + assert!(c.stream(&["Work"]).is_none()); + } + + /// The scanner's view: every stream with its path, each read under a cap + /// that says when it cut one. + #[test] + fn streams_are_listed_with_their_paths_and_read_under_a_cap() { + let big: Vec<u8> = (0..10_000u32).map(|i| (i % 5) as u8).collect(); + let file = build(&[("a", b"hello"), ("Big", &big)]); + let c = Compound::open(&file).unwrap(); + let listed: Vec<_> = c.streams().map(|s| (s.path.to_string(), s.size)).collect(); + assert_eq!( + listed, + [("/a".to_string(), 5), ("/Big".to_string(), 10_000)] + ); + let big_at = c.streams().find(|s| s.name == "Big").unwrap().index; + let cut = c.read(big_at, 4000); + assert!(cut.truncated); + assert_eq!(&cut.data[..], &big[..4000]); + let whole = c.read(big_at, 10_000); + assert!(!whole.truncated); + assert_eq!(&whole.data[..], &big[..]); + // A chain that ends early is short, not cut by the cap. + let mut file = file; + let dir = (u32_at(&file, 48) as usize + 1) * 512; + file[dir + 128 * 2 + 120..dir + 128 * 2 + 124].copy_from_slice(&20_000u32.to_le_bytes()); + let c = Compound::open(&file).unwrap(); + let short = c.read(big_at, u64::MAX); + assert!(!short.truncated); + assert!(short.data.len() < 20_000); + } + + /// Storages give their streams a path, and an entry no storage reaches + /// keeps its bare name. + #[test] + fn paths_follow_the_tree_of_storages() { + let file = std::io::Cursor::new(Vec::new()); + let mut w = ::cfb::CompoundFile::create(file).unwrap(); + w.create_storage("/Macros").unwrap(); + w.create_storage("/Macros/VBA").unwrap(); + for (path, body) in [("/Macros/VBA/dir", &b"x"[..]), ("/top", &b"y"[..])] { + std::io::Write::write_all(&mut w.create_stream(path).unwrap(), body).unwrap(); + } + w.flush().unwrap(); + let bytes = w.into_inner().into_inner(); + let c = Compound::open(&bytes).unwrap(); + let mut paths: Vec<_> = c.streams().map(|s| s.path.to_string()).collect(); + paths.sort(); + assert_eq!(paths, ["/Macros/VBA/dir", "/top"]); + assert_eq!(c.stream(&["top"]).unwrap(), b"y"); + assert_eq!(c.stream(&["dir"]).unwrap(), b"x"); + } +} diff --git a/crates/exav-unpack/src/formats/charset.rs b/crates/exav-unpack/src/formats/charset.rs new file mode 100644 index 00000000..063abede --- /dev/null +++ b/crates/exav-unpack/src/formats/charset.rs @@ -0,0 +1,93 @@ +//! Text in a Windows code page number (an XLS CODEPAGE record's, a font's +//! character set): Windows and Mac code pages through encoding_rs, DOS (OEM) +//! and the remaining Mac ones through tables of our own. + +use super::charset_tables::TABLES; +use encoding_rs::Encoding; + +enum Decoder { + Utf16, + Encoding(&'static Encoding), + Table(&'static [u16; 256]), +} + +fn decoder(cp: u16) -> Option<Decoder> { + use encoding_rs::{ + BIG5, EUC_KR, GBK, IBM866, MACINTOSH, SHIFT_JIS, WINDOWS_874, WINDOWS_1250, WINDOWS_1251, + WINDOWS_1252, WINDOWS_1253, WINDOWS_1254, WINDOWS_1255, WINDOWS_1256, WINDOWS_1257, + WINDOWS_1258, X_MAC_CYRILLIC, + }; + Some(Decoder::Encoding(match cp { + 1200 => return Some(Decoder::Utf16), + // US-ASCII, and 32769, which BIFF2 to BIFF4 write for Windows 1252. + 367 | 1252 | 0x8001 => WINDOWS_1252, + 866 => IBM866, + 874 => WINDOWS_874, + 932 => SHIFT_JIS, + 936 => GBK, + 949 => EUC_KR, + 950 => BIG5, + 1250 => WINDOWS_1250, + 1251 => WINDOWS_1251, + 1253 => WINDOWS_1253, + 1254 => WINDOWS_1254, + 1255 => WINDOWS_1255, + 1256 => WINDOWS_1256, + 1257 => WINDOWS_1257, + 1258 => WINDOWS_1258, + // Mac Roman; 32768 is how BIFF names it. + 10000 | 0x8000 => MACINTOSH, + 10007 => X_MAC_CYRILLIC, + _ => { + return TABLES + .iter() + .find(|t| t.0 == cp) + .map(|t| Decoder::Table(&t.1)); + } + })) +} + +/// Whether `cp` is a code page this module decodes. +pub fn known(cp: u16) -> bool { + decoder(cp).is_some() +} + +/// `b` in code page `cp`; an unknown code page is read as Windows 1252. +pub fn decode(b: &[u8], cp: u16) -> String { + match decoder(cp).unwrap_or(Decoder::Encoding(encoding_rs::WINDOWS_1252)) { + Decoder::Utf16 => { + let u: Vec<u16> = b + .as_chunks::<2>() + .0 + .iter() + .map(|x| u16::from_le_bytes(*x)) + .collect(); + String::from_utf16_lossy(&u) + } + Decoder::Encoding(e) => e.decode_without_bom_handling(b).0.into_owned(), + Decoder::Table(t) => { + let u: Vec<u16> = b.iter().map(|&x| t[x as usize]).collect(); + String::from_utf16_lossy(&u) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dos_and_windows_pages() { + // 0x82 is é in DOS Latin 1 and Latin US, a low quotation mark in 1252. + assert_eq!(decode(&[0x82], 850), "é"); + assert_eq!(decode(&[0x82], 437), "é"); + assert_eq!(decode(&[0x82], 1252), "\u{201A}"); + assert_eq!(decode(&[0xD5], 858), "€"); + assert_eq!(decode(&[0x8E], 866), "О"); + assert_eq!(decode(&[0x80], 10029), "Ä"); + assert_eq!(decode(b"A\0", 1200), "A"); + assert!(known(437) && known(869) && !known(1361)); + // An unknown page reads as 1252. + assert_eq!(decode(&[0xE9], 1361), "é"); + } +} diff --git a/crates/exav-unpack/src/formats/charset_tables.rs b/crates/exav-unpack/src/formats/charset_tables.rs new file mode 100644 index 00000000..18ead152 --- /dev/null +++ b/crates/exav-unpack/src/formats/charset_tables.rs @@ -0,0 +1,386 @@ +// Generated by scripts/codepages.py from Python's codecs. Do not edit. + +/// (code page, its 256 characters). +#[rustfmt::skip] +pub(crate) static TABLES: &[(u16, [u16; 256])] = &[ + // cp437 + (437, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp720 + (720, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x0080, 0x0081, 0x00E9, 0x00E2, 0x0084, 0x00E0, 0x0086, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x008D, 0x008E, 0x008F, + 0x0090, 0x0651, 0x0652, 0x00F4, 0x00A4, 0x0640, 0x00FB, 0x00F9, 0x0621, 0x0622, 0x0623, 0x0624, 0x00A3, 0x0625, 0x0626, 0x0627, + 0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x0636, 0x0637, 0x0638, 0x0639, 0x063A, 0x0641, 0x00B5, 0x0642, 0x0643, 0x0644, 0x0645, 0x0646, 0x0647, 0x0648, 0x0649, 0x064A, + 0x2261, 0x064B, 0x064C, 0x064D, 0x064E, 0x064F, 0x0650, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp737 + (737, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, + 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, + 0x03B9, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388, 0x0389, 0x038A, 0x038C, 0x038E, + 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp775 + (775, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x0106, 0x00FC, 0x00E9, 0x0101, 0x00E4, 0x0123, 0x00E5, 0x0107, 0x0142, 0x0113, 0x0156, 0x0157, 0x012B, 0x0179, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x014D, 0x00F6, 0x0122, 0x00A2, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x00A4, + 0x0100, 0x012A, 0x00F3, 0x017B, 0x017C, 0x017A, 0x201D, 0x00A6, 0x00A9, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x0141, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x0104, 0x010C, 0x0118, 0x0116, 0x2563, 0x2551, 0x2557, 0x255D, 0x012E, 0x0160, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0172, 0x016A, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x017D, + 0x0105, 0x010D, 0x0119, 0x0117, 0x012F, 0x0161, 0x0173, 0x016B, 0x017E, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x00D3, 0x00DF, 0x014C, 0x0143, 0x00F5, 0x00D5, 0x00B5, 0x0144, 0x0136, 0x0137, 0x013B, 0x013C, 0x0146, 0x0112, 0x0145, 0x2019, + 0x00AD, 0x00B1, 0x201C, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x201E, 0x00B0, 0x2219, 0x00B7, 0x00B9, 0x00B3, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp850 + (850, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1, 0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, + 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518, 0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, + 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9, 0x00FD, 0x00DD, 0x00AF, 0x00B4, + 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp852 + (852, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, + 0x00C9, 0x0139, 0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1, 0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, + 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518, 0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, + 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170, 0x00FD, 0x00DD, 0x0163, 0x00B4, + 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159, 0x25A0, 0x00A0, + ]), + // cp855 + (855, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x0452, 0x0402, 0x0453, 0x0403, 0x0451, 0x0401, 0x0454, 0x0404, 0x0455, 0x0405, 0x0456, 0x0406, 0x0457, 0x0407, 0x0458, 0x0408, + 0x0459, 0x0409, 0x045A, 0x040A, 0x045B, 0x040B, 0x045C, 0x040C, 0x045E, 0x040E, 0x045F, 0x040F, 0x044E, 0x042E, 0x044A, 0x042A, + 0x0430, 0x0410, 0x0431, 0x0411, 0x0446, 0x0426, 0x0434, 0x0414, 0x0435, 0x0415, 0x0444, 0x0424, 0x0433, 0x0413, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x0445, 0x0425, 0x0438, 0x0418, 0x2563, 0x2551, 0x2557, 0x255D, 0x0439, 0x0419, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x043A, 0x041A, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, + 0x043B, 0x041B, 0x043C, 0x041C, 0x043D, 0x041D, 0x043E, 0x041E, 0x043F, 0x2518, 0x250C, 0x2588, 0x2584, 0x041F, 0x044F, 0x2580, + 0x042F, 0x0440, 0x0420, 0x0441, 0x0421, 0x0442, 0x0422, 0x0443, 0x0423, 0x0436, 0x0416, 0x0432, 0x0412, 0x044C, 0x042C, 0x2116, + 0x00AD, 0x044B, 0x042B, 0x0437, 0x0417, 0x0448, 0x0428, 0x044D, 0x042D, 0x0449, 0x0429, 0x0447, 0x0427, 0x00A7, 0x25A0, 0x00A0, + ]), + // cp857 + (857, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1, 0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, + 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518, 0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, + 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9, 0x00EC, 0x00FF, 0x00AF, 0x00B4, + 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp858 + (858, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1, 0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, + 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x20AC, 0x00CD, 0x00CE, 0x00CF, 0x2518, 0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, + 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9, 0x00FD, 0x00DD, 0x00AF, 0x00B4, + 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp860 + (860, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E3, 0x00E0, 0x00C1, 0x00E7, 0x00EA, 0x00CA, 0x00E8, 0x00CD, 0x00D4, 0x00EC, 0x00C3, 0x00C2, + 0x00C9, 0x00C0, 0x00C8, 0x00F4, 0x00F5, 0x00F2, 0x00DA, 0x00F9, 0x00CC, 0x00D5, 0x00DC, 0x00A2, 0x00A3, 0x00D9, 0x20A7, 0x00D3, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00D2, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp861 + (861, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00D0, 0x00F0, 0x00DE, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00FE, 0x00FB, 0x00DD, 0x00FD, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x20A7, 0x0192, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00C1, 0x00CD, 0x00D3, 0x00DA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp862 + (862, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB, 0x05DC, 0x05DD, 0x05DE, 0x05DF, + 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp863 + (863, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00C2, 0x00E0, 0x00B6, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x2017, 0x00C0, 0x00A7, + 0x00C9, 0x00C8, 0x00CA, 0x00F4, 0x00CB, 0x00CF, 0x00FB, 0x00F9, 0x00A4, 0x00D4, 0x00DC, 0x00A2, 0x00A3, 0x00D9, 0x00DB, 0x0192, + 0x00A6, 0x00B4, 0x00F3, 0x00FA, 0x00A8, 0x00B8, 0x00B3, 0x00AF, 0x00CE, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00BE, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp864 + (864, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x066A, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00B0, 0x00B7, 0x2219, 0x221A, 0x2592, 0x2500, 0x2502, 0x253C, 0x2524, 0x252C, 0x251C, 0x2534, 0x2510, 0x250C, 0x2514, 0x2518, + 0x03B2, 0x221E, 0x03C6, 0x00B1, 0x00BD, 0x00BC, 0x2248, 0x00AB, 0x00BB, 0xFEF7, 0xFEF8, 0xFFFD, 0xFFFD, 0xFEFB, 0xFEFC, 0xFFFD, + 0x00A0, 0x00AD, 0xFE82, 0x00A3, 0x00A4, 0xFE84, 0xFFFD, 0xFFFD, 0xFE8E, 0xFE8F, 0xFE95, 0xFE99, 0x060C, 0xFE9D, 0xFEA1, 0xFEA5, + 0x0660, 0x0661, 0x0662, 0x0663, 0x0664, 0x0665, 0x0666, 0x0667, 0x0668, 0x0669, 0xFED1, 0x061B, 0xFEB1, 0xFEB5, 0xFEB9, 0x061F, + 0x00A2, 0xFE80, 0xFE81, 0xFE83, 0xFE85, 0xFECA, 0xFE8B, 0xFE8D, 0xFE91, 0xFE93, 0xFE97, 0xFE9B, 0xFE9F, 0xFEA3, 0xFEA7, 0xFEA9, + 0xFEAB, 0xFEAD, 0xFEAF, 0xFEB3, 0xFEB7, 0xFEBB, 0xFEBF, 0xFEC1, 0xFEC5, 0xFECB, 0xFECF, 0x00A6, 0x00AC, 0x00F7, 0x00D7, 0xFEC9, + 0x0640, 0xFED3, 0xFED7, 0xFEDB, 0xFEDF, 0xFEE3, 0xFEE7, 0xFEEB, 0xFEED, 0xFEEF, 0xFEF3, 0xFEBD, 0xFECC, 0xFECE, 0xFECD, 0xFEE1, + 0xFE7D, 0x0651, 0xFEE5, 0xFEE9, 0xFEEC, 0xFEF0, 0xFEF2, 0xFED0, 0xFED5, 0xFEF5, 0xFEF6, 0xFEDD, 0xFED9, 0xFEF1, 0x25A0, 0xFFFD, + ]), + // cp865 + (865, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x20A7, 0x0192, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00A4, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, + ]), + // cp869 + (869, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0386, 0xFFFD, 0x00B7, 0x00AC, 0x00A6, 0x2018, 0x2019, 0x0388, 0x2015, 0x0389, + 0x038A, 0x03AA, 0x038C, 0xFFFD, 0xFFFD, 0x038E, 0x03AB, 0x00A9, 0x038F, 0x00B2, 0x00B3, 0x03AC, 0x00A3, 0x03AD, 0x03AE, 0x03AF, + 0x03CA, 0x0390, 0x03CC, 0x03CD, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x00BD, 0x0398, 0x0399, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x039A, 0x039B, 0x039C, 0x039D, 0x2563, 0x2551, 0x2557, 0x255D, 0x039E, 0x039F, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x03A0, 0x03A1, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x03A3, + 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x2518, 0x250C, 0x2588, 0x2584, 0x03B4, 0x03B5, 0x2580, + 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x0384, + 0x00AD, 0x00B1, 0x03C5, 0x03C6, 0x03C7, 0x00A7, 0x03C8, 0x0385, 0x00B0, 0x00A8, 0x03C9, 0x03CB, 0x03B0, 0x03CE, 0x25A0, 0x00A0, + ]), + // mac_greek + (10006, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C4, 0x00B9, 0x00B2, 0x00C9, 0x00B3, 0x00D6, 0x00DC, 0x0385, 0x00E0, 0x00E2, 0x00E4, 0x0384, 0x00A8, 0x00E7, 0x00E9, 0x00E8, + 0x00EA, 0x00EB, 0x00A3, 0x2122, 0x00EE, 0x00EF, 0x2022, 0x00BD, 0x2030, 0x00F4, 0x00F6, 0x00A6, 0x20AC, 0x00F9, 0x00FB, 0x00FC, + 0x2020, 0x0393, 0x0394, 0x0398, 0x039B, 0x039E, 0x03A0, 0x00DF, 0x00AE, 0x00A9, 0x03A3, 0x03AA, 0x00A7, 0x2260, 0x00B0, 0x00B7, + 0x0391, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x0392, 0x0395, 0x0396, 0x0397, 0x0399, 0x039A, 0x039C, 0x03A6, 0x03AB, 0x03A8, 0x03A9, + 0x03AC, 0x039D, 0x00AC, 0x039F, 0x03A1, 0x2248, 0x03A4, 0x00AB, 0x00BB, 0x2026, 0x00A0, 0x03A5, 0x03A7, 0x0386, 0x0388, 0x0153, + 0x2013, 0x2015, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x0389, 0x038A, 0x038C, 0x038E, 0x03AD, 0x03AE, 0x03AF, 0x03CC, 0x038F, + 0x03CD, 0x03B1, 0x03B2, 0x03C8, 0x03B4, 0x03B5, 0x03C6, 0x03B3, 0x03B7, 0x03B9, 0x03BE, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BF, + 0x03C0, 0x03CE, 0x03C1, 0x03C3, 0x03C4, 0x03B8, 0x03C9, 0x03C2, 0x03C7, 0x03C5, 0x03B6, 0x03CA, 0x03CB, 0x0390, 0x03B0, 0x00AD, + ]), + // mac_latin2 + (10029, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C4, 0x0100, 0x0101, 0x00C9, 0x0104, 0x00D6, 0x00DC, 0x00E1, 0x0105, 0x010C, 0x00E4, 0x010D, 0x0106, 0x0107, 0x00E9, 0x0179, + 0x017A, 0x010E, 0x00ED, 0x010F, 0x0112, 0x0113, 0x0116, 0x00F3, 0x0117, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x011A, 0x011B, 0x00FC, + 0x2020, 0x00B0, 0x0118, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x0119, 0x00A8, 0x2260, 0x0123, 0x012E, + 0x012F, 0x012A, 0x2264, 0x2265, 0x012B, 0x0136, 0x2202, 0x2211, 0x0142, 0x013B, 0x013C, 0x013D, 0x013E, 0x0139, 0x013A, 0x0145, + 0x0146, 0x0143, 0x00AC, 0x221A, 0x0144, 0x0147, 0x2206, 0x00AB, 0x00BB, 0x2026, 0x00A0, 0x0148, 0x0150, 0x00D5, 0x0151, 0x014C, + 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x014D, 0x0154, 0x0155, 0x0158, 0x2039, 0x203A, 0x0159, 0x0156, + 0x0157, 0x0160, 0x201A, 0x201E, 0x0161, 0x015A, 0x015B, 0x00C1, 0x0164, 0x0165, 0x00CD, 0x017D, 0x017E, 0x016A, 0x00D3, 0x00D4, + 0x016B, 0x016E, 0x00DA, 0x016F, 0x0170, 0x0171, 0x0172, 0x0173, 0x00DD, 0x00FD, 0x0137, 0x017B, 0x0141, 0x017C, 0x0122, 0x02C7, + ]), + // mac_iceland + (10079, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, + 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, + 0x00DD, 0x00B0, 0x00A2, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, + 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, + 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, 0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, + 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178, 0x2044, 0x20AC, 0x00D0, 0x00F0, 0x00DE, 0x00FE, + 0x00FD, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, 0x00CC, 0x00D3, 0x00D4, + 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7, + ]), + // mac_turkish + (10081, [ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, + 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, + 0x2020, 0x00B0, 0x00A2, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, + 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, + 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, 0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, + 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178, 0x011E, 0x011F, 0x0130, 0x0131, 0x015E, 0x015F, + 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, 0x00CC, 0x00D3, 0x00D4, + 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0xF8A0, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7, + ]), +]; diff --git a/crates/exav-unpack/src/formats/chm.rs b/crates/exav-unpack/src/formats/chm.rs index fc63664e..7abc5a5d 100644 --- a/crates/exav-unpack/src/formats/chm.rs +++ b/crates/exav-unpack/src/formats/chm.rs @@ -105,10 +105,10 @@ fn parse_chm(d: &[u8]) -> Option<ChmDir> { } // Header-section table follows the 0x38-byte ITSF header. let off_hs1 = crate::bytes::to_usize(u64at(d, 0x38 + 0x10)?); // ITSP directory - // The directory offset must land inside the file. Bounding it here also keeps - // every `off_hs1 + <small const>` read below from overflowing `usize` on a - // crafted 64-bit offset (a `d.len() <= isize::MAX` invariant makes the adds - // safe once `off_hs1 < d.len()`). + // The directory offset must land inside the file. Bounding it here also keeps + // every `off_hs1 + <small const>` read below from overflowing `usize` on a + // crafted 64-bit offset (a `d.len() <= isize::MAX` invariant makes the adds + // safe once `off_hs1 < d.len()`). if off_hs1 >= d.len() { return None; } @@ -384,31 +384,30 @@ pub(crate) fn extract_chm<R>( // Bounded to what the budget still allows so a huge declared length can't OOM. let mut decompressed: Vec<u8> = Vec::new(); let mut frames_ok: Vec<bool> = Vec::new(); - if let Some(content_e) = content { - if let Some(content_bytes) = read_sec0(data, dir.sec0_offset, content_e) { - let params = control - .and_then(|c| read_sec0(data, dir.sec0_offset, c)) - .and_then(parse_control); - // Reset table gives the frame offsets + true uncompressed length; - // fall back to SpanInfo (length only) or the content length itself. - let rt = rtable - .and_then(|r| read_sec0(data, dir.sec0_offset, r)) - .and_then(parse_reset_table) - .or_else(|| { - spaninfo - .and_then(|s| read_sec0(data, dir.sec0_offset, s)) - .and_then(|b| u64at(b, 0)) - .map(|uncomp_len| ResetTable { - uncomp_len, - offsets: Vec::new(), - }) - }); - if let (Some(params), Some(rt)) = (params, rt) { - let cap = budget.reserve().unwrap_or(0) as usize; - if cap > 0 { - (decompressed, frames_ok) = - decompress_content(content_bytes, ¶ms, &rt, cap); - } + if let Some(content_e) = content + && let Some(content_bytes) = read_sec0(data, dir.sec0_offset, content_e) + { + let params = control + .and_then(|c| read_sec0(data, dir.sec0_offset, c)) + .and_then(parse_control); + // Reset table gives the frame offsets + true uncompressed length; + // fall back to SpanInfo (length only) or the content length itself. + let rt = rtable + .and_then(|r| read_sec0(data, dir.sec0_offset, r)) + .and_then(parse_reset_table) + .or_else(|| { + spaninfo + .and_then(|s| read_sec0(data, dir.sec0_offset, s)) + .and_then(|b| u64at(b, 0)) + .map(|uncomp_len| ResetTable { + uncomp_len, + offsets: Vec::new(), + }) + }); + if let (Some(params), Some(rt)) = (params, rt) { + let cap = budget.reserve().unwrap_or(0) as usize; + if cap > 0 { + (decompressed, frames_ok) = decompress_content(content_bytes, ¶ms, &rt, cap); } } } diff --git a/crates/exav-unpack/src/formats/cpio.rs b/crates/exav-unpack/src/formats/cpio.rs index 584f051d..346a62be 100644 --- a/crates/exav-unpack/src/formats/cpio.rs +++ b/crates/exav-unpack/src/formats/cpio.rs @@ -4,6 +4,7 @@ //! the old POSIX "portable" format (`070707`, octal fields). Member data is //! emitted for the engine to recurse into. +use crate::stream::Region; use crate::*; use std::io::{Read, Seek, SeekFrom}; @@ -39,9 +40,7 @@ pub(crate) fn walk<T>( /// cpio headers (newc/odc/old-binary) and return each file member as /// `(name, data_offset, size)`. Mirrors [`extract_cpio`]'s boundaries but never /// buffers member data. -pub(crate) fn stream_offsets<R: Read + Seek>( - source: &mut R, -) -> Result<Vec<(String, u64, u64)>, LimitHit> { +pub(crate) fn stream_offsets<R: Read + Seek>(source: &mut R) -> Result<Vec<Region>, LimitHit> { let mut magic = [0u8; 6]; source .seek(SeekFrom::Start(0)) @@ -66,49 +65,113 @@ enum Fields { Bin { swap: bool }, } +impl Fields { + /// The header magics a following header may start with, to find the next + /// one after bytes that are not a header (the binary format's two bytes + /// are too short to search for). + fn magics(&self) -> &'static [&'static [u8]] { + match self { + Fields::Newc => &[b"070701", b"070702"], + Fields::Odc => &[b"070707"], + Fields::Bin { .. } => &[], + } + } +} + +/// The first offset at or after `from` where one of `magics` starts. +fn find_magic<R: Read + Seek>( + source: &mut R, + from: u64, + len: u64, + magics: &[&[u8]], +) -> Result<Option<u64>, LimitHit> { + const CHUNK: u64 = 1 << 20; + let mut at = from; + while at < len { + let buf = crate::read_at(source, at, (len - at).min(CHUNK) as usize)?; + let found = magics + .iter() + .filter_map(|m| memchr::memmem::find(&buf, m)) + .min(); + if let Some(p) = found { + return Ok(Some(at + p as u64)); + } + if (buf.len() as u64) < CHUNK || at + buf.len() as u64 >= len { + break; + } + // A magic across the seam is seen whole by the next read. + at += buf.len() as u64 - 5; + } + Ok(None) +} + fn stream_walk<R: Read + Seek>( source: &mut R, hdr_len: usize, fields: Fields, -) -> Result<Vec<(String, u64, u64)>, LimitHit> { +) -> Result<Vec<Region>, LimitHit> { + let len = source + .seek(SeekFrom::End(0)) + .map_err(|e| LimitHit::corrupt(format!("cpio: {e}")))?; let mut out = Vec::new(); let mut pos = 0u64; + // Bytes that are not a header are scanned as they lie, as a member of their + // own, and the walk goes on at the next header there is. GNU cpio skips them + // the same way and extracts what follows, and the Linux kernel unpacks the + // archives of an initramfs one after the other, each ending in its own + // trailer. + let junk = |source: &mut R, out: &mut Vec<Region>, from: u64, magics: &[&[u8]]| { + let rest = crate::read_at(source, from, 4096)?; + if rest.iter().all(|&b| b == 0) { + return Ok::<Option<u64>, LimitHit>(None); + } + let next = find_magic(source, from + 1, len, magics)?.unwrap_or(len); + out.push(Region::Member( + format!("<cpio-from-{from}>"), + from, + next - from, + )); + Ok((next < len).then_some(next)) + }; loop { - let h = crate::read_at(source, pos, hdr_len)?; - if h.len() < hdr_len { + if pos >= len { break; } - let (namesize, filesize, magic_ok) = match &fields { - Fields::Newc => ( - hex(&h[94..102]) as u64, - hex(&h[54..62]) as u64, - &h[0..6] == b"070701" || &h[0..6] == b"070702", - ), - Fields::Odc => ( - oct(&h[59..65]) as u64, - oct(&h[65..76]) as u64, - &h[0..6] == b"070707", - ), - Fields::Bin { swap } => { - let u16at = |p: usize| -> u64 { - let v = u16::from_le_bytes([h[p], h[p + 1]]); - (if *swap { v.swap_bytes() } else { v }) as u64 - }; - ( - u16at(20), - (u16at(22) << 16) | u16at(24), - u16at(0) == 0o070707, - ) + let h = crate::read_at(source, pos, hdr_len)?; + let parsed = if h.len() < hdr_len { + None + } else { + match &fields { + Fields::Newc => (|| { + (&h[0..6] == b"070701" || &h[0..6] == b"070702") + .then_some(()) + .and(Some((hex(&h[94..102])?, hex(&h[54..62])?))) + })(), + Fields::Odc => (|| { + (&h[0..6] == b"070707") + .then_some(()) + .and(Some((oct(&h[59..65])?, oct(&h[65..76])?))) + })(), + Fields::Bin { swap } => { + let u16at = |p: usize| -> u64 { + let v = u16::from_le_bytes([h[p], h[p + 1]]); + (if *swap { v.swap_bytes() } else { v }) as u64 + }; + (u16at(0) == 0o070707).then(|| (u16at(20), (u16at(22) << 16) | u16at(24))) + } + } + }; + let Some((namesize, filesize)) = parsed.filter(|(n, _)| *n > 0) else { + match junk(source, &mut out, pos, fields.magics())? { + Some(next) => { + pos = next; + continue; + } + None => break, } }; - if !magic_ok { - break; - } let name_start = pos + hdr_len as u64; let name = read_name(source, name_start, namesize)?; - if name == TRAILER { - break; - } // Data start + next-header advance mirror the buffered extractor: // newc pads header+name and data to 4 bytes; odc has no padding; the old // binary format pads the name and the data to even boundaries. @@ -126,8 +189,42 @@ fn stream_walk<R: Read + Seek>( (ds, ds + filesize + (filesize & 1)) } }; + if name == TRAILER { + // What follows is another archive (an initramfs is laid out as + // several), padding, or neither. Padding of zeros is skipped. + let mut at = next.min(len); + loop { + let peek = crate::read_at(source, at, 4096)?; + let zeros = peek.iter().take_while(|&&b| b == 0).count(); + if zeros == 0 || zeros < peek.len() { + at += zeros as u64; + break; + } + at += zeros as u64; + } + if at >= len { + break; + } + let peek = crate::read_at(source, at, 6)?; + let header = match &fields { + Fields::Bin { swap: false } => peek.starts_with(&[0xc7, 0x71]), + Fields::Bin { swap: true } => peek.starts_with(&[0x71, 0xc7]), + _ => fields.magics().iter().any(|m| peek.starts_with(m)), + }; + if header { + pos = at; + continue; + } + match junk(source, &mut out, at, fields.magics())? { + Some(again) => { + pos = again; + continue; + } + None => break, + } + } if filesize > 0 { - out.push((name, data_start, filesize)); + out.push(Region::Member(name, data_start, filesize)); } if next <= pos { break; // no forward progress → stop rather than loop @@ -137,11 +234,17 @@ fn stream_walk<R: Read + Seek>( Ok(out) } -fn hex(field: &[u8]) -> usize { - usize::from_str_radix(std::str::from_utf8(field).unwrap_or("0").trim(), 16).unwrap_or(0) +/// A header's hexadecimal field, `None` where a byte is not a digit. +fn hex(field: &[u8]) -> Option<u64> { + std::str::from_utf8(field) + .ok() + .and_then(|s| u64::from_str_radix(s, 16).ok()) } -fn oct(field: &[u8]) -> usize { - usize::from_str_radix(std::str::from_utf8(field).unwrap_or("0").trim(), 8).unwrap_or(0) +/// A header's octal field, `None` where a byte is not a digit. +fn oct(field: &[u8]) -> Option<u64> { + std::str::from_utf8(field) + .ok() + .and_then(|s| u64::from_str_radix(s, 8).ok()) } #[cfg(test)] diff --git a/crates/exav-unpack/src/formats/cramfs.rs b/crates/exav-unpack/src/formats/cramfs.rs new file mode 100644 index 00000000..cea37aff --- /dev/null +++ b/crates/exav-unpack/src/formats/cramfs.rs @@ -0,0 +1,355 @@ +//! cramfs: the files of an image, read where they lie. +//! +//! The small read-only filesystem of embedded Linux boot images and initial +//! ramdisks. The image is a `Read + Seek`, read where the walk goes: the +//! superblock, each directory's entries and each file's blocks. Only a file is +//! ever held, bounded by the buffer limit. +//! +//! ```text +//! superblock (76 bytes) magic and signature, the root inode +//! inode (12 bytes) mode, size, and an offset in words: a directory's +//! entries, or a file's table of block ends +//! directory inodes, each followed by its name, padded to words +//! file a table of where each 4 KiB block ends, then the +//! blocks, each deflated (zlib); a block that is +//! empty is a hole +//! ``` +//! +//! Both byte orders are read, and an image that begins 512 bytes in (room for +//! boot code). A block that is stored as it is, as the extended block +//! pointers allow, is read; one that lies elsewhere than after the one before +//! it is reported. A symbolic link or a device node holds no file's bytes and +//! is not emitted. The image's checksum is not checked. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] + +use std::collections::HashSet; +use std::io::{Read, Seek}; + +use super::vol::{Attrs, Emit, Fill, Got, Step, Vol, be32, give, le32, read_file}; +use crate::source::{ByteSource, Reader}; +use crate::stream::Visit; +use crate::{Budget, LimitHit}; + +/// The most directories a path passes through. +const MAX_DEPTH: usize = 256; + +/// Bytes in a block of a file. +const BLOCK: usize = 4096; + +/// Superblock flag: block pointers carry flags of their own. +const EXT_BLOCK_POINTERS: u32 = 0x0000_0800; +/// Flags of an extended block pointer. +const BLK_UNCOMPRESSED: u32 = 1 << 31; +const BLK_DIRECT: u32 = 1 << 30; + +const S_IFMT: u32 = 0o170000; +const S_IFREG: u32 = 0o100000; +const S_IFDIR: u32 = 0o040000; + +/// Whether `data`, the start of an image, holds a cramfs superblock, at the +/// start or 512 bytes in: its magic in either byte order and its signature. +pub(crate) fn is_cramfs(data: &[u8]) -> bool { + super::sniff::is(data, crate::Format::Cramfs) +} + +/// An inode. +#[derive(Clone)] +struct Inode { + mode: u32, + size: u64, + /// Where its directory entries or its table of block ends are, in bytes + /// from the start of the image, and how long its name is, in bytes. + offset: u64, + name_len: usize, +} + +/// What the superblock says of the image. +struct Cram { + be: bool, + flags: u32, + root: Inode, +} + +impl Cram { + fn word(&self, d: &[u8], at: usize) -> u32 { + if self.be { be32(d, at) } else { le32(d, at) } + } + + /// The inode in the 12 bytes `d`. The fields are bits of words laid out as + /// the machine that made the image lays them out: from the low bit on in + /// the small-endian image, from the high bit on in the other. + fn inode(&self, d: &[u8]) -> Inode { + let (w0, w1, w2) = (self.word(d, 0), self.word(d, 4), self.word(d, 8)); + let (mode, size, name_len, offset) = if self.be { + (w0 >> 16, w1 >> 8, w2 >> 26, w2 & 0x03FF_FFFF) + } else { + (w0 & 0xFFFF, w1 & 0x00FF_FFFF, w2 & 63, w2 >> 6) + }; + Inode { + mode, + size: u64::from(size), + offset: u64::from(offset).saturating_mul(4), + name_len: crate::bytes::to_usize(u64::from(name_len).saturating_mul(4)), + } + } + + /// The entries of the directory `dir`: name and inode. The flag is false + /// where the directory is damaged. `left` counts the bytes of directories + /// the walk may still read. + fn entries( + &self, + v: &mut Vol, + dir: &Inode, + left: &mut u64, + limit: usize, + ) -> (Vec<(String, Inode)>, bool) { + let mut out = Vec::new(); + if dir.size > *left { + return (out, false); + } + *left = left.saturating_sub(dir.size); + let Some(list) = v.at(dir.offset, crate::bytes::to_usize(dir.size)) else { + return (out, false); + }; + let mut pos = 0usize; + while pos < list.len() { + let Some(head) = crate::bytes::at(&list, pos, 12) else { + return (out, false); + }; + let inode = self.inode(head); + let start = pos.saturating_add(12); + let Some(name) = crate::bytes::at(&list, start, inode.name_len) else { + return (out, false); + }; + // The name is null-padded to a whole number of words. + let end = name.iter().position(|&b| b == 0).unwrap_or(name.len()); + pos = start.saturating_add(inode.name_len); + if out.len() >= limit { + return (out, true); + } + out.push((String::from_utf8_lossy(&name[..end]).into_owned(), inode)); + } + (out, true) + } + + /// The bytes of a file: held when they are at most `cap`, else read as + /// they go. + fn file<'a>(&self, v: &'a mut Vol<'_>, f: &Inode, cap: u64) -> Got<'a> { + if f.size == 0 { + return Got::Whole(Vec::new()); + } + let size = crate::bytes::to_usize(f.size); + let blocks = size.div_ceil(BLOCK); + let Some(ends) = v.at(f.offset, blocks.saturating_mul(4)) else { + return Got::Why("cramfs file whose table of blocks is unreadable"); + }; + let fill = Blocks { + ends, + be: self.be, + ext: self.flags & EXT_BLOCK_POINTERS != 0, + // The first block follows the table, and each follows the one before. + prev: f.offset.saturating_add((blocks as u64).saturating_mul(4)), + i: 0, + blocks, + size, + }; + read_file(v, fill, f.size, cap) + } +} + +/// The blocks of a file, read and inflated as they are wanted. +struct Blocks { + /// Where each block ends. + ends: Vec<u8>, + be: bool, + ext: bool, + /// Where the next block starts. + prev: u64, + i: usize, + blocks: usize, + size: usize, +} + +impl Fill for Blocks { + fn fill(&mut self, v: &mut Vol, out: &mut Vec<u8>) -> Step { + if self.i >= self.blocks { + return Step::End; + } + let at = self.i.saturating_mul(4); + let raw = if self.be { + be32(&self.ends, at) + } else { + le32(&self.ends, at) + }; + let (stored, direct) = ( + self.ext && raw & BLK_UNCOMPRESSED != 0, + self.ext && raw & BLK_DIRECT != 0, + ); + let end = u64::from(if self.ext { raw & 0x3FFF_FFFF } else { raw }); + let want = BLOCK.min(self.size.saturating_sub(self.i.saturating_mul(BLOCK))); + if direct || end < self.prev { + return Step::Broken; + } + if end == self.prev { + // A hole. + out.resize(out.len().saturating_add(want), 0); + self.i = self.i.saturating_add(1); + return Step::More; + } + let len = crate::bytes::to_usize(end.saturating_sub(self.prev)); + // A block is a page, stored or deflated, and deflate adds a few bytes + // to a page it cannot shrink. + if len > BLOCK.saturating_mul(2) { + return Step::Broken; + } + let Some(raw_block) = v.at(self.prev, len) else { + return Step::Broken; + }; + self.prev = end; + let mut block = if stored { + raw_block + } else { + match unzlib(&raw_block) { + Some(b) => b, + None => return Step::Broken, + } + }; + block.resize(want, 0); + out.extend_from_slice(&block); + self.i = self.i.saturating_add(1); + Step::More + } +} + +/// A block inflated, to at most one block of bytes. +fn unzlib(src: &[u8]) -> Option<Vec<u8>> { + let mut out = Vec::new(); + crate::inflate::zlib_body(src)? + .take((BLOCK as u64).saturating_add(1)) + .read_to_end(&mut out) + .ok()?; + (out.len() <= BLOCK).then_some(out) +} + +/// The image's superblock, or `None` where it describes none that is read. +fn open(v: &mut Vol) -> Option<Cram> { + for base in [0u64, 512] { + let Some(b) = v.at(base, 76) else { + continue; + }; + if b.get(16..32) != Some(b"Compressed ROMFS".as_slice()) { + continue; + } + let be = match b.get(0..4)? { + [0x28, 0xCD, 0x3D, 0x45] => true, + [0x45, 0x3D, 0xCD, 0x28] => false, + _ => continue, + }; + let mut cram = Cram { + be, + flags: 0, + root: Inode { + mode: 0, + size: 0, + offset: 0, + name_len: 0, + }, + }; + cram.flags = cram.word(&b, 8); + cram.root = cram.inode(b.get(64..76)?); + return (cram.root.mode & S_IFMT == S_IFDIR).then_some(cram); + } + None +} + +/// Walk a cramfs image given whole as a source. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + super::volumes::entries(budget, visit, "", |budget, sink| { + walk_volume(Reader::new(src), src.len() as u64, budget, sink) + }) +} + +/// Walk the cramfs image `disk`, which is `len` bytes long, handing each file +/// to `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + len: u64, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + let max = crate::bytes::to_usize(budget.limits().max_buffer_bytes); + let mut v = Vol { + disk: &mut disk, + len, + max, + }; + let head = v + .at(0, crate::bytes::to_usize(len.min(544))) + .unwrap_or_default(); + if !is_cramfs(&head) { + return Ok(None); + } + let Some(fs) = open(&mut v) else { + return Err(LimitHit::corrupt( + "cramfs open: the superblock or the root directory is unreadable".into(), + )); + }; + + // One more than the member limit, so the entry past it is counted and the + // walk stops there rather than ending short. + let limit = crate::bytes::to_usize(budget.limits().max_members).saturating_add(1); + // The directories of an image are disjoint, so they are not more than it. + let mut left = len; + let mut damaged = false; + let mut seen: HashSet<(u64, u64)> = HashSet::from([(fs.root.offset, fs.root.size)]); + let mut stack = vec![(fs.root.clone(), String::new(), 0usize)]; + while let Some((dir, prefix, depth)) = stack.pop() { + let (list, intact) = fs.entries(&mut v, &dir, &mut left, limit); + damaged |= !intact; + let mut below = Vec::new(); + for (name, inode) in list { + budget.count_entry()?; + let path = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + match inode.mode & S_IFMT { + S_IFDIR => { + if depth >= MAX_DEPTH { + damaged = true; + } else if seen.insert((inode.offset, inode.size)) { + below.push((inode, path, depth.saturating_add(1))); + } + } + S_IFREG => { + let attrs = Attrs { + mode: Some(inode.mode), + mtime: None, + }; + if let Some(r) = give(budget, &mut *visit, path, attrs, |cap| { + fs.file(&mut v, &inode, cap) + })? { + return Ok(Some(r)); + } + } + // A link or a node holds no file's bytes. + _ => {} + } + } + stack.extend(below.into_iter().rev()); + } + if damaged { + return Err(LimitHit::corrupt( + "cramfs walk: the image is damaged; the files before the damage were scanned".into(), + )); + } + Ok(None) +} diff --git a/crates/exav-unpack/src/formats/dcl_explode.rs b/crates/exav-unpack/src/formats/dcl_explode.rs new file mode 100644 index 00000000..2c7bbdd5 --- /dev/null +++ b/crates/exav-unpack/src/formats/dcl_explode.rs @@ -0,0 +1,334 @@ +//! PKWARE Data Compression Library "implode", decoded. Not ZIP's method 6 +//! implode: this is the codec of InstallShield `.z` members. +//! +//! The algorithm and the code-length tables are those of Mark Adler's +//! `blast.c` (zlib `contrib/blast`, zlib licence), as the `explode` crate +//! 0.1.2 (Aaron Griffith, MIT) ports them; both attributed in `NOTICE`. This +//! one decodes a slice whole into a capped buffer, which drops `explode`'s +//! byte-at-a-time state machine and its `arraydeque` window: the output is +//! the window. + +use crate::Salvaged; + +/// `blast.c`'s code lengths, run-length packed: each byte is a length in its +/// low nibble, repeated one more time than its high nibble. +const LITERAL_LENGTHS: &[u8] = &[ + 11, 124, 8, 7, 28, 7, 188, 13, 76, 4, 10, 8, 12, 10, 12, 10, 8, 23, 8, 9, 7, 6, 7, 8, 7, 6, 55, + 8, 23, 24, 12, 11, 7, 9, 11, 12, 6, 7, 22, 5, 7, 24, 6, 11, 9, 6, 7, 22, 7, 11, 38, 7, 9, 8, + 25, 11, 8, 11, 9, 12, 8, 12, 5, 38, 5, 38, 5, 11, 7, 5, 6, 21, 6, 10, 53, 8, 7, 24, 10, 27, 44, + 253, 253, 253, 252, 252, 252, 13, 12, 45, 12, 45, 12, 61, 12, 45, 44, 173, +]; +const LENGTH_LENGTHS: &[u8] = &[2, 35, 36, 53, 38, 23]; +const DISTANCE_LENGTHS: &[u8] = &[2, 20, 53, 230, 247, 151, 248]; + +const LENGTH_BASE: [usize; 16] = [3, 2, 4, 5, 6, 7, 8, 9, 10, 12, 16, 24, 40, 72, 136, 264]; +const LENGTH_EXTRA: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8]; +/// The length that ends the stream. +const END: usize = 519; +const MAX_BITS: usize = 13; + +/// A canonical Huffman code: how many codes of each length, and the symbols +/// in code order. +struct Huffman { + count: [u16; MAX_BITS + 1], + symbol: Vec<u8>, +} + +impl Huffman { + fn new(packed: &[u8]) -> Huffman { + let mut lengths = Vec::with_capacity(256); + for &b in packed { + lengths.extend(std::iter::repeat_n(b & 15, usize::from(b >> 4) + 1)); + } + let mut count = [0u16; MAX_BITS + 1]; + for &l in &lengths { + count[usize::from(l)] += 1; + } + let mut offs = [0usize; MAX_BITS + 2]; + for len in 1..=MAX_BITS { + offs[len + 1] = offs[len] + usize::from(count[len]); + } + let mut symbol = vec![0u8; offs[MAX_BITS + 1]]; + for (s, &l) in lengths.iter().enumerate() { + if l != 0 { + symbol[offs[usize::from(l)]] = s as u8; + offs[usize::from(l)] += 1; + } + } + Huffman { count, symbol } + } +} + +enum Stop { + /// The input ended before the end code. + Truncated, + /// The input is not a DCL stream. + Invalid, + /// The output reached the cap. + Full, +} + +struct Bits<'a> { + input: &'a [u8], + pos: usize, + buf: u32, + count: u32, +} + +impl Bits<'_> { + fn bits(&mut self, n: u32) -> Result<u32, Stop> { + while self.count < n { + let b = *self.input.get(self.pos).ok_or(Stop::Truncated)?; + self.pos += 1; + self.buf |= u32::from(b) << self.count; + self.count += 8; + } + let v = self.buf & ((1 << n) - 1); + self.buf >>= n; + self.count -= n; + Ok(v) + } + + /// One symbol of `h`. The codes are stored bit-inverted. + fn decode(&mut self, h: &Huffman) -> Result<usize, Stop> { + let (mut code, mut first, mut index) = (0usize, 0usize, 0usize); + for len in 1..=MAX_BITS { + code |= (self.bits(1)? ^ 1) as usize; + let count = usize::from(h.count[len]); + if code < first + count { + return Ok(usize::from(h.symbol[index + code - first])); + } + index += count; + first = (first + count) << 1; + code <<= 1; + } + Err(Stop::Invalid) + } +} + +/// Decode `input` up to `cap` bytes, salvaging what came out before an +/// error, as [`crate::salvage`] reports a reader's. +pub(crate) fn explode(input: &[u8], cap: u64) -> Salvaged { + let limit = usize::try_from(cap).unwrap_or(usize::MAX).saturating_add(1); + let mut out = Vec::new(); + let stop = run(input, limit, &mut out); + let over_cap = out.len() > limit - 1; + out.truncate(limit - 1); + Salvaged { + data: out, + over_cap, + undecoded: matches!(stop, Some(Stop::Invalid)), + cut_short: matches!(stop, Some(Stop::Truncated)), + } +} + +/// `None` at the end code. +fn run(input: &[u8], limit: usize, out: &mut Vec<u8>) -> Option<Stop> { + let mut b = Bits { + input, + pos: 0, + buf: 0, + count: 0, + }; + // Each header byte is judged as it is read: a bad first byte is damage + // whether or not a second follows. + let coded = match b.bits(8) { + Ok(coded @ 0..=1) => coded == 1, + Ok(_) => return Some(Stop::Invalid), + Err(e) => return Some(e), + }; + let dict = match b.bits(8) { + Ok(dict @ 4..=6) => dict, + Ok(_) => return Some(Stop::Invalid), + Err(e) => return Some(e), + }; + let literal = Huffman::new(LITERAL_LENGTHS); + let length = Huffman::new(LENGTH_LENGTHS); + let distance = Huffman::new(DISTANCE_LENGTHS); + let step = |b: &mut Bits, out: &mut Vec<u8>| -> Result<bool, Stop> { + if b.bits(1)? == 0 { + let byte = match coded { + true => b.decode(&literal)?, + false => b.bits(8)? as usize, + }; + out.push(byte as u8); + return Ok(true); + } + let symbol = b.decode(&length)?; + let len = LENGTH_BASE[symbol] + b.bits(u32::from(LENGTH_EXTRA[symbol]))? as usize; + if len == END { + return Ok(false); + } + let extra = if len == 2 { 2 } else { dict }; + let dist = (b.decode(&distance)? << extra) + b.bits(extra)? as usize + 1; + // At most 4,096 back, which the window always holds once that much + // has come out. + if dist > out.len() { + return Err(Stop::Invalid); + } + for _ in 0..len { + out.push(out[out.len() - dist]); + } + Ok(true) + }; + loop { + if out.len() >= limit { + return Some(Stop::Full); + } + match step(&mut b, out) { + Ok(true) => {} + Ok(false) => return None, + Err(e) => return Some(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::explode; + + /// `blast.c`'s own example. + const AIAI: &[u8] = &[0x00, 0x04, 0x82, 0x24, 0x25, 0x8f, 0x80, 0x7f]; + + #[test] + fn blast_example() { + let s = explode(AIAI, 1 << 20); + assert_eq!(s.data, b"AIAIAIAIAIAIA"); + assert!(!s.over_cap && !s.undecoded && !s.cut_short); + } + + #[test] + fn trailing_bytes_are_left_alone() { + let mut input = AIAI.to_vec(); + input.push(42); + assert_eq!(explode(&input, 1 << 20).data, b"AIAIAIAIAIAIA"); + } + + #[test] + fn a_cut_stream_keeps_what_it_decoded() { + let s = explode(&AIAI[..AIAI.len() - 1], 1 << 20); + assert!(s.cut_short && !s.undecoded); + assert!(b"AIAIAIAIAIAIA".starts_with(&s.data)); + } + + #[test] + fn the_cap_holds() { + let s = explode(AIAI, 5); + assert!(s.over_cap); + assert_eq!(s.data, b"AIAIA"); + } + + #[test] + fn a_bad_header_or_distance_is_invalid() { + assert!(explode(&[2, 4, 0], 100).undecoded); + assert!(explode(&[0, 7, 0], 100).undecoded); + // A bad first byte is damage even with no second byte after it, and + // an input that ends inside the header is cut. + let s = explode(&[2], 100); + assert!(s.undecoded && !s.cut_short); + let s = explode(&[1], 100); + assert!(s.cut_short && !s.undecoded); + // A match first, before any output to copy from. + assert!(explode(&[0, 4, 0xff, 0xff, 0xff], 100).undecoded); + } + + /// The reference's verdict codes (`dcl_oracle.c`) for what `explode` found: + /// output past the cap is reported first. + fn verdict(s: &crate::Salvaged) -> u8 { + match (s.over_cap, s.cut_short, s.undecoded) { + (true, _, _) => 1, + (_, true, _) => 2, + (_, _, true) => 3, + _ => 0, + } + } + + /// Streams of every kind of damage, against `blast.c`'s verdict and the + /// bytes it wrote. Needs the oracle built by `tests/oracle/build.sh`: + /// `EXAV_DCL_ORACLE=... cargo test -p exav-unpack --lib -- --ignored dcl_oracle`. + #[test] + #[ignore = "needs the reference decoder: tests/oracle/build.sh, EXAV_DCL_ORACLE"] + fn dcl_oracle_agrees_on_damaged_streams() { + let rounds: usize = std::env::var("EXAV_DEBUG_ORACLE_ROUNDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5000); + let z = crate::read_fixture(&format!( + "{}/tests/fixtures/ishieldz/undhr.z", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + let u32_at = |o: usize| u32::from_le_bytes(z[o..o + 4].try_into().unwrap()) as usize; + let mut bases: Vec<(Vec<u8>, usize)> = vec![ + (AIAI.to_vec(), 8), + (z[255..u32_at(41)].to_vec(), 40), + // Headers of both literal modes and every window, then bit noise. + ( + vec![0, 4, 0x55, 0xaa, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc], + 10, + ), + ( + vec![1, 6, 0x55, 0xaa, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc], + 10, + ), + ]; + let mut rng = crate::oracle::Rng(0x2545_f491_4f6c_dd1d); + let mut jobs = Vec::new(); + for (base, hot) in bases.drain(..) { + jobs.push((u32::MAX >> 8, base.clone())); + for _ in 0..rounds { + let bad = rng.damage(&base, hot); + let cap = match rng.below(3) { + 0 => 1 << 20, + 1 => rng.below(base.len() * 8 + 1) as u32, + _ => 1 << 24, + }; + jobs.push((cap, bad)); + } + } + // Noise after a valid header, for every window and both literal modes. + for i in 0..rounds { + let mut v = vec![(i % 2) as u8, 4 + (i % 3) as u8]; + v.extend((0..1 + rng.below(60)).map(|_| rng.next() as u8)); + jobs.push((1 << 20, v)); + } + let replies = crate::oracle::run("EXAV_DCL_ORACLE", &[], &jobs); + let mut bad = Vec::new(); + let mut matrix = [[0usize; 4]; 4]; + for ((cap, input), reply) in jobs.iter().zip(&replies) { + let s = explode(input, u64::from(*cap)); + let ours = verdict(&s); + matrix[usize::from(reply.0)][usize::from(ours)] += 1; + // Past the cap the reference may meet an error first, in the + // window it was writing. + let at_cap = reply.1.len() == *cap as usize && ours == 1; + if s.data != reply.1 || (ours != reply.0 && !at_cap) { + crate::oracle::dump("dcl", bad.len(), input, &reply.1, &s.data); + bad.push(( + *cap, + reply.0, + reply.1.len(), + ours, + s.data.len(), + input.clone(), + )); + } + } + eprintln!( + "{} streams; rows the reference's verdict (ok, full, cut, error), columns ours: {matrix:?}", + jobs.len() + ); + for (cap, status, written, ours, data, input) in bad.iter().take(5) { + eprintln!( + "cap {cap}: reference ({status}, {written} bytes), ours ({ours}, {data} bytes), input {:02x?}", + &input[..input.len().min(32)] + ); + } + assert!( + bad.is_empty(), + "{} of {} streams differ", + bad.len(), + jobs.len() + ); + } +} diff --git a/crates/exav-unpack/src/formats/decmpfs.rs b/crates/exav-unpack/src/formats/decmpfs.rs new file mode 100644 index 00000000..722d6702 --- /dev/null +++ b/crates/exav-unpack/src/formats/decmpfs.rs @@ -0,0 +1,369 @@ +//! decmpfs, the transparent compression macOS applies to files, decoded. +//! +//! A compressed file's data fork is empty. Its bytes are in the +//! `com.apple.decmpfs` attribute after a 16-byte header, or, for the types that +//! do not fit there, in the file's resource fork (an attribute of its own on +//! APFS, `com.apple.ResourceFork`). Both filesystems keep the same attribute +//! and the same fork, so one decoder serves `formats/hfsplus` and +//! `formats/apfs`. +//! +//! The header is little-endian: `cmpf`, the type, the uncompressed size. The +//! types and their layouts are the ones libarchive (zlib) and apfs-fuse +//! (the rest) read; Apple documents type 1 only. +//! +//! ```text +//! type codec payload +//! 1 none attribute, as is +//! 3 zlib attribute (a first byte of 0xFF marks stored bytes) +//! 7 LZVN attribute (a first byte of 0x06 marks stored bytes) +//! 9 none attribute, behind a 0xCC byte +//! 11 LZFSE attribute +//! 4 zlib resource fork: a table of (offset, length) per block +//! 8 LZVN resource fork: a table of block offsets +//! 10 none resource fork: as 8, a marker byte before each block +//! 12 LZFSE resource fork: as 8 +//! ``` +//! +//! A file is cut in blocks of 64 KiB, each coded alone. Types 13 and 14 +//! (LZBITMAP) and the dataless types, whose content is not on the volume, +//! are reported. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] + +use super::vol::{Got, be32, le32, le64}; + +/// The attribute holding the header, and on APFS the one holding the fork. +pub(crate) const ATTR: &str = "com.apple.decmpfs"; +pub(crate) const RESOURCE_FORK_ATTR: &str = "com.apple.ResourceFork"; + +/// The file flag (`UF_COMPRESSED`) that marks a compressed file. macOS reads +/// the attribute only where it is set, so it decides here too. +pub(crate) const COMPRESSED_FLAG: u32 = 0x20; + +const MAGIC: u32 = 0x636d_7066; // "cmpf" +const HEADER: usize = 16; +const BLOCK: usize = 0x1_0000; + +/// The header of a `com.apple.decmpfs` attribute. +pub(crate) struct Header { + pub(crate) kind: u32, + /// The size of the file once decompressed. + pub(crate) size: u64, +} + +pub(crate) fn header(attr: &[u8]) -> Option<Header> { + (attr.len() >= HEADER && le32(attr, 0) == MAGIC).then(|| Header { + kind: le32(attr, 4), + size: le64(attr, 8), + }) +} + +/// Whether a type keeps its bytes in the resource fork. +pub(crate) fn in_fork(kind: u32) -> bool { + matches!(kind, 4 | 8 | 10 | 12 | 14) +} + +/// The bytes of the file `attr` describes, at most `cap` of them. `fork` is +/// its resource fork, wanted by the types that keep their bytes there. +pub(crate) fn unpack(attr: &[u8], fork: Option<&[u8]>, cap: u64) -> Got<'static> { + let Some(h) = header(attr) else { + return Got::Why("compressed file (decmpfs) with a damaged attribute"); + }; + if h.size > cap { + return Got::Big(h.size); + } + let size = crate::bytes::to_usize(h.size); + let payload = attr.get(HEADER..).unwrap_or_default(); + if in_fork(h.kind) && fork.is_none() { + return Got::Why("compressed file (decmpfs) whose resource fork is missing"); + } + let fork = fork.unwrap_or_default(); + let out = match h.kind { + 1 => Some(payload.to_vec()), + 3 => zlib(payload, size), + 7 => lzvn(payload, size), + 9 => stored(payload, 0xCC), + 11 => lzfse(payload, size), + 4 => zlib_fork(fork, size), + 8 | 10 | 12 => offset_fork(h.kind, fork, size), + _ => return Got::Why("compressed file (decmpfs) of a type exav does not read"), + }; + match out { + Some(bytes) if bytes.len() == size => Got::Whole(bytes), + _ => Got::Why("compressed file (decmpfs) that did not decompress"), + } +} + +fn blocks(size: usize) -> usize { + size.div_ceil(BLOCK) +} + +/// The uncompressed length of block `k`, short only for the last. +fn block_len(size: usize, k: usize) -> usize { + size.saturating_sub(k.saturating_mul(BLOCK)).min(BLOCK) +} + +/// A block's coded bytes, none longer than a stored block (a marker and 64 KiB). +fn slice(fork: &[u8], start: usize, len: usize) -> Option<&[u8]> { + if len > BLOCK.saturating_add(1) { + return None; + } + crate::bytes::at(fork, start, len) +} + +/// Type 4: zlib blocks. The fork opens with the big-endian offset of its +/// resource data, which opens with a block count and a table of +/// (offset, length) pairs, both little-endian and from the count's place. +fn zlib_fork(fork: &[u8], size: usize) -> Option<Vec<u8>> { + let table = crate::bytes::to_usize(u64::from(be32(fork, 0))).checked_add(4)?; + let count = crate::bytes::to_usize(u64::from(le32(fork, table))); + if count != blocks(size) { + return None; + } + let mut out = Vec::with_capacity(crate::cap_prealloc(size)); + for k in 0..count { + let entry = table.checked_add(4)?.checked_add(k.checked_mul(8)?)?; + let at = crate::bytes::to_usize(u64::from(le32(fork, entry))); + let len = crate::bytes::to_usize(u64::from(le32(fork, entry.checked_add(4)?))); + let block = slice(fork, table.checked_add(at)?, len)?; + out.extend_from_slice(&zlib(block, block_len(size, k))?); + } + Some(out) +} + +/// Types 8, 10 and 12: the fork opens with the offsets of its blocks, one more +/// than there are blocks, each block running to the next offset. +fn offset_fork(kind: u32, fork: &[u8], size: usize) -> Option<Vec<u8>> { + let mut out = Vec::with_capacity(crate::cap_prealloc(size)); + for k in 0..blocks(size) { + let at = k.checked_mul(4)?; + let start = crate::bytes::to_usize(u64::from(le32(fork, at))); + let end = crate::bytes::to_usize(u64::from(le32(fork, at.checked_add(4)?))); + let block = slice(fork, start, end.checked_sub(start)?)?; + let want = block_len(size, k); + out.extend_from_slice(&match kind { + 8 => lzvn(block, want)?, + 10 => block.get(1..)?.to_vec(), + _ => lzfse(block, want)?, + }); + } + Some(out) +} + +/// zlib, or stored bytes behind a first byte whose low nibble is 0xF, which +/// no zlib stream opens with. +fn zlib(data: &[u8], size: usize) -> Option<Vec<u8>> { + if data.first()? & 0x0f == 0x0f { + return Some(data.get(1..)?.to_vec()); + } + let s = crate::salvage(crate::inflate::zlib_body(data)?, size as u64); + (!s.over_cap).then_some(s.data) +} + +/// LZVN, or stored bytes behind 0x06, which is the end opcode and so opens no +/// coded block. +fn lzvn(data: &[u8], size: usize) -> Option<Vec<u8>> { + if *data.first()? == 0x06 { + return Some(data.get(1..)?.to_vec()); + } + let s = super::lzfse::decode_lzvn(data, size as u64); + (!s.over_cap && !s.undecoded && !s.cut_short).then_some(s.data) +} + +fn lzfse(data: &[u8], size: usize) -> Option<Vec<u8>> { + let s = super::lzfse::decode(data, size as u64); + (!s.over_cap && !s.undecoded && !s.cut_short).then_some(s.data) +} + +fn stored(data: &[u8], marker: u8) -> Option<Vec<u8>> { + (*data.first()? == marker).then(|| data[1..].to_vec()) +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::*; + use std::io::Write; + + fn attr(kind: u32, size: usize, payload: &[u8]) -> Vec<u8> { + let mut a = b"fpmc".to_vec(); + a.extend_from_slice(&kind.to_le_bytes()); + a.extend_from_slice(&(size as u64).to_le_bytes()); + a.extend_from_slice(payload); + a + } + + fn deflated(data: &[u8]) -> Vec<u8> { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(data).unwrap(); + z.finish().unwrap() + } + + fn lzfsed(data: &[u8]) -> Vec<u8> { + let mut enc = Vec::new(); + lzfse_rust::encode_bytes(data, &mut enc).unwrap(); + enc + } + + /// `data` as LZVN literals and the end opcode, built without the decoder. + fn lzvned(data: &[u8]) -> Vec<u8> { + let mut p = Vec::new(); + for c in data.chunks(271) { + match c.len() { + 1..=15 => p.push(0xe0 | c.len() as u8), + n => p.extend_from_slice(&[0xe0, (n - 16) as u8]), + } + p.extend_from_slice(c); + } + p.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]); + p + } + + fn text(n: usize) -> Vec<u8> { + (0..n) + .map(|i| b"the quick brown fox "[i % 20] ^ (i / 997) as u8 & 3) + .collect() + } + + fn bytes(u: Got) -> Vec<u8> { + match u { + Got::Whole(b) => b, + Got::Why(why) => panic!("{why}"), + _ => panic!("not whole"), + } + } + + fn why(u: Got) -> &'static str { + match u { + Got::Why(why) => why, + Got::Big(_) => "over", + _ => "bytes", + } + } + + #[test] + fn the_attribute_types_decode() { + let data = text(3000); + for (kind, payload) in [ + (1, data.clone()), + (3, deflated(&data)), + (3, [&[0xff][..], &data].concat()), + (7, lzvned(&data)), + (7, [&[0x06][..], &data].concat()), + (9, [&[0xcc][..], &data].concat()), + (11, lzfsed(&data)), + ] { + let a = attr(kind, data.len(), &payload); + assert_eq!(bytes(unpack(&a, None, 1 << 20)), data, "type {kind}"); + } + } + + #[test] + fn the_fork_types_decode_over_several_blocks() { + // Two blocks, the second short. + let data = text(70_000); + let (a, b) = data.split_at(BLOCK); + + // Type 4: the fork's header says where the table is (here 0x100). + let blocks = [deflated(a), deflated(b)]; + let mut fork = vec![0u8; 0x104]; + fork[..4].copy_from_slice(&0x100u32.to_be_bytes()); + fork.extend_from_slice(&2u32.to_le_bytes()); + let mut at = 4 + 2 * 8; + for blk in &blocks { + fork.extend_from_slice(&(at as u32).to_le_bytes()); + fork.extend_from_slice(&(blk.len() as u32).to_le_bytes()); + at += blk.len(); + } + for blk in &blocks { + fork.extend_from_slice(blk); + } + let got = unpack(&attr(4, data.len(), &[]), Some(&fork), 1 << 20); + assert_eq!(bytes(got), data, "type 4"); + + // Types 8, 10, 12: offsets, then the blocks. (A block of literals + // alone would be longer than a block; the first is stored instead.) + for (kind, enc) in [ + (8, [[&[0x06][..], a].concat(), lzvned(b)]), + (10, [[&[0][..], a].concat(), [&[0][..], b].concat()]), + (12, [lzfsed(a), lzfsed(b)]), + ] { + let mut fork = Vec::new(); + let mut at = 12; + for blk in &enc { + fork.extend_from_slice(&(at as u32).to_le_bytes()); + at += blk.len(); + } + fork.extend_from_slice(&(at as u32).to_le_bytes()); + for blk in &enc { + fork.extend_from_slice(blk); + } + let got = unpack(&attr(kind, data.len(), &[]), Some(&fork), 1 << 20); + assert_eq!(bytes(got), data, "type {kind}"); + } + } + + #[test] + fn what_cannot_be_decoded_is_reported_not_guessed() { + let data = text(100); + let z = deflated(&data); + + // Over the cap, before anything is decoded. + let a = attr(3, data.len(), &z); + assert!(matches!(unpack(&a, None, 99), Got::Big(100))); + assert!(matches!(unpack(&a, None, 100), Got::Whole(_))); + + // A size the stream does not give, either way. + for size in [99, 101] { + let a = attr(3, size, &z); + assert!( + why(unpack(&a, None, 1 << 20)).contains("did not decompress"), + "size {size}" + ); + } + // A cut stream, a wrong marker, a missing fork, a type not read. + let cut = attr(3, data.len(), &z[..z.len() / 2]); + assert!(why(unpack(&cut, None, 1 << 20)).contains("did not decompress")); + let marker = attr(9, 3, &[0xcd, 1, 2, 3]); + assert!(why(unpack(&marker, None, 1 << 20)).contains("did not decompress")); + let no_fork = attr(8, 10, &[]); + assert!(why(unpack(&no_fork, None, 1 << 20)).contains("resource fork is missing")); + for kind in [13, 14, 0x8000_0001, 99] { + let a = attr(kind, 10, &[]); + let fork = [0u8; 64]; + assert!( + why(unpack(&a, Some(&fork), 1 << 20)).contains("type exav does not read"), + "type {kind}" + ); + } + // Not the header. + assert!(why(unpack(b"cmpf", None, 1 << 20)).contains("damaged")); + let mut a = attr(1, 3, b"abc"); + a[0] = b'x'; + assert!(why(unpack(&a, None, 1 << 20)).contains("damaged")); + } + + #[test] + fn a_table_that_points_outside_the_fork_is_damage_not_a_panic() { + let data = text(70_000); + for kind in [4, 8, 10, 12] { + for fork in [ + vec![], + vec![0xff; 40], + 0x100u32.to_be_bytes().to_vec(), + [0u8; 300].to_vec(), + u32::MAX.to_le_bytes().repeat(40), + ] { + let got = unpack(&attr(kind, data.len(), &[]), Some(&fork), 1 << 20); + assert!( + why(got).contains("did not decompress"), + "type {kind}, fork of {} bytes", + fork.len() + ); + } + } + } +} diff --git a/crates/exav-unpack/src/formats/dmg.rs b/crates/exav-unpack/src/formats/dmg.rs index 508792e3..e154f1a0 100644 --- a/crates/exav-unpack/src/formats/dmg.rs +++ b/crates/exav-unpack/src/formats/dmg.rs @@ -5,6 +5,7 @@ // Every sum and product on a header's number is checked or saturating here; a // plain one fails the build, so the next edit cannot add the unchecked kind. #![deny(clippy::arithmetic_side_effects)] +use super::volumes; use crate::source::{ByteSource, Reader}; use crate::*; use std::io::{Cursor, Read, Seek, SeekFrom}; @@ -185,7 +186,7 @@ fn decrypt_keyblob( header: &EncryptedDmgHeader, derived_key: &[u8; 24], ) -> Result<Vec<u8>, LimitHit> { - use cbc::cipher::{block_padding::NoPadding, BlockModeDecrypt, KeyIvInit}; + use cbc::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}; let iv_len = header.blob_enc_iv_size as usize; let mut iv = [0u8; 8]; @@ -233,7 +234,11 @@ fn compute_chunk_iv(hmac_key: &[u8], chunk_no: u32) -> [u8; 16] { /// Decrypt the encrypted DMG data using the given password. /// Returns the decrypted raw disk image, or None if the password is wrong. #[cfg(feature = "decrypt")] -fn try_decrypt_dmg(data: &[u8], password: &str) -> Result<Vec<u8>, LimitHit> { +fn try_decrypt_dmg( + data: &[u8], + password: &str, + spent: &std::sync::atomic::AtomicU64, +) -> Result<Vec<u8>, LimitHit> { let header = parse_encrypted_header(data)?; if header.version != 2 { @@ -254,6 +259,16 @@ fn try_decrypt_dmg(data: &[u8], password: &str) -> Result<Vec<u8>, LimitHit> { let hmac_key_bytes = header.hmac_key_bits as usize / 8; + // The iteration count is the file's, up to 2^32: the scan has a bound on + // what it spends on them, over every password and every image + // ([`crate::kdf`]). A count of 0 is read as 1000 below. + let iterations = u64::from(header.kdf_iteration_count).max(1000); + if !crate::kdf::charge(spent, iterations, crate::kdf::PBKDF2_MAX_ROUNDS) { + return Err(LimitHit::new( + "encrypted DMG: key derivation past the scan's bound".into(), + )); + } + // Derive key from password. let derived_key = derive_key_dmg( password.as_bytes(), @@ -308,7 +323,7 @@ fn try_decrypt_dmg(data: &[u8], password: &str) -> Result<Vec<u8>, LimitHit> { let iv = compute_chunk_iv(hmac_key, chunk_no as u32); // AES-CBC decrypt. - use cbc::cipher::{block_padding::NoPadding, BlockModeDecrypt, KeyIvInit}; + use cbc::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}; type Aes128Cbc = cbc::Decryptor<aes::Aes128>; type Aes256Cbc = cbc::Decryptor<aes::Aes256>; @@ -324,7 +339,7 @@ fn try_decrypt_dmg(data: &[u8], password: &str) -> Result<Vec<u8>, LimitHit> { Err(_) => { return Err(LimitHit::corrupt( "AES-128 decryption failed (wrong password?)".into(), - )) + )); } } } else { @@ -336,7 +351,7 @@ fn try_decrypt_dmg(data: &[u8], password: &str) -> Result<Vec<u8>, LimitHit> { Err(_) => { return Err(LimitHit::corrupt( "AES-256 decryption failed (wrong password?)".into(), - )) + )); } } } @@ -355,22 +370,19 @@ pub(crate) fn walk<T>( visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { budget.count_entry()?; - let mut emit = - |entry: Entry, budget: &mut Budget| crate::stream::emit_entry(entry, budget, &mut *visit); if src.window(0, 8)[..] != ENCRCDSA_SIG[..] { - return walk_disk(Reader::new(src), budget, &mut emit); + return walk_disk(Reader::new(src), budget, visit); } match decrypt_image(src, budget)? { - Ok(disk) => walk_disk(Cursor::new(disk), budget, &mut emit), - Err(reason) => emit( + Ok(disk) => walk_disk(Cursor::new(disk), budget, visit), + Err(reason) => crate::stream::emit_entry( Entry::unsupported("encrypted.dmg".to_string(), src.len() as u64, true, reason), budget, + visit, ), } } -type Emit<'a, T> = &'a mut dyn FnMut(Entry, &mut Budget) -> Result<Option<T>, LimitHit>; - /// The decrypted image of an encrypted DMG, or why it was not decrypted. /// Decryption takes the whole image. fn decrypt_image( @@ -389,7 +401,7 @@ fn decrypt_image( } let data = crate::stream::read_whole(Format::Dmg, src, budget)?; for pw in &budget.passwords { - let Ok(decrypted) = try_decrypt_dmg(&data, pw) else { + let Ok(decrypted) = try_decrypt_dmg(&data, pw, &budget.kdf_rounds) else { continue; }; // A right password gives a koly trailer or a filesystem. @@ -419,23 +431,20 @@ fn decrypt_image( fn walk_disk<D: Read + Seek, T>( disk: D, budget: &mut Budget, - emit: Emit<T>, + visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { let disk = super::udif::disk(disk)?; let damaged = disk.damage(); - let found = walk_fs(disk, budget, emit)?; + let found = walk_fs(disk, budget, &mut *visit)?; if found.is_some() || !damaged.load(std::sync::atomic::Ordering::Relaxed) { return Ok(found); } - budget.count_entry()?; - emit( - Entry::unsupported( - "dmg-disk".to_string(), - 0, - false, - "DMG run failed to decode part way; the bytes before the failure were scanned", - ), + volumes::report( + "dmg-disk", + 0, + "DMG run failed to decode part way; the bytes before the failure were scanned", budget, + visit, ) } @@ -443,7 +452,7 @@ fn walk_disk<D: Read + Seek, T>( fn walk_fs<D: Read + Seek, T>( mut disk: super::udif::Disk<D>, budget: &mut Budget, - emit: Emit<T>, + visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { // Both filesystems are looked for in the first 64 KiB, as `is_dmg` does. let mut head = Vec::new(); @@ -453,56 +462,16 @@ fn walk_fs<D: Read + Seek, T>( if let Some(nxsb_off) = find_apfs_offset(&head) { let part = Part::new(disk, nxsb_off.saturating_sub(32) as u64)?; - let mut vol = apfs::ApfsVolume::open(part) - .map_err(|e| LimitHit::corrupt(format!("apfs open: {e}")))?; - let walk = vol - .walk() - .map_err(|e| LimitHit::corrupt(format!("apfs walk: {e}")))?; - for we in walk { - if we.entry.kind != apfs::EntryKind::File { - continue; - } - let hit = read_file(we.path, budget, emit, |path, out| { - vol.read_file_to(path, out).map(|_| ()) - })?; - if hit.is_some() { - return Ok(hit); - } - } - return Ok(None); + return volumes::entries(budget, visit, "", |budget, sink| { + super::apfs::walk_volume(part, budget, sink) + }); } if let Some(hfs_off) = find_hfs_offset(&head) { let part = Part::new(disk, hfs_off.saturating_sub(1024) as u64)?; - let mut vol = hfsplus::HfsVolume::open(part) - .map_err(|e| LimitHit::corrupt(format!("hfs+ open: {e}")))?; - let walk = vol - .walk() - .map_err(|e| LimitHit::corrupt(format!("hfs+ walk: {e}")))?; - for we in walk { - if we.entry.kind != hfsplus::EntryKind::File { - continue; - } - // Only the data fork is read. A resource fork is content too, and - // where macOS compressed a file (decmpfs) it holds the file's - // bytes while the data fork is empty: reported, not scanned as - // empty. - if vol.stat(&we.path).is_ok_and(|s| s.resource_fork_size > 0) { - budget.count_entry()?; - let e = Entry::unsupported(we.path, 0, false, "HFS+ resource fork not read"); - if let Some(t) = emit(e, budget)? { - return Ok(Some(t)); - } - continue; - } - let hit = read_file(we.path, budget, emit, |path, out| { - vol.read_file_to(path, out).map(|_| ()) - })?; - if hit.is_some() { - return Ok(hit); - } - } - return Ok(None); + return volumes::entries(budget, visit, "", |budget, sink| { + super::hfsplus::walk_volume(part, budget, sink) + }); } Err(LimitHit::corrupt( @@ -510,62 +479,6 @@ fn walk_fs<D: Read + Seek, T>( )) } -/// Read one file, bounded by the peak-buffer limit, and emit it. The -/// filesystem crates hand a file over whole. -fn read_file<T, E>( - path: String, - budget: &mut Budget, - emit: Emit<T>, - read: impl FnOnce(&str, &mut Capped) -> Result<(), E>, -) -> Result<Option<T>, LimitHit> { - budget.count_entry()?; - let mut out = Capped { - data: Vec::new(), - cap: budget.reserve()?, - over: false, - }; - let read = read(&path, &mut out); - if out.over { - return Err(LimitHit::new(format!("DMG file '{path}' exceeds budget"))); - } - if read.is_err() { - // Its bytes are in the image and were not all read: reported, not - // dropped, and those read before the failure scanned. - budget.commit(out.data.len() as u64); - return emit( - Entry { - unsupported: Some("DMG file could not be read"), - ..Entry::new(path, out.data) - }, - budget, - ); - } - budget.commit(out.data.len() as u64); - emit(Entry::new(path, out.data), budget) -} - -/// A `Write` that refuses to grow past `cap`. -struct Capped { - data: Vec<u8>, - cap: u64, - over: bool, -} - -impl std::io::Write for Capped { - fn write(&mut self, b: &[u8]) -> std::io::Result<usize> { - if (self.data.len() as u64).saturating_add(b.len() as u64) > self.cap { - self.over = true; - return Err(std::io::Error::other("over the buffer limit")); - } - self.data.extend_from_slice(b); - Ok(b.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - /// The disk from `start` on, where a filesystem begins. struct Part<D> { disk: D, @@ -701,10 +614,40 @@ mod tests { put32(&mut h, 104, 1); // kdf iterations put32(&mut h, 196, 64); // keyblob size h.resize(8192, 0); - let out = try_decrypt_dmg(&h, "any").expect("the walk ends with nothing decrypted"); + let spent = std::sync::atomic::AtomicU64::new(0); + let out = try_decrypt_dmg(&h, "any", &spent).expect("the walk ends with nothing decrypted"); assert!(out.is_empty()); } + /// The iteration count is the file's (a `u32`); 264 bytes with u32::MAX took + /// 9 minutes of PBKDF2 to refuse every password. A count past what a scan + /// may spend is not derived, whatever the passwords. + #[cfg(feature = "decrypt")] + #[test] + fn an_iteration_count_past_the_scans_bound_is_not_derived() { + let mut h = vec![0u8; 264]; + h[..8].copy_from_slice(b"encrcdsa"); + let put32 = + |h: &mut Vec<u8>, at: usize, v: u32| h[at..at + 4].copy_from_slice(&v.to_be_bytes()); + put32(&mut h, 8, 2); + put32(&mut h, 24, 128); + put32(&mut h, 32, 160); + put32(&mut h, 52, 4096); + put32(&mut h, 104, u32::MAX); + put32(&mut h, 196, 64); + let spent = std::sync::atomic::AtomicU64::new(0); + let start = std::time::Instant::now(); + for password in ["a", "b", "c"] { + assert!(try_decrypt_dmg(&h, password, &spent).is_err()); + } + assert!(start.elapsed() < std::time::Duration::from_millis(500)); + assert_eq!(spent.load(std::sync::atomic::Ordering::Relaxed), 0); + // What a header written by Disk Utility asks for is derived, and counted. + put32(&mut h, 104, 1000); + let _ = try_decrypt_dmg(&h, "a", &spent); + assert_eq!(spent.load(std::sync::atomic::Ordering::Relaxed), 1000); + } + /// Write a well-formed HFS+ volume header at `off`. fn put_hfs(buf: &mut [u8], off: usize) { buf[off] = 0x48; diff --git a/crates/exav-unpack/src/formats/dwg/extract.rs b/crates/exav-unpack/src/formats/dwg/extract.rs index da001111..b4846e97 100644 --- a/crates/exav-unpack/src/formats/dwg/extract.rs +++ b/crates/exav-unpack/src/formats/dwg/extract.rs @@ -5,7 +5,7 @@ //! applications its proxy and custom objects demand, for signatures and //! heuristics to match. -use super::{file, BitError, BitResult, Dwg, Error, PreviewKind, Version}; +use super::{BitError, BitResult, Dwg, Error, PreviewKind, Version, file}; use crate::*; /// The compound file signature (MS-CFB 2.2). @@ -219,10 +219,10 @@ fn from_objects<R>( None if bytes.is_empty() => None, None => Some((format!("{label}.bin"), bytes)), }; - if let Some((name, bytes)) = member { - if let Some(r) = emit(name, bytes, budget, visit)? { - return Ok(Some(r)); - } + if let Some((name, bytes)) = member + && let Some(r) = emit(name, bytes, budget, visit)? + { + return Ok(Some(r)); } } else if t == VBA_PROJECT || (t >= 500 && dwg.class(t).is_some_and(|c| c.name() == "VBA_PROJECT")) @@ -233,10 +233,9 @@ fn from_objects<R>( if let Some((name, bytes)) = dwg .object_bytes(offset) .and_then(|b| vba_member(format!("vbaproject-{handle:X}.ole"), b)) + && let Some(r) = emit(name, bytes, budget, visit)? { - if let Some(r) = emit(name, bytes, budget, visit)? { - return Ok(Some(r)); - } + return Ok(Some(r)); } } meta.note_xref(&dwg, t, offset); @@ -246,15 +245,14 @@ fn from_objects<R>( if let Some((name, bytes)) = dwg .vba_project() .and_then(|b| vba_member("vbaproject.ole".to_string(), b)) + && let Some(r) = emit(name, bytes, budget, visit)? { - if let Some(r) = emit(name, bytes, budget, visit)? { - return Ok(Some(r)); - } + return Ok(Some(r)); } - if let Some(bytes) = meta.finish() { - if let Some(r) = emit("dwg-metadata".to_string(), bytes, budget, visit)? { - return Ok(Some(r)); - } + if let Some(bytes) = meta.finish() + && let Some(r) = emit("dwg-metadata".to_string(), bytes, budget, visit)? + { + return Ok(Some(r)); } Ok(None) } @@ -300,12 +298,11 @@ impl Metadata { if t != BLOCK_HEADER { return; } - if let Ok(mut o) = dwg.object_at(offset) { - if let Some(path) = block_xref_path(&mut o, dwg.version()) { - if !path.is_empty() { - self.lines.push(format!("xref: {path}")); - } - } + if let Ok(mut o) = dwg.object_at(offset) + && let Some(path) = block_xref_path(&mut o, dwg.version()) + && !path.is_empty() + { + self.lines.push(format!("xref: {path}")); } } diff --git a/crates/exav-unpack/src/formats/dwg/file.rs b/crates/exav-unpack/src/formats/dwg/file.rs index 659c4732..3ae15f2a 100644 --- a/crates/exav-unpack/src/formats/dwg/file.rs +++ b/crates/exav-unpack/src/formats/dwg/file.rs @@ -7,9 +7,9 @@ use std::borrow::Cow; use super::bits::{BitError, Bits}; -use super::object::{split_strings, HeaderStreams, Object, Text}; -use super::{r2004, r2007}; +use super::object::{HeaderStreams, Object, Text, split_strings}; use super::{Error, Version}; +use super::{r2004, r2007}; /// The named sections of an R2004 to R2018 file: R2007's container (spec /// 5) or the others' (spec 4). diff --git a/crates/exav-unpack/src/formats/dwg/mod.rs b/crates/exav-unpack/src/formats/dwg/mod.rs index eb7f8ee6..c18acb93 100644 --- a/crates/exav-unpack/src/formats/dwg/mod.rs +++ b/crates/exav-unpack/src/formats/dwg/mod.rs @@ -41,7 +41,7 @@ pub use bits::{BitError, BitResult, Bits, HandleRef}; pub use extract::bmp_file; pub(crate) use extract::extract_dwg; pub use file::{ - preview, trim_zeros, Class, Dwg, Locator, PreviewImage, PreviewKind, DEFAULT_MAX_BYTES, + Class, DEFAULT_MAX_BYTES, Dwg, Locator, PreviewImage, PreviewKind, preview, trim_zeros, }; pub use object::{Eed, EedValue, EntityColor, EntityCommon, HeaderStreams, Object, Text}; diff --git a/crates/exav-unpack/src/formats/dwg/object.rs b/crates/exav-unpack/src/formats/dwg/object.rs index 1d79895d..a9c3e3b8 100644 --- a/crates/exav-unpack/src/formats/dwg/object.rs +++ b/crates/exav-unpack/src/formats/dwg/object.rs @@ -3,9 +3,9 @@ //! from: the data before the handles, from R2007 the strings after the data, //! and the handles. +use super::Version; use super::bits::{BitError, BitResult, Bits, HandleRef}; use super::file::Dwg; -use super::Version; /// A TV string as stored (spec 2): bytes in the drawing's code page to /// R2004, UTF-16 from R2007. diff --git a/crates/exav-unpack/src/formats/dwg/r2004.rs b/crates/exav-unpack/src/formats/dwg/r2004.rs index ae486e40..f27e7288 100644 --- a/crates/exav-unpack/src/formats/dwg/r2004.rs +++ b/crates/exav-unpack/src/formats/dwg/r2004.rs @@ -175,14 +175,18 @@ pub(super) fn decompress(src: &[u8], limit: usize) -> Result<Vec<u8>, Inflate> { let op = byte(&mut i)?; let (count, offset, lits) = match op { 0x11 => break, - 0x10 | 0x12..=0x1F => { - let count = if op == 0x10 { + 0x10..=0x1F => { + // As in LZO1X, bit 3 is a distance past 32 KiB (its high + // bit, 0x4000) and the length is the low three bits: only + // the section map of a large file is long enough to need it. + let low = usize::from(op & 7); + let count = if low == 0 { long_count(&mut i)? + 9 } else { - usize::from(op & 0x0F) + 2 + low + 2 }; let (offset, lits) = two_byte(&mut i)?; - (count, offset + 0x3FFF, lits) + (count, offset + 0x3FFF + (usize::from(op & 8) << 11), lits) } 0x20..=0x3F => { let count = if op == 0x20 { @@ -631,6 +635,32 @@ mod tests { ); } + #[test] + fn a_copy_from_more_than_32_kib_back_sets_bit_3_of_its_opcode() { + // 40000 literals: 0x00, then 156 zeros (each 0xFF more), then 202. + let mut src = vec![0x00]; + src.extend_from_slice(&[0x00; 156]); + src.push(202); + let lits: Vec<u8> = (0..40_000u32).map(|i| (i % 251) as u8).collect(); + src.extend_from_slice(&lits); + // 0x1D: bit 3 set, low bits 5: 5 + 2 = 7 bytes, from + // (233 + 0x3FFF + 0x4000) + 1 = 33001 back; the low bits of the + // offset's first byte say one literal follows. + src.extend_from_slice(&[0x1D, (41 << 2) | 1, 3, 0xEE, 0x11]); + let out = decompress(&src, 50_000).unwrap(); + assert_eq!(out.len(), 40_008); + assert_eq!(out[..40_000], lits[..]); + assert_eq!(out[40_000..40_007], lits[6999..7006]); + assert_eq!(out[40_007], 0xEE); + // Bit 3 with no length bits: the length follows, as it does for 0x10. + let mut longer = src[..src.len() - 5].to_vec(); + longer.extend_from_slice(&[0x18, 0x00, 11, (41 << 2) | 1, 3, 0xEE, 0x11]); + let out = decompress(&longer, 50_000).unwrap(); + // 255 + 11 + 9 = 275 bytes from 33001 back. + assert_eq!(out.len(), 40_000 + 275 + 1); + assert_eq!(out[40_000..40_275], lits[6999..7274]); + } + #[test] fn an_overlapping_copy_repeats_its_source() { // Five literals, then 0x24: 0x24 - 0x1E = 6 bytes from offset 1 diff --git a/crates/exav-unpack/src/formats/dwg/r2007.rs b/crates/exav-unpack/src/formats/dwg/r2007.rs index 51f2bc67..14b8e573 100644 --- a/crates/exav-unpack/src/formats/dwg/r2007.rs +++ b/crates/exav-unpack/src/formats/dwg/r2007.rs @@ -15,8 +15,8 @@ //! checked against what the file holds, or, for decompressed data, against //! the byte budget the caller gives. -use super::r2004::Inflate; use super::Error; +use super::r2004::Inflate; /// Spec 5.13: a codeword of 255 bytes, 239 of them data in system pages, /// 251 in data pages. diff --git a/crates/exav-unpack/src/formats/dxf/extract.rs b/crates/exav-unpack/src/formats/dxf/extract.rs index 4ffea0fb..84585fc7 100644 --- a/crates/exav-unpack/src/formats/dxf/extract.rs +++ b/crates/exav-unpack/src/formats/dxf/extract.rs @@ -121,10 +121,10 @@ pub(crate) fn extract_dxf<R>( } else { whole_file_extension(&chunks).map(|ext| (format!("{label}.{ext}"), chunks)) }; - if let Some((name, bytes)) = out { - if let Some(r) = emit(name, bytes, budget, visit)? { - return Ok(Some(r)); - } + if let Some((name, bytes)) = out + && let Some(r) = emit(name, bytes, budget, visit)? + { + return Ok(Some(r)); } } // A pair that is not one leaves the rest of the file undecoded: whatever diff --git a/crates/exav-unpack/src/formats/dxf/mod.rs b/crates/exav-unpack/src/formats/dxf/mod.rs index dce111ef..9ff13ddd 100644 --- a/crates/exav-unpack/src/formats/dxf/mod.rs +++ b/crates/exav-unpack/src/formats/dxf/mod.rs @@ -28,7 +28,7 @@ mod parts; mod records; pub(crate) use extract::extract_dxf; -pub use pairs::{Stop, Tag, Tags, Value, BINARY_SENTINEL}; +pub use pairs::{BINARY_SENTINEL, Stop, Tag, Tags, Value}; pub use parts::Parts; pub use records::{Record, Records}; diff --git a/crates/exav-unpack/src/formats/email.rs b/crates/exav-unpack/src/formats/email.rs index 3c94caf0..ed314a28 100644 --- a/crates/exav-unpack/src/formats/email.rs +++ b/crates/exav-unpack/src/formats/email.rs @@ -1,32 +1,219 @@ #![allow(unused_imports)] use crate::*; +use mail_parser::{Message, MessageParser, MessagePart, MimeHeaders, PartType}; +use std::collections::HashSet; use std::io::{BufReader, Cursor, Read, Seek, Write}; +use std::ops::Deref; + +/// Deepest chain of `message/rfc822` parts read. +const MAX_NESTED_MESSAGES: usize = 64; + +/// A parsed message that is taken apart without recursion when it is dropped. +/// A message holds the messages attached to it, and dropping a chain of them +/// one inside the other takes a frame of the stack for each: 40,000 levels is +/// a 1.4 MB file. +struct Parsed<'a>(Option<Message<'a>>); + +impl<'a> Deref for Parsed<'a> { + type Target = Message<'a>; + fn deref(&self) -> &Message<'a> { + self.0.as_ref().expect("held until dropped") + } +} + +impl Drop for Parsed<'_> { + fn drop(&mut self) { + let mut pending: Vec<Message<'_>> = self.0.take().into_iter().collect(); + while let Some(mut msg) = pending.pop() { + for part in &mut msg.parts { + if let PartType::Message(inner) = + std::mem::replace(&mut part.body, PartType::Binary(Default::default())) + { + pending.push(inner); + } + } + } + } +} + +/// A leaf of a message: its name, its part, and its bytes. +type Leaf<'a> = (String, &'a MessagePart<'a>, &'a [u8]); + +/// The leaves of `msg` and of the messages attached to it, in order, named by +/// where they are: `email-part-3` and, in the message that is part 3, +/// `email-part-3-0`. +fn leaves<'a>(msg: &'a Message<'_>) -> Result<Vec<Leaf<'a>>, LimitHit> { + let mut out = vec![]; + // The parts being read, the name of the message they belong to, the next + // one to read. + let mut stack: Vec<(&[MessagePart<'_>], String, usize)> = + vec![(&msg.parts, "email-part".to_string(), 0)]; + while let Some((parts, name, i)) = stack.pop() { + let Some(part) = parts.get(i) else { continue }; + stack.push((parts, name.clone(), i + 1)); + let leaf = format!("{name}-{i}"); + match &part.body { + PartType::Text(t) | PartType::Html(t) => out.push((leaf, part, t.as_bytes())), + PartType::Binary(b) | PartType::InlineBinary(b) => out.push((leaf, part, b.as_ref())), + PartType::Message(inner) => { + if stack.len() > MAX_NESTED_MESSAGES { + return Err(LimitHit::new(format!( + "email: messages attached to messages more than {MAX_NESTED_MESSAGES} deep" + ))); + } + stack.push((&inner.parts, leaf, 0)); + } + // The parts of a multipart are the ones after it. + PartType::Multipart(_) => {} + } + } + Ok(out) +} + +/// The boundaries of the multiparts in `msg` and in the messages attached to +/// it, outer first. +fn boundaries(msg: &Message<'_>) -> Vec<String> { + let mut out = Vec::new(); + let mut pending: Vec<&Message<'_>> = vec![msg]; + while let Some(m) = pending.pop() { + for part in &m.parts { + if let Some(b) = part.content_type().and_then(|ct| ct.attribute("boundary")) { + out.push(b.to_string()); + } + if let PartType::Message(inner) = &part.body { + pending.push(inner); + } + } + } + out +} + +/// The closing delimiters a message lacks: a multipart that is cut short, or +/// that its writer never closed, has its last part dropped by the parser, and +/// a mail reader shows that part. Inner multiparts are closed first. +fn missing_closings(msg: &Message<'_>, data: &[u8]) -> Vec<u8> { + let boundaries = boundaries(msg); + // One pass over the lines for all of them: a search for each boundary in + // turn reads the whole message once per multipart. + let wanted: HashSet<&[u8]> = boundaries.iter().map(String::as_bytes).collect(); + let mut closed: HashSet<&[u8]> = HashSet::new(); + for line in data.split(|&c| c == b'\n') { + if let Some(b) = line + .trim_ascii_end() + .strip_prefix(b"--") + .and_then(|l| l.strip_suffix(b"--")) + && wanted.contains(b) + { + closed.insert(b); + } + } + let mut out = Vec::new(); + for b in boundaries.iter().rev() { + if !closed.contains(b.as_bytes()) { + out.extend_from_slice(format!("\r\n--{b}--\r\n").as_bytes()); + } + } + out +} + +/// The text of a base64 or quoted-printable part the parser could not decode, +/// decoded as far as it goes: every byte outside the alphabet is skipped, as the +/// mail readers that show such a part do. +fn lenient_decode(part: &MessagePart<'_>) -> Option<Vec<u8>> { + if !part.is_encoding_problem { + return None; + } + let declared = part + .headers + .iter() + .find(|h| { + h.name + .as_str() + .eq_ignore_ascii_case("content-transfer-encoding") + })? + .value + .as_text()? + .trim() + .to_ascii_lowercase(); + if declared != "base64" { + return None; + } + let raw: &[u8] = match &part.body { + PartType::Text(t) | PartType::Html(t) => t.as_bytes(), + PartType::Binary(b) | PartType::InlineBinary(b) => b.as_ref(), + _ => return None, + }; + let mut out = Vec::with_capacity(raw.len() / 4 * 3); + let (mut acc, mut bits) = (0u32, 0u32); + for &c in raw { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => continue, + }; + acc = (acc << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} /// MIME email: emit each text/binary part (already base64/QP-decoded by the -/// parser), so attachments and bodies are scanned. +/// parser), so attachments and bodies are scanned, those of attached messages +/// too. +/// +/// Three things a mail reader shows that the parser leaves out are added: the +/// last part of a multipart nothing closed, a base64 part with a stray byte in +/// it (read as far as it decodes), and a file uuencoded into a text part. pub(crate) fn extract_email<R>( data: &[u8], budget: &mut Budget, visit: Sink<R>, ) -> Result<Option<R>, LimitHit> { - use mail_parser::{MessageParser, PartType}; - let msg = MessageParser::default() - .parse(data) - .ok_or_else(|| LimitHit::new("email: parse failed".to_string()))?; - for (i, part) in msg.parts.iter().enumerate() { - let bytes: Vec<u8> = match &part.body { - PartType::Text(t) | PartType::Html(t) => t.as_bytes().to_vec(), - PartType::Binary(b) | PartType::InlineBinary(b) => b.to_vec(), - // Nested messages/multiparts are walked via their own parts. - _ => continue, - }; + fn parse(bytes: &[u8]) -> Result<Message<'_>, LimitHit> { + MessageParser::default() + .parse(bytes) + .ok_or_else(|| LimitHit::new("email: parse failed".to_string())) + } + let first = Parsed(Some(parse(data)?)); + let closings = missing_closings(&first, data); + let completed; + let msg = if closings.is_empty() { + first + } else { + completed = [data, closings.as_slice()].concat(); + drop(first); + Parsed(Some(parse(&completed)?)) + }; + let mut entries: Vec<(String, std::borrow::Cow<'_, [u8]>)> = Vec::new(); + for (name, part, bytes) in leaves(&msg)? { + if let Some(decoded) = lenient_decode(part) { + entries.push((format!("{name}-decoded"), decoded.into())); + } + entries.push((name, bytes.into())); + } + for (name, bytes) in entries { budget.count_entry()?; let cap = budget.reserve()?; if bytes.len() as u64 > cap { return Err(LimitHit::new("email part exceeds budget".to_string())); } budget.commit(bytes.len() as u64); - if let Some(r) = visit(Entry::new(format!("email-part-{i}"), bytes), budget) { + if let Some(r) = visit(Entry::new(name.clone(), bytes.to_vec()), budget) { + return Ok(Some(r)); + } + // A file uuencoded into the text, as old mailers sent attachments. + #[cfg(feature = "uuencode")] + if memchr::memmem::find(&bytes, b"begin ").is_some() + && let Some(r) = super::uuencode::extract_uuencode(&bytes, budget, &mut *visit)? + { return Ok(Some(r)); } } diff --git a/crates/exav-unpack/src/formats/erofs.rs b/crates/exav-unpack/src/formats/erofs.rs new file mode 100644 index 00000000..8fb36088 --- /dev/null +++ b/crates/exav-unpack/src/formats/erofs.rs @@ -0,0 +1,762 @@ +//! EROFS: the files of an image, read where they lie. +//! +//! The read-only filesystem of Android system images, container images and +//! live media. The image is a `Read + Seek`, read where the walk goes: the +//! superblock, the inodes of the directories and files, and their data. Only a +//! file is ever held, bounded by the buffer limit. +//! +//! ```text +//! superblock (1024 bytes in) block size, where the inodes are, the root +//! inode (32 or 64 bytes) mode, size, how the data is laid out, and +//! where it is +//! data whole blocks at an address, a tail after the +//! inode, or chunks each at an address +//! directory blocks of (inode number, name) entries +//! ``` +//! +//! An inode is addressed by its number: the 32-byte slot it begins at, from the +//! inode area. Data stored plain, with its tail after the inode, and in chunks +//! (with holes) is read. A file stored compressed is reported, not read, and so +//! is a file in chunks on another device. A symbolic link or a device node holds +//! no file's bytes and is not emitted. The checksum is not checked. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] + +use std::collections::HashSet; +use std::io::{Read, Seek}; + +use super::vol::{ + Attrs, Emit, Got, List, Next, Piece, Pieces, Plan, Vol, give, le16, le32, le64, read_file, +}; +use crate::source::{ByteSource, Reader}; +use crate::stream::Visit; +use crate::{Budget, LimitHit, Mtime}; + +/// The most directories a path passes through. +const MAX_DEPTH: usize = 256; + +/// The most a directory is, bytes. +const MAX_DIR: u64 = 64 << 20; + +/// How the data of an inode is laid out. +const FLAT_PLAIN: u16 = 0; +const FLAT_INLINE: u16 = 2; +const CHUNK_BASED: u16 = 4; + +/// The address of a chunk that is a hole. +const NULL_ADDR: u32 = 0xFFFF_FFFF; +/// A chunk format with an index (and a device) for each chunk. +const CHUNK_INDEXES: u16 = 0x20; + +const S_IFMT: u32 = 0o170000; +const S_IFREG: u32 = 0o100000; +const S_IFDIR: u32 = 0o040000; + +/// Whether `data`, the start of an image, holds the magic of an EROFS +/// superblock. +pub(crate) fn is_erofs(data: &[u8]) -> bool { + super::sniff::is(data, crate::Format::Erofs) +} + +/// What the superblock says of the image. +struct Erofs { + /// Bytes in a block. + bs: u64, + /// Where the inode area begins, and the number of the root. + meta: u64, + root: u64, +} + +/// An inode. +struct Inode { + mode: u32, + size: u64, + layout: u16, + /// The union at 16: the address of the data, or the format of the chunks. + union: u32, + mtime: Option<i64>, + /// Where the data kept after it begins: past the inode and its attributes. + after: u64, +} + +impl Erofs { + fn inode(&self, v: &mut Vol, nid: u64) -> Option<Inode> { + let at = self.meta.checked_add(nid.checked_mul(32)?)?; + let h = v.at(at, 32)?; + let format = le16(&h, 0); + // Bit 0: the longer inode. Bits 1 to 3: the layout. + let wide = format & 1 != 0; + let layout = (format >> 1) & 7; + let (isize, size, mtime) = if wide { + let ext = v.at(at.checked_add(32)?, 32)?; + (64u64, le64(&h, 8), Some(i64::try_from(le64(&ext, 0)).ok()?)) + } else { + (32u64, u64::from(le32(&h, 8)), None) + }; + // The attributes: a header of 12 bytes and 4 for each count past the first. + let icount = u64::from(le16(&h, 2)); + let xattr = if icount == 0 { + 0 + } else { + icount + .saturating_sub(1) + .saturating_mul(4) + .saturating_add(12) + }; + let len = isize.saturating_add(xattr); + Some(Inode { + mode: u32::from(le16(&h, 4)), + size, + layout, + union: le32(&h, 16), + mtime, + after: at.checked_add(len)?, + }) + } + + /// The data of `ino`: held when it is at most `cap` bytes, else read as it + /// goes. + fn data<'a>(&self, v: &'a mut Vol<'_>, ino: &Inode, cap: u64) -> Got<'a> { + if ino.size == 0 { + return Got::Whole(Vec::new()); + } + let size = ino.size; + match ino.layout { + FLAT_PLAIN | FLAT_INLINE => { + let whole = if ino.layout == FLAT_INLINE { + size.checked_div(self.bs) + .unwrap_or(0) + .saturating_mul(self.bs) + } else { + size + }; + let mut pieces = Vec::new(); + // A file of less than a block has no block: no address either. + if whole > 0 { + let Some(at) = u64::from(ino.union).checked_mul(self.bs) else { + return Got::Short(Vec::new()); + }; + pieces.push(Piece::Disk(at, whole)); + } + // The tail kept after the inode. + if whole < size { + pieces.push(Piece::Disk(ino.after, size.saturating_sub(whole))); + } + let plan = List { + pieces: pieces.into_iter(), + intact: true, + }; + read_file(v, Pieces::new(plan, size), size, cap) + } + CHUNK_BASED => match self.chunks(ino) { + Some(plan) => read_file(v, Pieces::new(plan, size), size, cap), + None => Got::Short(Vec::new()), + }, + _ => Got::Why("EROFS compressed file not read"), + } + } + + /// The chunks of a file that is in chunks, or `None` where its layout is + /// not one. + fn chunks(&self, ino: &Inode) -> Option<Chunks> { + let format = (ino.union & 0xFFFF) as u16; + let chunk = self + .bs + .checked_shl(u32::from(format & 0x1F)) + .filter(|&c| c > 0 && c <= 1 << 30)?; + let unit: u64 = if format & CHUNK_INDEXES != 0 { 8 } else { 4 }; + // The map follows the attributes, at a multiple of its entries' size. + let map = ino.after.checked_next_multiple_of(unit)?; + Some(Chunks { + map, + unit, + bs: self.bs, + chunk, + size: ino.size, + count: ino.size.div_ceil(chunk), + k: 0, + batch: Vec::new(), + batch_at: 0, + }) + } + + /// The entries of directory `dir`: name and inode number. The flag is + /// false where the directory is damaged. `left` counts the bytes of + /// directories the walk may still read. + fn entries( + &self, + v: &mut Vol, + dir: &Inode, + left: &mut u64, + limit: usize, + ) -> (Vec<(String, u64)>, bool) { + let mut out = Vec::new(); + if dir.size > MAX_DIR || dir.size > *left { + return (out, false); + } + *left = left.saturating_sub(dir.size); + let bytes = match self.data(v, dir, MAX_DIR) { + Got::Whole(b) => b, + _ => return (out, false), + }; + let bs = crate::bytes::to_usize(self.bs); + let mut intact = true; + for block in bytes.as_slice().chunks(bs.max(1)) { + intact &= dirents(block, &mut out, limit); + if out.len() >= limit { + break; + } + } + (out, intact) + } +} + +/// Append the entries of a block of a directory to `out`, up to `limit` in +/// all. False where the block is not well formed. +fn dirents(block: &[u8], out: &mut Vec<(String, u64)>, limit: usize) -> bool { + // The first name begins where the entries end, which says how many there are. + let first = usize::from(le16(block, 8)); + if first == 0 || !first.is_multiple_of(12) || first > block.len() { + return false; + } + let count = first.checked_div(12).unwrap_or(0); + for i in 0..count { + let e = i.saturating_mul(12); + let (nid, off) = ( + le64(block, e), + usize::from(le16(block, e.saturating_add(8))), + ); + // The name ends where the next begins, or, for the last, at its null + // or at the end of the block. + let end = if i.saturating_add(1) < count { + usize::from(le16(block, e.saturating_add(20))) + } else { + block + .get(off..) + .and_then(|r| r.iter().position(|&b| b == 0)) + .map_or(block.len(), |n| off.saturating_add(n)) + }; + let Some(name) = block + .get(off..end) + .filter(|n| !n.is_empty() && off >= first) + else { + return false; + }; + if name == b"." || name == b".." { + continue; + } + if out.len() >= limit { + return true; + } + out.push((String::from_utf8_lossy(name).into_owned(), nid)); + } + true +} + +/// The image's superblock, or `None` where it describes none that is read. +fn open(v: &mut Vol) -> Option<Erofs> { + let b = v.at(1024, 128)?; + if !is_erofs(&[&[0u8; 1024][..], &b].concat()) { + return None; + } + let bits = u32::from(b[12]); + if !(9..=16).contains(&bits) { + return None; + } + let bs = 1u64.checked_shl(bits)?; + let meta = u64::from(le32(&b, 40)).checked_mul(bs)?; + let fs = Erofs { + bs, + meta, + root: u64::from(le16(&b, 14)), + }; + // The root is a directory. + let root = fs.inode(v, fs.root)?; + (root.mode & S_IFMT == S_IFDIR).then_some(fs) +} + +/// How many entries of a file's chunk map are read at once. +const MAP_BATCH: u64 = 4096; + +/// The stretches of a file that is in chunks, the map read as they are wanted. +struct Chunks { + /// Where the map is, and how long its entries are. + map: u64, + unit: u64, + bs: u64, + chunk: u64, + size: u64, + count: u64, + /// The next chunk. + k: u64, + /// Some entries of the map, from entry `batch_at`. + batch: Vec<u8>, + batch_at: u64, +} + +impl Plan for Chunks { + fn next(&mut self, v: &mut Vol) -> Next { + if self.k >= self.count { + return Next::End; + } + let held = (self.batch.len() as u64) + .checked_div(self.unit) + .unwrap_or(0); + if self.k < self.batch_at || self.k >= self.batch_at.saturating_add(held) { + let n = self.count.saturating_sub(self.k).min(MAP_BATCH); + let batch = self + .k + .checked_mul(self.unit) + .and_then(|o| o.checked_add(self.map)) + .and_then(|at| v.at(at, crate::bytes::to_usize(n.saturating_mul(self.unit)))); + let Some(batch) = batch else { + return Next::Broken; + }; + self.batch = batch; + self.batch_at = self.k; + } + let entry = crate::bytes::to_usize( + self.k + .saturating_sub(self.batch_at) + .saturating_mul(self.unit), + ); + let (addr, device) = if self.unit == 8 { + ( + le32(&self.batch, entry.saturating_add(4)), + le16(&self.batch, entry.saturating_add(2)), + ) + } else { + (le32(&self.batch, entry), 0) + }; + let want = self + .chunk + .min(self.size.saturating_sub(self.k.saturating_mul(self.chunk))); + self.k = self.k.saturating_add(1); + if addr == NULL_ADDR { + return Next::Piece(Piece::Zeros(want)); + } + // A chunk on another device is not on this image. + if device != 0 { + return Next::Broken; + } + match u64::from(addr).checked_mul(self.bs) { + Some(at) => Next::Piece(Piece::Disk(at, want)), + None => Next::Broken, + } + } +} + +/// Walk an EROFS image given whole as a source. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + super::volumes::entries(budget, visit, "", |budget, sink| { + walk_volume(Reader::new(src), src.len() as u64, budget, sink) + }) +} + +/// Walk the EROFS image `disk`, which is `len` bytes long, handing each file +/// to `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + len: u64, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + let max = crate::bytes::to_usize(budget.limits().max_buffer_bytes); + let mut v = Vol { + disk: &mut disk, + len, + max, + }; + let head = v + .at(0, crate::bytes::to_usize(len.min(1028))) + .unwrap_or_default(); + if !is_erofs(&head) { + return Ok(None); + } + let Some(fs) = open(&mut v) else { + return Err(LimitHit::corrupt( + "erofs open: the superblock or the root directory is unreadable".into(), + )); + }; + + // One more than the member limit, so the entry past it is counted and the + // walk stops there rather than ending short. + let limit = crate::bytes::to_usize(budget.limits().max_members).saturating_add(1); + // The directories of an image are disjoint, so they are not more than it. + let mut left = len; + let mut damaged = false; + let mut seen: HashSet<u64> = HashSet::from([fs.root]); + let mut stack = vec![(fs.root, String::new(), 0usize)]; + while let Some((nid, prefix, depth)) = stack.pop() { + let Some(dir) = fs.inode(&mut v, nid) else { + damaged = true; + continue; + }; + let (list, intact) = fs.entries(&mut v, &dir, &mut left, limit); + damaged |= !intact; + let mut below = Vec::new(); + for (name, child) in list { + budget.count_entry()?; + let path = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + let Some(inode) = fs.inode(&mut v, child) else { + damaged = true; + continue; + }; + match inode.mode & S_IFMT { + S_IFDIR => { + if depth >= MAX_DEPTH { + damaged = true; + } else if seen.insert(child) { + below.push((child, path, depth.saturating_add(1))); + } + } + S_IFREG => { + let attrs = Attrs { + mode: Some(inode.mode), + mtime: inode.mtime.map(Mtime::Unix), + }; + if let Some(r) = give(budget, &mut *visit, path, attrs, |cap| { + fs.data(&mut v, &inode, cap) + })? { + return Ok(Some(r)); + } + } + // A link or a node holds no file's bytes. + _ => {} + } + } + stack.extend(below.into_iter().rev()); + } + if damaged { + return Err(LimitHit::corrupt( + "erofs walk: the image is damaged; the files before the damage were scanned".into(), + )); + } + Ok(None) +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::super::vol::collected; + use super::*; + use crate::Limits; + use std::io::Cursor; + + const BS: usize = 4096; + /// The inode area is the second block; data begins at the third. + const META: usize = BS; + const DATA: usize = 2 * BS; + + /// An image being written: inodes in 32-byte slots of the inode area, data + /// in blocks after it. + struct Img { + d: Vec<u8>, + slot: usize, + blocks: usize, + } + + impl Img { + fn new() -> Img { + let mut d = vec![0u8; DATA]; + d[1024..1028].copy_from_slice(&0xE0F5_E1E2u32.to_le_bytes()); + d[1024 + 12] = 12; + d[1024 + 40..1024 + 44].copy_from_slice(&1u32.to_le_bytes()); + Img { + d, + slot: 0, + blocks: 2, + } + } + + /// Whole blocks of data; their block address. + fn blocks(&mut self, bytes: &[u8]) -> u32 { + let at = self.blocks; + self.d + .resize((at + bytes.len().div_ceil(BS).max(1)) * BS, 0); + self.d[at * BS..at * BS + bytes.len()].copy_from_slice(bytes); + self.blocks = self.d.len() / BS; + at as u32 + } + + /// A compact inode of `layout`, with `tail` after it; its number. + fn inode(&mut self, layout: u16, mode: u16, size: u32, union: u32, tail: &[u8]) -> u64 { + let nid = self.slot; + let at = META + nid * 32; + let end = at + 32 + tail.len().div_ceil(32) * 32; + if self.d.len() < end { + self.d.resize(end.max(DATA), 0); + } + self.d[at..at + 2].copy_from_slice(&(layout << 1).to_le_bytes()); + self.d[at + 4..at + 6].copy_from_slice(&mode.to_le_bytes()); + self.d[at + 8..at + 12].copy_from_slice(&size.to_le_bytes()); + self.d[at + 16..at + 20].copy_from_slice(&union.to_le_bytes()); + self.d[at + 32..at + 32 + tail.len()].copy_from_slice(tail); + self.slot += 1 + tail.len().div_ceil(32); + nid as u64 + } + + fn file(&mut self, bytes: &[u8]) -> u64 { + let whole = bytes.len() / BS * BS; + let addr = if whole > 0 { + self.blocks(&bytes[..whole]) + } else { + 0 + }; + self.inode( + FLAT_INLINE, + 0o100644, + bytes.len() as u32, + addr, + &bytes[whole..], + ) + } + + /// A directory of `entries`, kept after its inode. + fn dir(&mut self, entries: &[(&str, u64, u8)]) -> u64 { + let bytes = dir_bytes(entries); + self.inode(FLAT_INLINE, 0o040755, bytes.len() as u32, 0, &bytes) + } + + /// The root of the image; the image. + fn finish(mut self, root: u64) -> Vec<u8> { + self.d[1024 + 14..1024 + 16].copy_from_slice(&(root as u16).to_le_bytes()); + self.d + } + } + + /// The bytes of a directory: the entries, then the names. + fn dir_bytes(entries: &[(&str, u64, u8)]) -> Vec<u8> { + let mut all: Vec<(&str, u64, u8)> = vec![(".", 0, 2), ("..", 0, 2)]; + all.extend_from_slice(entries); + let mut out = Vec::new(); + let mut names = Vec::new(); + for (name, nid, ty) in &all { + out.extend_from_slice(&nid.to_le_bytes()); + out.extend_from_slice(&((all.len() * 12 + names.len()) as u16).to_le_bytes()); + out.push(*ty); + out.push(0); + names.extend_from_slice(name.as_bytes()); + } + out.extend_from_slice(&names); + out + } + + type Member = (String, Vec<u8>, Option<&'static str>); + + fn walked(img: &[u8], limits: Limits) -> (Vec<Member>, Result<(), LimitHit>) { + let mut out = Vec::new(); + let mut budget = Budget::new(limits); + let len = img.len() as u64; + let end = walk_volume(Cursor::new(img), len, &mut budget, &mut |e, _| { + let e = e.whole(); + out.push((e.name, e.data, e.unsupported)); + None::<()> + }); + (out, end.map(|_| ())) + } + + fn pat(n: usize) -> Vec<u8> { + (0..n).map(|i| (i % 251) as u8).collect() + } + + /// A root with `a` (a block and a tail), a folder `d` holding `b` (all in + /// its tail), `h`, a file in chunks that has a hole, and an empty file. + fn tree() -> Img { + let mut img = Img::new(); + let a = img.file(&pat(BS + 100)); + let b = img.file(b"in d"); + let d = img.dir(&[("b", b, 1)]); + // Two chunks of a block: the first a hole, the second at a block. + let at = img.blocks(&pat(BS)); + let mut map = Vec::new(); + map.extend_from_slice(&NULL_ADDR.to_le_bytes()); + map.extend_from_slice(&at.to_le_bytes()); + let h = img.inode(CHUNK_BASED, 0o100644, 2 * BS as u32, 0, &map); + let e = img.inode(FLAT_PLAIN, 0o100644, 0, 0, b""); + let root = img.dir(&[("a", a, 1), ("d", d, 2), ("h", h, 1), ("e", e, 1)]); + img.finish_with(root) + } + + impl Img { + fn finish_with(self, root: u64) -> Img { + let d = self.finish(root); + Img { + d, + slot: 0, + blocks: 0, + } + } + } + + fn sorted(mut e: Vec<Member>) -> Vec<(String, Vec<u8>)> { + assert!( + e.iter().all(|m| m.2.is_none()), + "{:?}", + e.iter().map(|m| (&m.0, m.2)).collect::<Vec<_>>() + ); + e.sort_by(|a, b| a.0.cmp(&b.0)); + e.into_iter().map(|m| (m.0, m.1)).collect() + } + + #[test] + fn files_in_blocks_in_a_tail_and_in_chunks_with_a_hole_come_out() { + let (e, end) = walked(&tree().d, Limits::default()); + end.unwrap(); + let mut hole = vec![0u8; BS]; + hole.extend(pat(BS)); + assert_eq!( + sorted(e), + [ + ("a".to_string(), pat(BS + 100)), + ("d/b".to_string(), b"in d".to_vec()), + ("e".to_string(), Vec::new()), + ("h".to_string(), hole), + ] + ); + } + + #[test] + fn a_folder_that_lists_itself_is_walked_once() { + let mut img = Img::new(); + let b = img.file(b"in d"); + // `b` takes the slots 0 and 1, so the folder is the inode in slot 2. + let d = img.dir(&[("b", b, 1), ("up", 2, 2)]); + let root = img.dir(&[("d", d, 2)]); + let img = img.finish(root); + let (e, end) = walked(&img, Limits::default()); + end.unwrap(); + let names: Vec<_> = e.iter().map(|m| m.0.as_str()).collect(); + assert_eq!(names, ["d/b"]); + } + + #[test] + fn a_file_at_an_address_past_the_image_is_reported_with_what_there_is() { + let mut img = Img::new(); + let f = img.inode(FLAT_PLAIN, 0o100644, 100, 9999, b""); + let root = img.dir(&[("f", f, 1)]); + let (e, end) = walked(&img.finish(root), Limits::default()); + end.unwrap(); + assert_eq!(e[0].2, Some("file could not be read from the volume")); + } + + #[test] + fn a_compressed_file_is_reported_and_so_is_a_chunk_on_another_device() { + let mut img = Img::new(); + let z = img.inode(1, 0o100644, 100, 0, b""); + let at = img.blocks(&pat(BS)); + // An indexed chunk: advise, device 1, address. + let mut map = vec![0u8, 0, 1, 0]; + map.extend_from_slice(&at.to_le_bytes()); + let other = img.inode(CHUNK_BASED, 0o100644, BS as u32, 0x20, &map); + let root = img.dir(&[("z", z, 1), ("o", other, 1)]); + let (e, end) = walked(&img.finish(root), Limits::default()); + end.unwrap(); + let by = |n: &str| e.iter().find(|m| m.0 == n).unwrap(); + assert_eq!(by("z").2, Some("EROFS compressed file not read")); + assert_eq!(by("o").2, Some("file could not be read from the volume")); + } + + #[test] + fn a_block_of_a_directory_that_does_not_add_up_is_damage() { + let good = dir_bytes(&[("x", 5, 1)]); + let mut out = Vec::new(); + assert!(dirents(&good, &mut out, 100)); + assert_eq!(out, [("x".to_string(), 5)]); + // No first name offset, one that is not a multiple of 12, one past the + // block, a name that begins before the entries end. + for edit in [(8usize, 0u16), (8, 13), (8, 4000), (20, 5)] { + let mut bad = good.clone(); + bad[edit.0..edit.0 + 2].copy_from_slice(&edit.1.to_le_bytes()); + let mut out = Vec::new(); + assert!(!dirents(&bad, &mut out, 100), "{edit:?}"); + } + } + + #[test] + fn the_entry_past_the_member_limit_is_counted_and_stops_the_walk() { + let mut img = Img::new(); + let a = img.file(b"a"); + let b = img.file(b"b"); + let c = img.file(b"c"); + let root = img.dir(&[("a", a, 1), ("b", b, 1), ("c", c, 1)]); + let img = img.finish(root); + let run = |max: u64| { + let limits = Limits { + max_members: max, + ..Limits::default() + }; + walked(&img, limits) + }; + let (e, end) = run(3); + end.unwrap(); + assert_eq!(e.len(), 3); + let (e, end) = run(2); + assert!(!end.unwrap_err().is_corrupt()); + assert_eq!(e.len(), 2); + } + + #[test] + fn files_over_the_buffer_limit_are_read_as_they_go_in_each_layout() { + let img = tree().d; + let len = img.len() as u64; + let mut budget = Budget::new(Limits { + max_buffer_bytes: 100, + ..Limits::default() + }); + let (e, end) = collected(|emit| walk_volume(Cursor::new(&img[..]), len, &mut budget, emit)); + end.unwrap(); + let mut hole = vec![0u8; BS]; + hole.extend(pat(BS)); + let want = [ + ("a", pat(BS + 100), true), + ("d/b", b"in d".to_vec(), false), + ("e", Vec::new(), false), + ("h", hole, true), + ]; + for (name, data, streamed) in want { + let (entry, was) = e.iter().find(|(m, _)| m.name == name).unwrap(); + assert_eq!(entry.data, data, "{name}"); + assert_eq!(*was, streamed, "{name}"); + assert_eq!(entry.unsupported, None, "{name}"); + } + } + + #[test] + fn a_chunk_map_longer_than_a_batch_is_read_in_batches() { + // One more chunk than a batch of the map holds, each a block at the + // same place, so the file is that block over and over. + let n = MAP_BATCH as usize + 1; + let mut img = Img::new(); + // The map is longer than the area of the inodes, so the data starts + // after where it ends. + img.blocks = 8; + let at = img.blocks(&pat(BS)); + let map: Vec<u8> = (0..n).flat_map(|_| at.to_le_bytes()).collect(); + let f = img.inode(CHUNK_BASED, 0o100644, (n * BS) as u32, 0, &map); + let root = img.dir(&[("f", f, 1)]); + let img = img.finish(root); + let len = img.len() as u64; + let mut budget = Budget::new(Limits { + max_buffer_bytes: 100, + ..Limits::default() + }); + let (e, end) = collected(|emit| walk_volume(Cursor::new(&img[..]), len, &mut budget, emit)); + end.unwrap(); + assert_eq!(e.len(), 1); + assert!(e[0].1, "read as it goes"); + let want: Vec<u8> = (0..n).flat_map(|_| pat(BS)).collect(); + assert_eq!(e[0].0.data, want); + } +} diff --git a/crates/exav-unpack/src/formats/exfat.rs b/crates/exav-unpack/src/formats/exfat.rs new file mode 100644 index 00000000..088b4764 --- /dev/null +++ b/crates/exav-unpack/src/formats/exfat.rs @@ -0,0 +1,938 @@ +//! exFAT: the files of a volume, read where they lie. +//! +//! The filesystem of the memory cards and USB sticks bigger than FAT32 holds. +//! The volume is a `Read + Seek`, read where the walk goes: the boot sector, +//! the part of the allocation table a chain passes through, each directory's +//! clusters and each file's runs of clusters. Only a file is ever held, bounded +//! by the buffer limit. +//! +//! ```text +//! boot sector sector and cluster sizes, where the table and the +//! cluster heap start, the first cluster of the root +//! allocation table one entry per cluster: the next of its chain, or the end +//! cluster heap directories: sets of 32-byte entries, a file being a +//! file entry, a stream entry (first cluster, size, and +//! whether the clusters are in a row) and name entries +//! ``` +//! +//! A file whose stream says its clusters are in a row has no chain in the table. +//! The bytes of a file past its valid length read as zeros, as they do to the +//! system that mounts the volume. The checksums of the boot region and of the +//! entry sets, and the hash of a name, are not checked, nor is the bitmap of +//! the clusters in use read: a file is where its entry says. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek}; + +use super::vol::{ + Attrs, Emit, Fill, Got, Next, Piece, Pieces, Plan, Step, Vol, give, le16, le32, le64, read_file, +}; +use crate::source::{ByteSource, Reader}; +use crate::stream::Visit; +use crate::{Budget, LimitHit, Mtime}; + +/// The most directories a path passes through. +const MAX_DEPTH: usize = 256; + +/// Bytes of the allocation table kept at a time, and how many of those. +const PAGE: u64 = 4096; +const PAGES: usize = 64; + +/// File attribute bit of a folder. +const ATTR_DIR: u16 = 0x10; + +/// Whether `data`, the start of a volume, holds the boot sector of an exFAT +/// volume: its name, the zeroes where a FAT volume has its parameters, and the +/// signature that ends a boot sector. +pub(crate) fn is_exfat(data: &[u8]) -> bool { + data.len() >= 512 + && data.get(3..11) == Some(b"EXFAT ".as_slice()) + && data.get(510..512) == Some(&[0x55, 0xAA][..]) + && data.get(11..64).is_some_and(|z| z.iter().all(|&b| b == 0)) +} + +/// What the boot sector says of the volume. +struct Exfat { + /// Bytes in a cluster. + cluster: u64, + /// Clusters of data, numbered from 2. + clusters: u32, + /// Byte offsets of the table in use and of cluster 2. + table: u64, + heap: u64, + root: u32, + pages: HashMap<u64, Vec<u8>>, +} + +/// Where the bytes of a file or a folder are. +#[derive(Clone, Copy)] +struct Place { + first: u32, + /// The clusters are one after the other, with no chain in the table. + in_a_row: bool, + /// The size the entry gives, which is not known of the root. + size: Option<u64>, +} + +/// A file or a folder in a folder. +struct Item { + name: String, + dir: bool, + place: Place, + /// How much of the file is written: the rest reads as zeros. + valid: u64, + mtime: Option<Mtime>, +} + +impl Exfat { + fn table_byte(&mut self, v: &mut Vol, off: u64) -> Option<u8> { + let (page, at) = (off.checked_div(PAGE)?, off.checked_rem(PAGE)?); + if !self.pages.contains_key(&page) { + let start = self.table.checked_add(page.checked_mul(PAGE)?)?; + let want = crate::bytes::to_usize(PAGE.min(v.len.checked_sub(start)?)); + let got = v.at(start, want)?; + if self.pages.len() >= PAGES { + self.pages.clear(); + } + self.pages.insert(page, got); + } + self.pages + .get(&page)? + .get(crate::bytes::to_usize(at)) + .copied() + } + + /// The cluster after `c` in its chain: `Some(None)` at the end of it, and + /// `None` where the table does not say. + fn next(&mut self, v: &mut Vol, c: u32) -> Option<Option<u32>> { + if c < 2 || c > self.clusters.saturating_add(1) { + return None; + } + let off = u64::from(c).checked_mul(4)?; + let mut raw = 0u32; + for i in (0..4u64).rev() { + let b = self.table_byte(v, off.checked_add(i)?)?; + raw = raw.checked_mul(256)?.checked_add(u32::from(b))?; + } + match raw { + 0xFFFF_FFF8.. => Some(None), + 2.. if raw <= self.clusters.saturating_add(1) => Some(Some(raw)), + _ => None, + } + } + + /// The byte offset of cluster `c`. + fn at(&self, c: u32) -> Option<u64> { + u64::from(c.checked_sub(2)?) + .checked_mul(self.cluster)? + .checked_add(self.heap) + } + + /// The runs of the clusters of `place`, as (first cluster, clusters), up + /// to `want` of them (all of them where it is not given). The flag is false + /// where the chain breaks, loops or ends before that. + fn runs(&mut self, v: &mut Vol, place: Place, want: Option<u64>) -> (Vec<(u32, u64)>, bool) { + if place.in_a_row { + let n = want.unwrap_or(0); + let last = u64::from(place.first).saturating_add(n).saturating_sub(1); + let fits = place.first >= 2 && last <= u64::from(self.clusters).saturating_add(1); + return (vec![(place.first, n)], fits); + } + let mut runs: Vec<(u32, u64)> = Vec::new(); + let mut c = place.first; + let mut got = 0u64; + loop { + // A chain longer than the volume has clusters comes back on itself. + if got >= u64::from(self.clusters) || want.is_some_and(|w| got >= w) { + return (runs, want.is_some()); + } + match runs.last_mut() { + Some((start, n)) if u64::from(*start).saturating_add(*n) == u64::from(c) => { + *n = n.saturating_add(1); + } + _ => runs.push((c, 1)), + } + got = got.saturating_add(1); + match self.next(v, c) { + Some(Some(next)) => c = next, + Some(None) if want.is_none() => return (runs, true), + _ => return (runs, want.is_some_and(|w| got >= w)), + } + } + } + + /// The items of the folder at `place`, up to `limit` of them. `left` + /// counts the clusters of folders the walk may still read. The flag is + /// false where the folder is damaged. + fn items( + &mut self, + v: &mut Vol, + place: Place, + left: &mut u64, + limit: usize, + ) -> (Vec<Item>, bool) { + let want = place.size.map(|s| s.div_ceil(self.cluster)); + let (runs, mut intact) = self.runs(v, place, want); + let mut parse = Parser::default(); + 'runs: for (first, n) in runs { + for i in 0..n { + if *left == 0 { + intact = false; + break 'runs; + } + *left = left.saturating_sub(1); + let c = u32::try_from(u64::from(first).saturating_add(i)).unwrap_or(0); + let block = self + .at(c) + .and_then(|o| v.at(o, crate::bytes::to_usize(self.cluster))); + let Some(block) = block else { + intact = false; + break 'runs; + }; + if parse.feed(&block, limit) { + break 'runs; + } + } + } + (parse.items, intact) + } + + /// The bytes of a file: held when they are at most `cap`, else read as + /// they go. + fn file<'a>(&'a mut self, v: &'a mut Vol<'_>, item: &Item, cap: u64) -> Got<'a> { + let Some(size) = item.place.size else { + return Got::Why("exFAT file without a size"); + }; + if size == 0 { + return Got::Whole(Vec::new()); + } + let want = size.div_ceil(self.cluster); + let chain = Chain { + fat: self, + next: Some(item.place.first), + row: item.place.in_a_row, + want, + got: 0, + broken: false, + }; + let fill = Valid { + inner: Pieces::new(chain, size), + at: 0, + valid: item.valid.min(size), + }; + read_file(v, fill, size, cap) + } +} + +/// The runs of clusters of a file, found as they are read. +struct Chain<'a> { + fat: &'a mut Exfat, + /// Where the next run starts, if the chain goes on. + next: Option<u32>, + /// The clusters are one after the other, with no chain in the table. + row: bool, + /// The clusters of the file not yet in a run. + want: u64, + /// The clusters followed so far. + got: u64, + /// The chain breaks, loops or ends before the file does. + broken: bool, +} + +impl Plan for Chain<'_> { + fn next(&mut self, v: &mut Vol) -> Next { + if self.want == 0 { + return Next::End; + } + let Some(first) = self.next.filter(|_| !self.broken) else { + return Next::Broken; + }; + if self.row { + let n = self.want; + self.want = 0; + let last = u64::from(first).saturating_add(n).saturating_sub(1); + if first < 2 || last > u64::from(self.fat.clusters).saturating_add(1) { + return Next::Broken; + } + return match self.fat.at(first) { + Some(at) => Next::Piece(Piece::Disk(at, n.saturating_mul(self.fat.cluster))), + None => Next::Broken, + }; + } + let mut c = first; + let mut n = 0u64; + loop { + // A chain longer than the volume has clusters comes back on itself. + if self.got >= u64::from(self.fat.clusters) { + self.broken = true; + break; + } + n = n.saturating_add(1); + self.got = self.got.saturating_add(1); + self.want = self.want.saturating_sub(1); + if self.want == 0 { + break; + } + match self.fat.next(v, c) { + Some(Some(next)) if u64::from(next) == u64::from(c).saturating_add(1) => c = next, + Some(Some(next)) => { + self.next = Some(next); + break; + } + Some(None) | None => { + self.broken = true; + break; + } + } + } + match self.fat.at(first) { + Some(at) if n > 0 => Next::Piece(Piece::Disk(at, n.saturating_mul(self.fat.cluster))), + _ => Next::Broken, + } + } +} + +/// A file's bytes with what is past its valid length read as zeros: it was +/// never written. +struct Valid<F> { + inner: F, + /// How many bytes of the file `inner` has given. + at: u64, + valid: u64, +} + +impl<F: Fill> Fill for Valid<F> { + fn fill(&mut self, v: &mut Vol, out: &mut Vec<u8>) -> Step { + let from = out.len(); + let step = self.inner.fill(v, out); + let got = out.len().saturating_sub(from) as u64; + // The first byte of what was given that is past the valid length. + let zero = crate::bytes::to_usize(self.valid.saturating_sub(self.at).min(got)); + if let Some(tail) = out.get_mut(from.saturating_add(zero)..) { + tail.fill(0); + } + self.at = self.at.saturating_add(got); + step + } +} + +/// Reads the entries of a directory, across its clusters. +#[derive(Default)] +struct Parser { + items: Vec<Item>, + /// The entry set being read: its entries so far, and how many more. + set: Vec<[u8; 32]>, + more: usize, +} + +impl Parser { + /// Read the entries of `block`. True where the directory ends in it, or + /// holds as many as `limit`. + fn feed(&mut self, block: &[u8], limit: usize) -> bool { + for e in block.as_chunks::<32>().0 { + if e[0] == 0 { + return true; + } + if self.items.len() >= limit { + return true; + } + match e[0] { + // A file: the entry, then its secondary entries. + 0x85 => { + self.set = vec![*e]; + self.more = usize::from(e[1]); + } + // A secondary entry of the set, in use: a stream, a name, or + // another, which is counted and not read. + 0xC0..=0xFF if self.more > 0 && !self.set.is_empty() => { + self.set.push(*e); + self.more = self.more.saturating_sub(1); + if self.more == 0 { + self.finish(); + } + } + // The bitmap, the table of upper case letters, the label, the + // entries not in use: not files, and the end of a set. + _ => { + self.set.clear(); + self.more = 0; + } + } + } + false + } + + /// The item the set read is. + fn finish(&mut self) { + let set = std::mem::take(&mut self.set); + let (Some(file), Some(stream)) = (set.first(), set.get(1)) else { + return; + }; + if stream[0] != 0xC0 { + return; + } + let units: Vec<u16> = set + .iter() + .skip(2) + .filter(|e| e[0] == 0xC1) + .flat_map(|e| e.get(2..).unwrap_or(&[]).as_chunks::<2>().0.iter()) + .map(|c| u16::from_le_bytes(*c)) + .take(usize::from(stream[3])) + .collect(); + if units.is_empty() { + return; + } + let size = le64(stream, 24); + self.items.push(Item { + name: String::from_utf16_lossy(&units), + dir: le16(file, 4) & ATTR_DIR != 0, + place: Place { + first: le32(stream, 20), + in_a_row: stream[1] & 0x02 != 0, + size: Some(size), + }, + valid: le64(stream, 8), + mtime: timestamp(le32(file, 12)), + }); + } +} + +/// A timestamp of a file entry: the year since 1980, month, day, hour, minute +/// and two seconds, packed; wall-clock, the offset from UTC being another field. +fn timestamp(t: u32) -> Option<Mtime> { + let (day, month) = ((t >> 16) & 0x1F, (t >> 21) & 0x0F); + let (hour, minute, second) = ( + (t >> 11) & 0x1F, + (t >> 5) & 0x3F, + (t & 0x1F).saturating_mul(2), + ); + if t == 0 || !(1..=12).contains(&month) || day == 0 || hour > 23 || minute > 59 || second > 59 { + return None; + } + Some(Mtime::Local { + year: 1980u16.saturating_add(u16::try_from(t >> 25).ok()?), + month: u8::try_from(month).ok()?, + day: u8::try_from(day).ok()?, + hour: u8::try_from(hour).ok()?, + minute: u8::try_from(minute).ok()?, + second: u8::try_from(second).ok()?, + }) +} + +/// The volume's boot sector, or `None` where it describes none a system would +/// mount. +fn open(v: &mut Vol) -> Option<Exfat> { + let b = v.at(0, 512)?; + if !is_exfat(&b) || le16(&b, 104) >> 8 != 1 { + return None; + } + let (sector_shift, cluster_shift) = (u32::from(b[108]), u32::from(b[109])); + if !(9..=12).contains(§or_shift) || sector_shift.saturating_add(cluster_shift) > 25 { + return None; + } + let sector = 1u64.checked_shl(sector_shift)?; + let cluster = 1u64.checked_shl(sector_shift.saturating_add(cluster_shift))?; + let fats = u64::from(b[110]); + if !(1..=2).contains(&fats) { + return None; + } + let (fat_at, fat_len) = (u64::from(le32(&b, 80)), u64::from(le32(&b, 84))); + let heap = u64::from(le32(&b, 88)).checked_mul(sector)?; + // Nothing is read from past the volume the boot sector describes. + let volume = le64(&b, 72).saturating_mul(sector).min(v.len); + let room = volume.checked_sub(heap)?.checked_div(cluster)?; + let clusters = u32::try_from(u64::from(le32(&b, 92)).min(room)).ok()?; + let root = le32(&b, 96); + if clusters == 0 || fat_len == 0 || root < 2 || root > clusters.saturating_add(1) { + return None; + } + // Two tables, and the volume flags say which is in use. + let active = u64::from(fats == 2 && le16(&b, 106) & 1 != 0); + let table = fat_at + .saturating_add(active.saturating_mul(fat_len)) + .saturating_mul(sector); + Some(Exfat { + cluster, + clusters, + table, + heap, + root, + pages: HashMap::new(), + }) +} + +/// Walk an exFAT volume given whole as a source. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + super::volumes::entries(budget, visit, "", |budget, sink| { + walk_volume(Reader::new(src), src.len() as u64, budget, sink) + }) +} + +/// Walk the exFAT volume `disk`, which is `len` bytes long, handing each file +/// to `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + len: u64, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + let max = crate::bytes::to_usize(budget.limits().max_buffer_bytes); + let mut v = Vol { + disk: &mut disk, + len, + max, + }; + match v.at(0, 512) { + Some(boot) if is_exfat(&boot) => {} + _ => return Ok(None), + } + let Some(mut fs) = open(&mut v) else { + return Err(LimitHit::corrupt( + "exFAT open: the boot sector is unreadable".into(), + )); + }; + + // One more than the member limit, so the entry past it is counted and the + // walk stops there rather than ending short. + let limit = crate::bytes::to_usize(budget.limits().max_members).saturating_add(1); + let mut left = u64::from(fs.clusters); + let mut damaged = false; + // A folder is walked once, however many entries name it. + let mut seen: HashSet<u32> = HashSet::from([fs.root]); + let root = Place { + first: fs.root, + in_a_row: false, + size: None, + }; + let mut stack = vec![(root, String::new(), 0usize)]; + while let Some((dir, prefix, depth)) = stack.pop() { + let (items, intact) = fs.items(&mut v, dir, &mut left, limit); + damaged |= !intact; + let mut below = Vec::new(); + for item in items { + budget.count_entry()?; + let path = if prefix.is_empty() { + item.name.clone() + } else { + format!("{prefix}/{}", item.name) + }; + if item.dir { + if item.place.first < 2 || depth >= MAX_DEPTH { + damaged = true; + } else if seen.insert(item.place.first) { + below.push((item.place, path, depth.saturating_add(1))); + } + continue; + } + let attrs = Attrs { + mode: None, + mtime: item.mtime, + }; + if let Some(r) = give(budget, &mut *visit, path, attrs, |cap| { + fs.file(&mut v, &item, cap) + })? { + return Ok(Some(r)); + } + } + stack.extend(below.into_iter().rev()); + } + if damaged { + return Err(LimitHit::corrupt( + "exFAT walk: the volume is damaged; the files before the damage were scanned".into(), + )); + } + Ok(None) +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::super::vol::collected; + use super::*; + use crate::Limits; + use std::io::Cursor; + + /// Sectors and clusters of 512 bytes; the table at sector 1, 4 sectors + /// long, the heap at sector 8, 100 clusters, the root in cluster 2. + const FAT_AT: usize = 512; + const HEAP: usize = 8 * 512; + + struct Img(Vec<u8>); + + impl Img { + fn new() -> Img { + let mut d = vec![0u8; HEAP + 100 * 512]; + d[0] = 0xEB; + d[3..11].copy_from_slice(b"EXFAT "); + d[72..80].copy_from_slice(&108u64.to_le_bytes()); + d[80..84].copy_from_slice(&1u32.to_le_bytes()); + d[84..88].copy_from_slice(&4u32.to_le_bytes()); + d[88..92].copy_from_slice(&8u32.to_le_bytes()); + d[92..96].copy_from_slice(&100u32.to_le_bytes()); + d[96..100].copy_from_slice(&2u32.to_le_bytes()); + d[104..106].copy_from_slice(&0x0100u16.to_le_bytes()); + d[108] = 9; + d[109] = 0; + d[110] = 1; + d[510..512].copy_from_slice(&[0x55, 0xAA]); + let mut img = Img(d); + img.link(2, 0xFFFF_FFFF); + img + } + + fn link(&mut self, c: usize, next: u32) { + self.0[FAT_AT + c * 4..FAT_AT + c * 4 + 4].copy_from_slice(&next.to_le_bytes()); + } + + fn put(&mut self, c: usize, bytes: &[u8]) { + let at = HEAP + (c - 2) * 512; + self.0[at..at + bytes.len()].copy_from_slice(bytes); + } + + /// `entries` as the directory in cluster `c`. + fn dir(&mut self, c: usize, entries: &[Vec<[u8; 32]>]) { + let flat: Vec<u8> = entries.iter().flatten().flatten().copied().collect(); + self.put(c, &flat); + } + } + + /// The entry set of a file (or folder) called `name`. + fn set( + name: &str, + attr: u16, + first: u32, + size: u64, + valid: u64, + in_a_row: bool, + ) -> Vec<[u8; 32]> { + let units: Vec<u16> = name.encode_utf16().collect(); + let names = units.len().div_ceil(15); + let mut file = [0u8; 32]; + file[0] = 0x85; + file[1] = 1 + names as u8; + file[4..6].copy_from_slice(&attr.to_le_bytes()); + // 2024-03-05 14:30:12. + let ts: u32 = (44 << 25) | (3 << 21) | (5 << 16) | (14 << 11) | (30 << 5) | 6; + file[12..16].copy_from_slice(&ts.to_le_bytes()); + let mut stream = [0u8; 32]; + stream[0] = 0xC0; + stream[1] = 0x01 | if in_a_row { 0x02 } else { 0 }; + stream[3] = units.len() as u8; + stream[8..16].copy_from_slice(&valid.to_le_bytes()); + stream[20..24].copy_from_slice(&first.to_le_bytes()); + stream[24..32].copy_from_slice(&size.to_le_bytes()); + let mut out = vec![file, stream]; + for chunk in units.chunks(15) { + let mut e = [0u8; 32]; + e[0] = 0xC1; + for (i, u) in chunk.iter().enumerate() { + e[2 + i * 2..4 + i * 2].copy_from_slice(&u.to_le_bytes()); + } + out.push(e); + } + out + } + + type Member = (String, Vec<u8>, Option<&'static str>); + + fn walked(d: &[u8], limits: Limits) -> (Vec<Member>, Result<(), LimitHit>) { + let mut out = Vec::new(); + let mut budget = Budget::new(limits); + let len = d.len() as u64; + let end = walk_volume(Cursor::new(d), len, &mut budget, &mut |e, _| { + let e = e.whole(); + out.push((e.name, e.data, e.unsupported)); + None::<()> + }); + (out, end.map(|_| ())) + } + + /// Files in a row, chained, written in part, empty, and long-named. + fn mixed() -> Vec<u8> { + let mut img = Img::new(); + // The root takes clusters 2 and 3. + img.link(2, 3); + img.link(3, 0xFFFF_FFFF); + // A file in a row, in clusters 13 and 14; a file chained 15, 17; + // another written to 600 bytes of 1000. + img.put(13, &[b'a'; 512]); + img.put(14, &[b'b'; 512]); + img.put(15, &[b'c'; 512]); + img.put(17, &[b'c'; 512]); + img.link(15, 17); + img.link(17, 0xFFFF_FFFF); + img.put(18, &[b'd'; 512]); + img.put(19, &[b'd'; 512]); + img.link(18, 19); + img.link(19, 0xFFFF_FFFF); + img.dir( + 2, + &[ + set("row.bin", 0x20, 13, 700, 700, true), + set("chain.bin", 0x20, 15, 1000, 1000, false), + set("partly.bin", 0x20, 18, 1000, 600, false), + set("empty", 0x20, 0, 0, 0, false), + set("a name of more than fifteen letters", 0x20, 13, 1, 1, true), + ], + ); + img.0 + } + + #[test] + fn files_come_out_whether_they_are_in_a_row_or_chained_and_the_unwritten_end_is_zeros() { + let (e, end) = walked(&mixed(), Limits::default()); + end.unwrap(); + let by = |n: &str| e.iter().find(|x| x.0 == n).unwrap(); + assert_eq!(by("row.bin").1, [vec![b'a'; 512], vec![b'b'; 188]].concat()); + assert_eq!(by("chain.bin").1, vec![b'c'; 1000]); + assert_eq!( + by("partly.bin").1, + [vec![b'd'; 600], vec![0u8; 400]].concat() + ); + assert_eq!(by("empty").1, b""); + assert_eq!(by("a name of more than fifteen letters").1, b"a"); + assert!(e.iter().all(|x| x.2.is_none())); + } + + #[test] + fn folders_are_walked_once_and_a_folder_that_names_itself_does_not_loop() { + let mut img = Img::new(); + img.link(3, 0xFFFF_FFFF); + img.link(4, 0xFFFF_FFFF); + img.put(5, b"deep"); + img.link(5, 0xFFFF_FFFF); + img.dir( + 2, + &[ + set("sub", 0x10, 3, 512, 512, false), + // The same folder under another name. + set("again", 0x10, 3, 512, 512, false), + ], + ); + img.put( + 3, + &[ + set("inner", 0x10, 4, 512, 512, false), + set("self", 0x10, 3, 512, 512, false), + set("root", 0x10, 2, 512, 512, false), + ] + .iter() + .flatten() + .flatten() + .copied() + .collect::<Vec<u8>>(), + ); + img.put(4, &set("deep.txt", 0x20, 5, 4, 4, false).concat()); + let (e, end) = walked(&img.0, Limits::default()); + end.unwrap(); + let names: Vec<_> = e.iter().map(|x| x.0.as_str()).collect(); + assert_eq!(names, ["sub/inner/deep.txt"]); + } + + #[test] + fn an_entry_set_that_is_not_one_is_not_a_file() { + let mut img = Img::new(); + img.put(3, &[b'x'; 512]); + let good = set("good", 0x20, 3, 5, 5, true); + // A file entry with its stream cut off by an entry of another kind. + let mut cut = set("cut", 0x20, 3, 5, 5, true); + cut.truncate(1); + let mut other = [0u8; 32]; + other[0] = 0x83; + // A secondary entry with no file entry before it. + let mut orphan = set("orphan", 0x20, 3, 5, 5, true); + orphan.remove(0); + // A set that begins with a name, not a stream. + let mut nameless = set("nameless", 0x20, 3, 5, 5, true); + nameless.swap(1, 2); + // Entries not in use. + let mut unused = set("unused", 0x20, 3, 5, 5, true); + for e in &mut unused { + e[0] &= 0x7F; + } + img.dir(2, &[cut, vec![other], orphan, nameless, unused, good]); + let (e, end) = walked(&img.0, Limits::default()); + end.unwrap(); + let names: Vec<_> = e.iter().map(|x| x.0.as_str()).collect(); + assert_eq!(names, ["good"]); + } + + #[test] + fn a_chain_that_ends_early_is_a_file_cut_short() { + let mut img = Img::new(); + img.put(3, &[b'x'; 512]); + img.link(3, 0xFFFF_FFFF); + img.dir(2, &[set("short", 0x20, 3, 1500, 1500, false)]); + let (e, end) = walked(&img.0, Limits::default()); + end.unwrap(); + assert_eq!(e.len(), 1); + assert_eq!(e[0].2, Some("file could not be read from the volume")); + assert_eq!(e[0].1.len(), 512); + } + + #[test] + fn the_entry_past_the_member_limit_is_counted_and_stops_the_walk() { + let mut img = Img::new(); + img.dir( + 2, + &[ + set("a", 0x20, 0, 0, 0, false), + set("b", 0x20, 0, 0, 0, false), + set("c", 0x20, 0, 0, 0, false), + ], + ); + let run = |max: u64| { + let limits = Limits { + max_members: max, + ..Limits::default() + }; + walked(&img.0, limits) + }; + let (e, end) = run(3); + end.unwrap(); + assert_eq!(e.len(), 3); + let (e, end) = run(2); + assert!(!end.unwrap_err().is_corrupt()); + assert_eq!(e.len(), 2); + } + + /// A [`Fill`] that gives its chunks one at a time. + struct Chunks(std::vec::IntoIter<Vec<u8>>); + + impl Fill for Chunks { + fn fill(&mut self, _: &mut Vol, out: &mut Vec<u8>) -> Step { + match self.0.next() { + Some(c) => { + out.extend_from_slice(&c); + Step::More + } + None => Step::End, + } + } + } + + #[test] + fn what_is_past_the_valid_length_is_zeros_whichever_chunk_it_falls_in() { + let mut disk = Cursor::new(Vec::new()); + let mut v = Vol { + disk: &mut disk, + len: 0, + max: 0, + }; + let chunks = vec![vec![1u8; 4], vec![2u8; 4], vec![3u8; 4]]; + for (valid, want) in [ + (0u64, [0u8; 12].to_vec()), + (3, [vec![1; 3], vec![0; 9]].concat()), + (4, [vec![1; 4], vec![0; 8]].concat()), + (6, [vec![1; 4], vec![2; 2], vec![0; 6]].concat()), + (12, [vec![1; 4], vec![2; 4], vec![3; 4]].concat()), + ] { + let mut fill = Valid { + inner: Chunks(chunks.clone().into_iter()), + at: 0, + valid, + }; + let mut out = Vec::new(); + while fill.fill(&mut v, &mut out) == Step::More {} + assert_eq!(out, want, "valid {valid}"); + } + } + + #[test] + fn files_over_the_buffer_limit_are_read_as_they_go_and_come_out_as_they_do_held() { + let d = mixed(); + let (held, end) = walked(&d, Limits::default()); + end.unwrap(); + let limits = Limits { + max_buffer_bytes: 100, + ..Limits::default() + }; + let mut budget = Budget::new(limits); + let len = d.len() as u64; + let (e, end) = collected(|emit| walk_volume(Cursor::new(&d[..]), len, &mut budget, emit)); + end.unwrap(); + assert_eq!(e.len(), held.len()); + for ((entry, streamed), (name, data, _)) in e.iter().zip(&held) { + assert_eq!((&entry.name, &entry.data), (name, data)); + assert_eq!(*streamed, data.len() > 100, "{name}"); + assert_eq!(entry.unsupported, None, "{name}"); + } + assert!(e.iter().filter(|(_, streamed)| *streamed).count() >= 3); + } + + #[test] + fn a_boot_sector_that_describes_no_volume_is_not_opened() { + // What is written, and where. + let edits: [(&str, usize, &[u8]); 10] = [ + ("a FAT parameter where zeros are", 20, &[1]), + ("a revision of another major number", 105, &[2]), + ("sectors of 256", 108, &[8]), + ("sectors of 8 KiB", 108, &[13]), + ("a cluster past 32 MiB", 109, &[17]), + ("three tables", 110, &[3]), + ("no tables", 110, &[0]), + ("no table length", 84, &[0, 0, 0, 0]), + ("a root outside the clusters", 96, &[200, 0, 0, 0]), + ("a heap past the volume", 88, &[0xFF, 0xFF, 0, 0]), + ]; + for (what, at, bytes) in edits { + let mut d = Img::new().0; + d[at..at + bytes.len()].copy_from_slice(bytes); + let (e, end) = walked(&d, Limits::default()); + assert!(e.is_empty(), "{what}"); + // A boot sector that is not exFAT's is no volume; one that is and + // cannot be used is damage. + if is_exfat(&d) { + assert!(end.unwrap_err().is_corrupt(), "{what}"); + } + } + } + + #[test] + fn the_second_table_is_the_one_in_use_when_the_flags_say_so() { + let mut img = Img::new(); + img.0[110] = 2; + let table = |img: &Img| { + let mut d = img.0.clone(); + let len = d.len() as u64; + let mut cur = Cursor::new(&mut d[..]); + let mut v = Vol { + disk: &mut cur, + len, + max: 1 << 20, + }; + open(&mut v).map(|f| f.table) + }; + assert_eq!(table(&img), Some(512)); + img.0[106] = 1; + assert_eq!(table(&img), Some(512 + 4 * 512)); + } + + #[test] + fn a_timestamp_is_wall_clock_or_none() { + let t: u32 = (44 << 25) | (3 << 21) | (5 << 16) | (14 << 11) | (30 << 5) | 6; + assert_eq!( + timestamp(t), + Some(Mtime::Local { + year: 2024, + month: 3, + day: 5, + hour: 14, + minute: 30, + second: 12 + }) + ); + assert_eq!(timestamp(0), None); + assert_eq!(timestamp(t & !(0xF << 21)), None); + assert_eq!(timestamp((t & !(0x1F << 11)) | (25 << 11)), None); + } +} diff --git a/crates/exav-unpack/src/formats/ext.rs b/crates/exav-unpack/src/formats/ext.rs index 9f4a65fa..a0c17075 100644 --- a/crates/exav-unpack/src/formats/ext.rs +++ b/crates/exav-unpack/src/formats/ext.rs @@ -1,230 +1,1176 @@ -//! ext2/ext3/ext4 — walking the filesystem rather than carving the bytes. +//! ext2, ext3 and ext4: the files of a volume, read where they lie. //! -//! Same argument as `formats/fat.rs`: a disk image handed to a victim gets -//! mounted, and what they run is a *file* in the filesystem. Carving finds a -//! file whose magic sits at the start of a contiguous run and misses the rest, -//! and ext4 fragments freely — a file written into a hole left by a deleted one -//! lands in two extents, and the first fragment alone will not decompress. -//! Reassembling it needs the inode's block map, which means reading the -//! filesystem. +//! A disk image handed to a victim is mounted, and what they run is a file in +//! the filesystem. Carving finds a file whose magic sits at the start of a +//! contiguous run and misses the rest, and ext4 fragments freely: a file +//! written into a hole left by a deleted one lands in two extents, and the +//! first fragment alone will not decompress. Reassembling it takes the inode's +//! extent tree or block map, which means reading the filesystem. //! -//! Reading is delegated to the `ext4-view` crate: read-only by construction (it -//! has no write path at all), no `unsafe`, and no dependencies of its own. +//! The volume is a `Read + Seek`, read where the walk goes: the superblock, the +//! group descriptors, each directory's blocks, the inodes it names and the +//! files' blocks. Only a file is ever held, bounded by the buffer limit. //! -//! **This is a forced-materialization site.** `Ext4::load` takes an owned -//! `Box<dyn Ext4Read>`, so the image is copied once — it cannot borrow the -//! caller's buffer. The copy is charged against `max_buffer_bytes` like every -//! other such site, and an image past that budget is reported rather than -//! quietly skipped. +//! ```text +//! superblock (1024 bytes in) block size, inode size, group geometry +//! group descriptors where each group's inode table is +//! inode table mode, size, and 60 bytes that are an extent +//! tree root, a block map, or the data itself +//! directories linear lists of (inode, name); an indexed +//! directory is read as the list it also is +//! ``` +//! +//! Extents (ext4), block maps (ext2/3), inline data, 64-bit group descriptors +//! and meta block groups are followed. A preallocated extent reads as zeros, as +//! it does to the system that mounts the volume. A file in an encrypted +//! directory is reported. A symbolic link or a device node holds no file's +//! bytes and is not emitted. The journal is not replayed, and no checksum is +//! checked: a structure the system would refuse to mount is read all the same, +//! which can only show a scan more. // Every sum and product on a header's number is checked or saturating here; a // plain one fails the build, so the next edit cannot add the unchecked kind. #![deny(clippy::arithmetic_side_effects)] -use crate::{Budget, Entry, LimitHit, Sink}; +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek}; + +use super::vol::{Attrs, Emit, Got, Next, Piece, Pieces, Plan, Vol, give, le16, le32, read_file}; +use crate::source::{ByteSource, Reader}; +use crate::stream::Visit; +use crate::{Budget, LimitHit, Mtime}; + +/// The inode of the root directory. +const ROOT: u32 = 2; + +/// The most directories a path passes through. +const MAX_DEPTH: usize = 256; + +/// The most runs of blocks one file or directory is followed through. +const MAX_RUNS: usize = 1 << 20; + +/// The most blocks of an extent tree or block map one file is followed through. +const MAX_TREE_NODES: u32 = 1 << 16; + +/// The deepest an extent tree goes. +const MAX_EXTENT_DEPTH: u16 = 5; -/// Bounds on the tree walk; hitting either is reported, never a quiet stop. -const MAX_ENTRIES: usize = 20_000; -const MAX_DEPTH: u32 = 32; +/// How many descriptor blocks are kept. +const DESC_CACHE: usize = 64; -/// The superblock magic `0xEF53`, which sits 0x38 bytes into the superblock — -/// itself 1024 bytes into the volume. +/// `s_feature_incompat` bits. +const INCOMPAT_JOURNAL_DEV: u32 = 0x8; +const INCOMPAT_META_BG: u32 = 0x10; +const INCOMPAT_64BIT: u32 = 0x80; +const INCOMPAT_LARGEDIR: u32 = 0x4000; +/// `s_feature_compat` and `s_feature_ro_compat` bits. +const COMPAT_SPARSE_SUPER2: u32 = 0x200; +const RO_COMPAT_SPARSE_SUPER: u32 = 0x1; +const RO_COMPAT_BIGALLOC: u32 = 0x200; + +/// `i_flags` bits. +const ENCRYPT_FL: u32 = 0x800; +const EXTENTS_FL: u32 = 0x8_0000; +const INLINE_DATA_FL: u32 = 0x1000_0000; + +const S_IFMT: u16 = 0o170000; +const S_IFREG: u16 = 0o100000; +const S_IFDIR: u16 = 0o040000; + +/// Whether `data`, the start of a volume, holds an ext superblock's magic. pub(crate) fn is_ext(data: &[u8]) -> bool { super::sniff::is(data, crate::Format::Ext) } -pub(crate) fn extract_ext<R>( - data: &[u8], - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - if !is_ext(data) { - return Ok(None); - } - // See the module note: the reader must own its bytes. - let cap = budget.limits.max_buffer_bytes; - if data.len() as u64 > cap { - budget.count_entry()?; - return Ok(visit( - Entry::unsupported( - "<ext-filesystem>".to_string(), - data.len() as u64, - false, - "ext filesystem image exceeds the peak-buffer budget, so the \ - files inside it were not examined", - ), - budget, - )); +/// What the superblock says of the volume. +struct Fs { + /// Bytes in a block. + bs: u64, + /// Blocks in the volume: those it says it has, or fewer where the image + /// ends first. + blocks: u64, + first_data: u64, + /// Blocks and inodes in a group. + bpg: u64, + ipg: u64, + inodes: u64, + inode_size: usize, + desc_size: usize, + groups: u64, + meta_bg: bool, + first_meta_bg: u64, + sparse: bool, + sparse2: bool, + backup: [u64; 2], + large_dir: bool, + /// Descriptor blocks read, by block. + descs: HashMap<u64, Vec<u8>>, +} + +/// An inode. +struct Inode { + mode: u16, + flags: u32, + size: u64, + mtime: i64, + raw: Vec<u8>, +} + +impl Inode { + fn kind(&self) -> u16 { + self.mode & S_IFMT } - let fs = match ext4_view::Ext4::load(Box::new(data.to_vec())) { - Ok(fs) => fs, - Err(e) => { - // The superblock said ext; failing to mount leaves every file in it - // unexamined, which must not pass quietly. - let _ = e; - budget.count_entry()?; - return Ok(visit( - Entry::unsupported( - "<ext-filesystem>".to_string(), - data.len() as u64, - false, - "ext filesystem could not be mounted", - ), - budget, - )); + + /// The 60 bytes that hold the extent tree, the block map or the data. + fn block(&self) -> &[u8] { + crate::bytes::at(&self.raw, 40, 60).unwrap_or(&[]) + } + + fn attrs(&self) -> Attrs { + Attrs { + mode: Some(u32::from(self.mode)), + mtime: Some(Mtime::Unix(self.mtime)), } - }; + } +} - let mut seen = 0usize; - let mut stack = vec![("/".to_string(), 0u32)]; - while let Some((dir, depth)) = stack.pop() { - if depth > MAX_DEPTH { - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - format!("<ext-below-depth-{MAX_DEPTH}>"), - 0, - false, - "ext directory tree deeper than exav walks", - ), - budget, - ) { - return Ok(Some(r)); +/// A run of a file's blocks. +struct Run { + logical: u64, + phys: u64, + len: u64, + /// Preallocated, not written: it reads as zeros. + zero: bool, +} + +/// The stretches of a file, from its runs: a block the map does not give is a +/// hole. +struct Extents { + runs: std::vec::IntoIter<Run>, + /// A run held back while the hole before it is given. + pending: Option<Run>, + bs: u64, + size: u64, + /// How many bytes of the file the stretches given so far cover. + at: u64, + /// The map is whole where its runs end. + intact: bool, +} + +impl Plan for Extents { + fn next(&mut self, _: &mut Vol) -> Next { + loop { + if self.at >= self.size { + return Next::End; } - continue; - } - let entries = match fs.read_dir(dir.as_str()) { - Ok(e) => e, - Err(e) => { - // Everything under an unreadable directory is unreachable. - let _ = e; - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - format!("<ext-dir:{dir}>"), - 0, - false, - "unreadable ext directory; its contents are unreachable", - ), - budget, - ) { - return Ok(Some(r)); + let Some(run) = self.pending.take().or_else(|| self.runs.next()) else { + // Past the last run, to the end of the file, is a hole. + if !self.intact { + return Next::Broken; } + let n = self.size.saturating_sub(self.at); + self.at = self.size; + return Next::Piece(Piece::Zeros(n)); + }; + let from = run.logical.saturating_mul(self.bs); + if from >= self.size { + self.runs = Vec::new().into_iter(); continue; } + // Runs come in order and do not overlap. + if from < self.at { + return Next::Broken; + } + if from > self.at { + let hole = from.saturating_sub(self.at); + self.at = from; + self.pending = Some(run); + return Next::Piece(Piece::Zeros(hole)); + } + let want = run + .len + .saturating_mul(self.bs) + .min(self.size.saturating_sub(from)); + self.at = from.saturating_add(want); + if run.zero { + return Next::Piece(Piece::Zeros(want)); + } + return match run.phys.checked_mul(self.bs) { + Some(at) => Next::Piece(Piece::Disk(at, want)), + None => Next::Broken, + }; + } + } +} + +/// Where following a map stands. +enum Flow { + More, + /// Everything wanted has been seen. + Done, + Bad, +} + +impl Fs { + /// Whether group `g` holds a copy of the superblock. + fn has_super(&self, g: u64) -> bool { + if g == 0 { + return true; + } + if self.sparse2 { + return g == self.backup[0] || g == self.backup[1]; + } + if !self.sparse { + return true; + } + g == 1 || [3u64, 5, 7].iter().any(|&p| is_power_of(g, p)) + } + + /// The block of group `g`'s inode table. + fn inode_table(&mut self, v: &mut Vol, g: u64) -> Option<u64> { + if g >= self.groups { + return None; + } + let per = self.bs.checked_div(self.desc_size as u64)?; + let (nr, idx) = (g.checked_div(per)?, g.checked_rem(per)?); + let at = if !self.meta_bg || nr < self.first_meta_bg { + // The superblock is in block 1 of a volume of 1 KiB blocks, else in 0. + let sb = u64::from(self.bs == 1024); + sb.saturating_add(1).saturating_add(nr) + } else { + let first = nr.saturating_mul(per); + self.first_data + .saturating_add(first.saturating_mul(self.bpg)) + .saturating_add(u64::from(self.has_super(first))) + }; + if !self.descs.contains_key(&at) { + let block = v.at(at.checked_mul(self.bs)?, crate::bytes::to_usize(self.bs))?; + if self.descs.len() >= DESC_CACHE { + self.descs.clear(); + } + self.descs.insert(at, block); + } + let block = self.descs.get(&at)?; + let d = crate::bytes::at( + block, + crate::bytes::to_usize(idx).checked_mul(self.desc_size)?, + self.desc_size, + )?; + let lo = u64::from(le32(d, 8)); + let hi = if self.desc_size >= 64 { + u64::from(le32(d, 0x28)) + } else { + 0 }; - for entry in entries { - let Ok(entry) = entry else { - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - format!("<ext-dir:{dir}>"), - 0, - false, - "unreadable ext directory entry; the rest of that \ - directory is unreachable", - ), - budget, - ) { - return Ok(Some(r)); - } - break; + Some(lo | hi.checked_shl(32)?) + } + + fn inode(&mut self, v: &mut Vol, ino: u32) -> Option<Inode> { + let n = u64::from(ino); + if n == 0 || n > self.inodes { + return None; + } + let n = n.saturating_sub(1); + let table = self.inode_table(v, n.checked_div(self.ipg)?)?; + let slot = n.checked_rem(self.ipg)?; + let off = table + .checked_mul(self.bs)? + .checked_add(slot.checked_mul(self.inode_size as u64)?)?; + let raw = v.at(off, self.inode_size)?; + let mode = le16(&raw, 0); + let kind = mode & S_IFMT; + let hi = if kind == S_IFREG || (kind == S_IFDIR && self.large_dir) { + u64::from(le32(&raw, 108)) + } else { + 0 + }; + let size = u64::from(le32(&raw, 4)) | hi.checked_shl(32)?; + // The high bits of the time are in the extra fields, if the inode has them. + let extra = usize::from(le16(&raw, 128)) >= 12; + let epoch = if extra { le32(&raw, 136) & 3 } else { 0 }; + let secs = i64::from(i32::from_le_bytes(le32(&raw, 16).to_le_bytes())); + let mtime = secs.saturating_add(i64::from(epoch).saturating_mul(1 << 32)); + Some(Inode { + mode, + flags: le32(&raw, 32), + size, + mtime, + raw, + }) + } + + /// The runs of `ino`'s blocks before logical block `upto`, in order. The + /// flag is false where the extent tree or the block map is damaged. + fn runs(&self, v: &mut Vol, ino: &Inode, upto: u64) -> (Vec<Run>, bool) { + let mut m = Map { + fs: self, + upto, + runs: Vec::new(), + next: 0, + nodes: 0, + }; + let flow = if ino.flags & EXTENTS_FL != 0 { + m.extents(v, ino.block(), None) + } else { + m.blocks(v, ino.block()) + }; + (m.runs, !matches!(flow, Flow::Bad)) + } + + /// The bytes of a regular file: held when they are at most `cap`, else + /// read as they go. + fn file<'a>(&self, v: &'a mut Vol<'_>, ino: &Inode, cap: u64) -> Got<'a> { + if ino.flags & ENCRYPT_FL != 0 { + return Got::Locked("ext file is encrypted"); + } + if ino.flags & INLINE_DATA_FL != 0 { + let size = crate::bytes::to_usize(ino.size); + let mut data = inline_data(ino); + let whole = data.len() >= size; + data.truncate(size); + return if whole { + Got::Whole(data) + } else { + Got::Short(data) }; - // A name that is not UTF-8 is still a name a `.cdb` signature may - // match on; `display()` keeps it printable without inventing bytes. - let name = entry.file_name().display().to_string(); - if name == "." || name == ".." { - continue; + } + let (runs, intact) = self.runs(v, ino, ino.size.div_ceil(self.bs)); + let plan = Extents { + runs: runs.into_iter(), + pending: None, + bs: self.bs, + size: ino.size, + at: 0, + intact, + }; + read_file(v, Pieces::new(plan, ino.size), ino.size, cap) + } + + /// The entries of directory `ino`, up to `limit` of them. The flag is + /// false where the directory is damaged. `left` counts the directory blocks + /// the walk may still read. + fn entries( + &self, + v: &mut Vol, + ino: &Inode, + left: &mut u64, + limit: usize, + ) -> (Vec<(String, u32)>, bool) { + let mut out = Vec::new(); + let bs = crate::bytes::to_usize(self.bs); + if ino.flags & INLINE_DATA_FL != 0 { + // The parent's number, then entries; more of them in the attribute. + let head = ino.block().get(4..).unwrap_or(&[]); + let mut intact = dirents(head, bs, &mut out, limit); + if let Some(more) = system_data(&ino.raw) { + intact &= dirents(more, bs, &mut out, limit); } - let path = format!("{}{}", dir.trim_end_matches('/'), format_args!("/{name}")); - seen = seen.saturating_add(1); - if seen > MAX_ENTRIES { - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - format!("<ext-entries-beyond-{MAX_ENTRIES}>"), - 0, - false, - "too many ext entries to walk them all", - ), - budget, - ) { - return Ok(Some(r)); + return (out, intact); + } + let upto = ino.size.div_ceil(self.bs); + let (runs, mut intact) = self.runs(v, ino, upto); + let mut at = 0u64; + 'runs: for run in runs { + // A directory with a hole in it. + intact &= run.logical == at; + at = run.logical; + for i in 0..run.len.min(upto.saturating_sub(run.logical)) { + if *left == 0 { + intact = false; + break 'runs; } - return Ok(None); - } - // `symlink_metadata` rather than `metadata`: a symlink must be seen - // as a symlink, not silently followed to whatever it points at — - // which for an absolute target would be a path outside the image. - let Ok(meta) = fs.symlink_metadata(path.as_str()) else { - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - path, - 0, - false, - "ext inode could not be read, so this file went unexamined", - ), - budget, - ) { - return Ok(Some(r)); + *left = left.saturating_sub(1); + at = at.saturating_add(1); + // A block never written is zeros, which is no entry. + let block = (!run.zero) + .then(|| { + let blk = run.phys.saturating_add(i); + blk.checked_mul(self.bs).and_then(|o| v.at(o, bs)) + }) + .flatten(); + let Some(block) = block else { + intact = false; + continue; + }; + intact &= dirents(&block, bs, &mut out, limit); + if out.len() >= limit { + break 'runs; } - continue; + } + } + (out, intact) + } +} + +/// Whether `n` is a power of `p`. +fn is_power_of(mut n: u64, p: u64) -> bool { + if n == 0 || p < 2 { + return false; + } + while n.checked_rem(p) == Some(0) { + n = n.checked_div(p).unwrap_or(0); + } + n == 1 +} + +/// The length of a directory entry, as the block size stores it. +fn rec_len(raw: u16, bs: usize) -> usize { + if bs < 1 << 16 { + usize::from(raw) + } else if raw == 0 || raw == u16::MAX { + bs + } else { + usize::from(raw & 0xFFFC) | (usize::from(raw & 3) << 16) + } +} + +/// Append the named entries of a block of directory entries to `out`, up to +/// `limit` in all. False where an entry is not well formed: the rest of the +/// block is not read. +fn dirents(block: &[u8], bs: usize, out: &mut Vec<(String, u32)>, limit: usize) -> bool { + let mut pos = 0usize; + while pos < block.len() { + let Some(head) = crate::bytes::at(block, pos, 8) else { + return false; + }; + let ino = le32(head, 0); + let rec = rec_len(le16(head, 4), bs); + let name_len = usize::from(head[6]); + let end = pos.saturating_add(rec); + if rec < 8 + || !rec.is_multiple_of(4) + || end > block.len() + || name_len.saturating_add(8) > rec + { + return false; + } + let name = crate::bytes::at(block, pos.saturating_add(8), name_len).unwrap_or(&[]); + // The directory and its parent are not entries of it to walk. + if ino != 0 && name_len > 0 && name != b"." && name != b".." { + if out.len() >= limit { + return true; + } + out.push((String::from_utf8_lossy(name).into_owned(), ino)); + } + pos = end; + } + true +} + +/// The `system.data` attribute kept in the inode itself: the end of a file or +/// a directory that does not fit the 60 bytes of `i_block`. +fn system_data(raw: &[u8]) -> Option<&[u8]> { + let start = 128usize.saturating_add(usize::from(le16(raw, 128))); + let region = raw.get(start..)?; + if le32(region, 0) != 0xEA02_0000 { + return None; + } + let mut pos = 4usize; + loop { + let e = crate::bytes::at(region, pos, 16)?; + if le32(e, 0) == 0 { + return None; + } + let name_len = usize::from(e[0]); + let name = crate::bytes::at(region, pos.saturating_add(16), name_len)?; + if e[1] == 7 && name == b"data" { + // Values are placed from the first entry on. + let off = 4usize.saturating_add(usize::from(le16(e, 2))); + return crate::bytes::at(region, off, crate::bytes::to_usize(u64::from(le32(e, 8)))); + } + let step = name_len.saturating_add(16).saturating_add(3) & !3; + pos = pos.saturating_add(step); + } +} + +/// The bytes of a file or a directory kept in its inode. +fn inline_data(ino: &Inode) -> Vec<u8> { + let mut data = ino.block().to_vec(); + if let Some(more) = system_data(&ino.raw) { + data.extend_from_slice(more); + } + data +} + +/// Following the extent tree or the block map of one inode. +struct Map<'a> { + fs: &'a Fs, + /// The first logical block that is not wanted. + upto: u64, + runs: Vec<Run>, + /// The logical block the next run must not start before. + next: u64, + nodes: u32, +} + +impl Map<'_> { + fn push(&mut self, run: Run) -> Flow { + if run.logical >= self.upto { + return Flow::Done; + } + // Runs come in order and do not overlap; one that does is not a map. + if run.logical < self.next || self.runs.len() >= MAX_RUNS { + return Flow::Bad; + } + self.next = run.logical.saturating_add(run.len); + self.runs.push(run); + Flow::More + } + + /// The runs of the extent tree whose node is `node`. + fn extents(&mut self, v: &mut Vol, node: &[u8], depth: Option<u16>) -> Flow { + let height = le16(node, 6); + let entries = usize::from(le16(node, 2)); + let room = node.len().saturating_sub(12).checked_div(12).unwrap_or(0); + if le16(node, 0) != 0xF30A + || height > MAX_EXTENT_DEPTH + || depth.is_some_and(|d| d != height) + || entries > room + { + return Flow::Bad; + } + for i in 0..entries { + let at = 12usize.saturating_add(i.saturating_mul(12)); + let Some(e) = crate::bytes::at(node, at, 12) else { + return Flow::Bad; }; - if meta.is_dir() { - stack.push((path, depth.saturating_add(1))); - continue; + let block = u64::from(le32(e, 0)); + if height == 0 { + let raw = u64::from(le16(e, 4)); + // A length past 32768 marks an extent preallocated, not written. + let (len, zero) = match raw.checked_sub(32768) { + Some(n) if n > 0 => (n, true), + _ => (raw, false), + }; + if len == 0 { + continue; + } + let phys = u64::from(le32(e, 8)) | u64::from(le16(e, 6)).saturating_mul(1 << 32); + match self.push(Run { + logical: block, + phys, + len, + zero, + }) { + Flow::More => {} + other => return other, + } + } else { + if block >= self.upto { + return Flow::Done; + } + let child = u64::from(le32(e, 4)) | u64::from(le16(e, 8)).saturating_mul(1 << 32); + self.nodes = self.nodes.saturating_add(1); + let read = (self.nodes <= MAX_TREE_NODES) + .then(|| child.checked_mul(self.fs.bs)) + .flatten() + .and_then(|o| v.at(o, crate::bytes::to_usize(self.fs.bs))); + let Some(read) = read else { + return Flow::Bad; + }; + match self.extents(v, &read, Some(height.saturating_sub(1))) { + Flow::More => {} + other => return other, + } } - // Only regular files hold content. A symlink is a path, and a - // device/fifo/socket node has no bytes in the image at all — there - // is nothing here that went unscanned, so nothing to report. - if meta.file_type() != ext4_view::FileType::Regular { - continue; + } + Flow::More + } + + /// The runs of a block map, `i_block` being twelve block numbers and then + /// those of a block of block numbers, one of those, and one of those. + fn blocks(&mut self, v: &mut Vol, i_block: &[u8]) -> Flow { + let mut at = 0u64; + let mut last: Option<Run> = None; + let flow = self.map_blocks(v, i_block, &mut at, &mut last); + if let Some(run) = last + && let Flow::Bad = self.push(run) + { + return Flow::Bad; + } + flow + } + + fn map_blocks( + &mut self, + v: &mut Vol, + i_block: &[u8], + at: &mut u64, + last: &mut Option<Run>, + ) -> Flow { + for slot in 0..15usize { + if *at >= self.upto { + return Flow::Done; + } + let ptr = u64::from(le32(i_block, slot.saturating_mul(4))); + let level = match slot { + 0..=11 => 0u32, + 12 => 1, + 13 => 2, + _ => 3, + }; + match self.level(v, level, ptr, at, last) { + Flow::More => {} + other => return other, + } + } + Flow::More + } + + /// The blocks `ptr` stands for: a data block at level 0, else a block of + /// pointers to the level below. + fn level( + &mut self, + v: &mut Vol, + level: u32, + ptr: u64, + at: &mut u64, + last: &mut Option<Run>, + ) -> Flow { + let per = self.fs.bs.checked_div(4).unwrap_or(0); + if level == 0 { + let here = *at; + *at = at.saturating_add(1); + if ptr == 0 { + return self.flush(last); } + if let Some(run) = last + && run.phys.saturating_add(run.len) == ptr + && run.logical.saturating_add(run.len) == here + { + run.len = run.len.saturating_add(1); + return Flow::More; + } + if let Flow::Bad = self.flush(last) { + return Flow::Bad; + } + *last = Some(Run { + logical: here, + phys: ptr, + len: 1, + zero: false, + }); + return Flow::More; + } + if ptr == 0 { + // A hole as wide as the blocks below it. + *at = at.saturating_add(per.saturating_pow(level)); + return self.flush(last); + } + self.nodes = self.nodes.saturating_add(1); + let read = (self.nodes <= MAX_TREE_NODES) + .then(|| ptr.checked_mul(self.fs.bs)) + .flatten() + .and_then(|o| v.at(o, crate::bytes::to_usize(self.fs.bs))); + let Some(read) = read else { + return Flow::Bad; + }; + for i in 0..crate::bytes::to_usize(per) { + if *at >= self.upto { + return Flow::Done; + } + let p = u64::from(le32(&read, i.saturating_mul(4))); + match self.level(v, level.saturating_sub(1), p, at, last) { + Flow::More => {} + other => return other, + } + } + Flow::More + } + fn flush(&mut self, last: &mut Option<Run>) -> Flow { + match last.take() { + Some(run) => self.push(run), + None => Flow::More, + } + } +} + +/// The volume's superblock, or `None` where it is not one a system would mount. +fn open(v: &mut Vol) -> Option<Fs> { + let sb = v.at(1024, 1024)?; + if le16(&sb, 56) != 0xEF53 { + return None; + } + let bs = 1024u64 + .checked_shl(le32(&sb, 24)) + .filter(|&b| b <= 1 << 16)?; + let (compat, incompat, ro_compat) = (le32(&sb, 92), le32(&sb, 96), le32(&sb, 100)); + // A journal kept apart from the files it journals holds none of them. + if incompat & INCOMPAT_JOURNAL_DEV != 0 { + return None; + } + let wide = incompat & INCOMPAT_64BIT != 0; + let hi = if wide { u64::from(le32(&sb, 336)) } else { 0 }; + let blocks = (u64::from(le32(&sb, 4)) | hi.checked_shl(32)?).min(v.len.checked_div(bs)?); + let first_data = u64::from(le32(&sb, 20)); + let (bpg, ipg) = (u64::from(le32(&sb, 32)), u64::from(le32(&sb, 40))); + // A bitmap bit stands for a cluster, which is a block unless bigalloc says more. + let ratio = if ro_compat & RO_COMPAT_BIGALLOC != 0 { + let shift = le32(&sb, 28).checked_sub(le32(&sb, 24))?; + 1u64.checked_shl(shift).filter(|&r| r <= 1 << 16)? + } else { + 1 + }; + let per_bitmap = bs.checked_mul(8)?; + if bpg == 0 + || ipg == 0 + || bpg > per_bitmap.checked_mul(ratio)? + || ipg > per_bitmap + || blocks <= first_data + { + return None; + } + let inode_size = if le32(&sb, 76) == 0 { + 128 + } else { + usize::from(le16(&sb, 88)) + }; + if !inode_size.is_power_of_two() || inode_size < 128 || inode_size as u64 > bs { + return None; + } + let desc_size = if wide { + usize::from(le16(&sb, 254)) + } else { + 32 + }; + if !desc_size.is_power_of_two() || desc_size < 32 || (wide && desc_size < 64) { + return None; + } + if desc_size as u64 > bs { + return None; + } + let groups = blocks.saturating_sub(first_data).div_ceil(bpg); + let mut fs = Fs { + bs, + blocks, + first_data, + bpg, + ipg, + inodes: u64::from(le32(&sb, 0)).min(groups.saturating_mul(ipg)), + inode_size, + desc_size, + groups, + meta_bg: incompat & INCOMPAT_META_BG != 0, + first_meta_bg: u64::from(le32(&sb, 0x104)), + sparse: ro_compat & RO_COMPAT_SPARSE_SUPER != 0, + sparse2: compat & COMPAT_SPARSE_SUPER2 != 0, + backup: [u64::from(le32(&sb, 0x24C)), u64::from(le32(&sb, 0x250))], + large_dir: incompat & INCOMPAT_LARGEDIR != 0, + descs: HashMap::new(), + }; + // A volume whose root is not a directory is none that can be walked. + let root = fs.inode(v, ROOT)?; + (root.kind() == S_IFDIR).then_some(fs) +} + +/// Whether `src` is an ext volume a system would mount: a superblock, and a root +/// that is a directory. The first kilobyte is left to boot code, so a file that +/// starts as something else can be one too. +pub(crate) fn is_volume(src: &dyn ByteSource) -> bool { + let mut disk = Reader::new(src); + let mut v = Vol { + disk: &mut disk, + len: src.len() as u64, + max: 1 << 20, + }; + v.at(1080, 2).is_some_and(|m| m == [0x53, 0xEF]) && open(&mut v).is_some() +} + +/// Walk an ext volume given whole as a source. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + super::volumes::entries(budget, visit, "", |budget, sink| { + walk_volume(Reader::new(src), src.len() as u64, budget, sink) + }) +} + +/// Walk the ext volume `disk`, which is `len` bytes long, handing each file to +/// `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + len: u64, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + let max = crate::bytes::to_usize(budget.limits().max_buffer_bytes); + let mut v = Vol { + disk: &mut disk, + len, + max, + }; + match v.at(1080, 2) { + Some(magic) if magic == [0x53, 0xEF] => {} + _ => return Ok(None), + } + let Some(mut fs) = open(&mut v) else { + return Err(LimitHit::corrupt( + "ext open: the superblock or the root directory is unreadable".into(), + )); + }; + + // One more than the member limit, so the entry past it is counted and the + // walk stops there rather than ending short. + let limit = crate::bytes::to_usize(budget.limits().max_members).saturating_add(1); + let mut left = fs.blocks; + let mut damaged = false; + // A directory is walked once, whatever names it has. + let mut seen: HashSet<u32> = HashSet::from([ROOT]); + let mut stack = vec![(ROOT, String::new(), 0usize)]; + while let Some((dir, prefix, depth)) = stack.pop() { + let Some(ino) = fs.inode(&mut v, dir) else { + damaged = true; + continue; + }; + let (list, intact) = fs.entries(&mut v, &ino, &mut left, limit); + damaged |= !intact; + let mut below = Vec::new(); + for (name, n) in list { budget.count_entry()?; - let declared = meta.len(); - let cap = budget.reserve()?; - if declared > cap { - if let Some(r) = visit( - Entry::unsupported( - path, - declared, - false, - "ext file exceeds the per-member size budget", - ), - budget, - ) { - return Ok(Some(r)); - } + let path = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + let Some(child) = fs.inode(&mut v, n) else { + damaged = true; continue; - } - // Reading follows the inode's extent tree / block map, which is the - // whole point: a fragmented file comes back whole here and does not - // from a carve. - let bytes = match fs.read(path.as_str()) { - Ok(b) => b, - Err(e) => { - let _ = e; - if let Some(r) = visit( - Entry::unsupported( - path, - declared, - false, - "ext file could not be read from its block map", - ), - budget, - ) { + }; + match child.kind() { + S_IFDIR => { + if depth >= MAX_DEPTH { + damaged = true; + } else if seen.insert(n) { + below.push((n, path, depth.saturating_add(1))); + } + } + S_IFREG => { + let attrs = child.attrs(); + if let Some(r) = give(budget, &mut *visit, path, attrs, |cap| { + fs.file(&mut v, &child, cap) + })? { return Ok(Some(r)); } - continue; } - }; - budget.commit(bytes.len() as u64); - if let Some(r) = visit(Entry::new(path, bytes), budget) { - return Ok(Some(r)); + // A link or a node holds no file's bytes. + _ => {} } } + stack.extend(below.into_iter().rev()); + } + if damaged { + return Err(LimitHit::corrupt( + "ext walk: the volume is damaged; the files before the damage were scanned".into(), + )); } Ok(None) } + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::*; + use std::io::Cursor; + + fn fs(bs: u64) -> Fs { + Fs { + bs, + blocks: 1 << 20, + first_data: 0, + bpg: 8192, + ipg: 8192, + inodes: 1 << 20, + inode_size: 256, + desc_size: 64, + groups: 1, + meta_bg: false, + first_meta_bg: 0, + sparse: true, + sparse2: false, + backup: [0; 2], + large_dir: false, + descs: HashMap::new(), + } + } + + /// An extent node: the header, then `entries` as (first, len, physical) of + /// a leaf, or (first, 0, child) of an index when `height` is not 0. + fn node(height: u16, entries: &[(u32, u16, u32)]) -> Vec<u8> { + let mut n = vec![0u8; 12 + 12 * entries.len()]; + n[0..2].copy_from_slice(&0xF30Au16.to_le_bytes()); + n[2..4].copy_from_slice(&(entries.len() as u16).to_le_bytes()); + n[4..6].copy_from_slice(&(entries.len() as u16).to_le_bytes()); + n[6..8].copy_from_slice(&height.to_le_bytes()); + for (i, (first, len, phys)) in entries.iter().enumerate() { + let e = 12 + 12 * i; + n[e..e + 4].copy_from_slice(&first.to_le_bytes()); + if height == 0 { + n[e + 4..e + 6].copy_from_slice(&len.to_le_bytes()); + n[e + 8..e + 12].copy_from_slice(&phys.to_le_bytes()); + } else { + n[e + 4..e + 8].copy_from_slice(&phys.to_le_bytes()); + } + } + n + } + + fn runs(fs: &Fs, disk: Vec<u8>, node: &[u8], upto: u64) -> (Vec<(u64, u64, u64, bool)>, bool) { + let mut cur = Cursor::new(disk); + let len = cur.get_ref().len() as u64; + let mut v = Vol { + disk: &mut cur, + len, + max: 1 << 20, + }; + let mut m = Map { + fs, + upto, + runs: Vec::new(), + next: 0, + nodes: 0, + }; + let flow = m.extents(&mut v, node, None); + let runs = m + .runs + .iter() + .map(|r| (r.logical, r.phys, r.len, r.zero)) + .collect(); + (runs, !matches!(flow, Flow::Bad)) + } + + #[test] + fn the_extents_of_a_leaf_are_its_runs_and_a_preallocated_one_reads_as_zeros() { + let f = fs(1024); + let leaf = node(0, &[(0, 4, 100), (10, 32768 + 5, 200), (20, 0, 300)]); + let (r, ok) = runs(&f, vec![], &leaf, 100); + assert!(ok); + // A length of 0 is no extent. + assert_eq!(r, [(0, 100, 4, false), (10, 200, 5, true)]); + } + + #[test] + fn extents_are_followed_through_an_index_to_the_blocks_it_wants() { + let f = fs(1024); + let mut disk = vec![0u8; 8192]; + let a = node(0, &[(0, 2, 50), (2, 2, 60)]); + let b = node(0, &[(100, 3, 70)]); + disk[2048..2048 + a.len()].copy_from_slice(&a); + disk[3072..3072 + b.len()].copy_from_slice(&b); + let root = node(1, &[(0, 0, 2), (100, 0, 3)]); + let (r, ok) = runs(&f, disk.clone(), &root, 1000); + assert!(ok); + assert_eq!(r.len(), 3); + assert_eq!(r[2], (100, 70, 3, false)); + // Only the first node is read when nothing past block 50 is wanted. + let (r, ok) = runs(&f, disk, &root, 50); + assert!(ok); + assert_eq!(r.len(), 2); + } + + #[test] + fn extents_out_of_order_or_overlapping_are_not_a_map() { + let f = fs(1024); + let (_, ok) = runs(&f, vec![], &node(0, &[(10, 2, 1), (5, 2, 9)]), 100); + assert!(!ok, "out of order"); + let (r, ok) = runs(&f, vec![], &node(0, &[(0, 10, 1), (5, 2, 9)]), 100); + assert!(!ok && r.len() == 1, "overlapping"); + } + + #[test] + fn a_node_that_is_not_one_is_not_followed() { + let f = fs(1024); + let mut bad = node(0, &[(0, 1, 1)]); + bad[0] = 0; + assert!(!runs(&f, vec![], &bad, 10).1, "magic"); + let mut more = node(0, &[(0, 1, 1)]); + more[2..4].copy_from_slice(&5u16.to_le_bytes()); + assert!(!runs(&f, vec![], &more, 10).1, "more entries than room"); + assert!(!runs(&f, vec![], &node(6, &[(0, 0, 1)]), 10).1, "too tall"); + // An index that points at a node of the wrong height. + let mut disk = vec![0u8; 4096]; + let leaf = node(1, &[(0, 0, 1)]); + disk[2048..2048 + leaf.len()].copy_from_slice(&leaf); + assert!(!runs(&f, disk, &node(1, &[(0, 0, 2)]), 10).1, "height"); + // An index that points past the volume. + assert!(!runs(&f, vec![0; 1024], &node(1, &[(0, 0, 9)]), 10).1); + } + + #[test] + fn a_block_map_is_followed_through_its_three_levels() { + let f = fs(1024); + // 256 pointers to a block: direct blocks 0 to 11, then 12 to 267 by + // the first level, then 268 to 65803 by the second, then the third. + let mut disk = vec![0u8; 16 * 1024]; + let put = |disk: &mut Vec<u8>, block: usize, slot: usize, ptr: u32| { + disk[block * 1024 + slot * 4..][..4].copy_from_slice(&ptr.to_le_bytes()); + }; + put(&mut disk, 3, 0, 500); // indirect block 3: block 12 is at 500 + put(&mut disk, 3, 1, 501); + put(&mut disk, 5, 0, 4); // double indirect 5 -> indirect 4 + put(&mut disk, 4, 2, 900); // block 268 + 2 is at 900 + put(&mut disk, 8, 0, 7); // triple indirect 8 -> double 7 -> indirect 6 + put(&mut disk, 7, 0, 6); + put(&mut disk, 6, 0, 1200); // block 268 + 65536 is at 1200 + let mut i_block = [0u8; 60]; + for (slot, ptr) in [(0, 40), (1, 41), (3, 43), (12, 3), (13, 5), (14, 8)] { + i_block[slot * 4..][..4].copy_from_slice(&u32::to_le_bytes(ptr)); + } + let mut cur = Cursor::new(disk); + let mut v = Vol { + disk: &mut cur, + len: 16 * 1024, + max: 1 << 20, + }; + let mut m = Map { + fs: &f, + upto: 1 << 20, + runs: Vec::new(), + next: 0, + nodes: 0, + }; + assert!(!matches!(m.blocks(&mut v, &i_block), Flow::Bad)); + let got: Vec<_> = m.runs.iter().map(|r| (r.logical, r.phys, r.len)).collect(); + assert_eq!( + got, + [ + (0, 40, 2), + (3, 43, 1), + (12, 500, 2), + (270, 900, 1), + (65804, 1200, 1) + ] + ); + } + + #[test] + fn a_block_map_stops_at_the_blocks_wanted() { + let f = fs(1024); + let mut i_block = [0u8; 60]; + for slot in 0..12usize { + i_block[slot * 4..][..4].copy_from_slice(&(100 + slot as u32).to_le_bytes()); + } + let mut cur = Cursor::new(vec![0u8; 1024]); + let mut v = Vol { + disk: &mut cur, + len: 1024, + max: 1 << 20, + }; + let mut m = Map { + fs: &f, + upto: 5, + runs: Vec::new(), + next: 0, + nodes: 0, + }; + m.blocks(&mut v, &i_block); + assert_eq!(m.runs.len(), 1); + assert_eq!(m.runs[0].len, 5); + } + + fn entry(ino: u32, rec: u16, name: &str) -> Vec<u8> { + let mut e = vec![0u8; usize::from(rec)]; + e[0..4].copy_from_slice(&ino.to_le_bytes()); + e[4..6].copy_from_slice(&rec.to_le_bytes()); + e[6] = name.len() as u8; + e[8..8 + name.len()].copy_from_slice(name.as_bytes()); + e + } + + #[test] + fn the_entries_of_a_block_are_its_live_names() { + let block = [ + entry(2, 12, "."), + entry(2, 12, ".."), + entry(5, 12, "a"), + // Deleted: no inode. + entry(0, 16, "gone"), + entry(7, 1024 - 52, "b.txt"), + ] + .concat(); + let mut out = Vec::new(); + assert!(dirents(&block, 1024, &mut out, 100)); + assert_eq!(out, [("a".to_string(), 5), ("b.txt".to_string(), 7)]); + // As many as are wanted. + let mut out = Vec::new(); + assert!(dirents(&block, 1024, &mut out, 1)); + assert_eq!(out.len(), 1); + } + + #[test] + fn a_block_whose_entries_do_not_add_up_is_damaged() { + // An entry's header, and `total` bytes in all. + let raw = |rec: u16, name_len: u8, total: usize| { + let mut e = vec![0u8; total]; + e[0] = 5; + e[4..6].copy_from_slice(&rec.to_le_bytes()); + e[6] = name_len; + e + }; + for (what, bad) in [ + ("a length of 0 never moves on", raw(0, 1, 16)), + ("a length that is not a multiple of 4", raw(10, 1, 16)), + ("a length under the header", raw(4, 0, 16)), + ("a name longer than its entry", raw(12, 9, 12)), + ("an entry that runs past the block", raw(24, 1, 16)), + ("less than a header left", vec![0; 4]), + ] { + let mut out = Vec::new(); + assert!(!dirents(&bad, 1024, &mut out, 100), "{what}"); + } + } + + #[test] + fn the_length_of_an_entry_is_stored_as_the_block_size_says() { + assert_eq!(rec_len(12, 4096), 12); + assert_eq!(rec_len(0, 65536), 65536); + assert_eq!(rec_len(u16::MAX, 65536), 65536); + assert_eq!(rec_len(0x0104, 65536), 0x0104); + // The two low bits stand for bits 16 and 17. + assert_eq!(rec_len(0x0105, 65536), 0x1_0104); + } + + #[test] + fn the_superblock_copies_are_in_the_groups_that_have_them() { + let mut f = fs(4096); + let have = |f: &Fs| (0..60).filter(|&g| f.has_super(g)).collect::<Vec<_>>(); + assert_eq!(have(&f), [0, 1, 3, 5, 7, 9, 25, 27, 49]); + f.sparse = false; + assert_eq!(have(&f).len(), 60); + f.sparse = true; + f.sparse2 = true; + f.backup = [1, 40]; + assert_eq!(have(&f), [0, 1, 40]); + } + + #[test] + fn the_data_kept_in_the_inode_is_found_by_its_name() { + let mut raw = vec![0u8; 256]; + raw[128..130].copy_from_slice(&32u16.to_le_bytes()); + let at = 128 + 32; + raw[at..at + 4].copy_from_slice(&0xEA02_0000u32.to_le_bytes()); + // An entry of another name first, then "data" in index 7 (system). + let mut e = vec![0u8; 16 + 4]; + e[0] = 4; + e[1] = 7; + e[16..20].copy_from_slice(b"junk"); + e.extend_from_slice(&[0; 16]); + let d = e.len() - 16; + e[d] = 4; + e[d + 1] = 7; + e[d + 2..d + 4].copy_from_slice(&80u16.to_le_bytes()); + e[d + 8..d + 12].copy_from_slice(&5u32.to_le_bytes()); + e.extend_from_slice(b"data"); + raw[at + 4..at + 4 + e.len()].copy_from_slice(&e); + raw[at + 4 + 80..at + 4 + 85].copy_from_slice(b"hello"); + assert_eq!(system_data(&raw), Some(&b"hello"[..])); + // A value that runs past the inode is no value. + raw[at + 4 + d + 8..at + 4 + d + 12].copy_from_slice(&500u32.to_le_bytes()); + assert_eq!(system_data(&raw), None); + raw[at] = 0; + assert_eq!(system_data(&raw), None); + } +} diff --git a/crates/exav-unpack/src/formats/fat.rs b/crates/exav-unpack/src/formats/fat.rs index 3d9f7750..6050cb98 100644 --- a/crates/exav-unpack/src/formats/fat.rs +++ b/crates/exav-unpack/src/formats/fat.rs @@ -1,4 +1,4 @@ -//! FAT12/16/32 — walking the filesystem rather than carving the bytes. +//! FAT12, FAT16 and FAT32: the files of a volume, read where they lie. //! //! A disk image handed to a victim is usually mounted, and what they run is a //! *file* in the filesystem. exav reconstructs the guest disk of every image @@ -7,58 +7,60 @@ //! //! What that costs, concretely. A FAT image with a zipped payload written to a //! fresh filesystem is contiguous, and carving reaches it. Write two other files -//! first, delete the middle one, then write the payload — it lands in the hole -//! and continues after, in two runs — and carving finds only the first -//! fragment, which will not decompress. Reassembling that needs the cluster -//! chain, which means reading the filesystem. +//! first, delete the middle one, then write the payload: it lands in the hole +//! and continues after, in two runs, and carving finds only the first fragment, +//! which will not decompress. Reassembling that needs the cluster chain, which +//! means reading the filesystem. //! -//! Reading is delegated to the `fatfs` crate rather than hand-rolled: it carries -//! no `unsafe`, and its only dependencies are `bitflags` and `log`. +//! The volume is a `Read + Seek`, read where the walk goes: the boot sector, +//! the part of the allocation table a chain passes through, each directory's +//! clusters and each file's runs of clusters. Only a file is ever held, bounded +//! by the buffer limit. //! -//! The volume boot record is also what a partition table is *not* — see -//! `formats/partition.rs`, where mistaking one for the other invents a partition -//! spanning the whole image. +//! ```text +//! boot sector sector and cluster sizes, where the tables start +//! allocation table one entry per cluster: the next of its chain, or the end +//! root directory a fixed run of sectors (FAT12/16) or a chain (FAT32) +//! data clusters 32-byte entries in a directory: a name, a first +//! cluster and a size +//! ``` +//! +//! A long name is the UCS-2 text of the entries before its short one, taken +//! when their checksum is the short name's, as the systems that mount the +//! volume take it; otherwise the short name is the name. A deleted entry and +//! the volume label are not files. The volume boot record is also what a +//! partition table is *not*: see `formats/partition.rs`, where mistaking one for +//! the other invents a partition spanning the whole image. // Every sum and product on a header's number is checked or saturating here; a // plain one fails the build, so the next edit cannot add the unchecked kind. #![deny(clippy::arithmetic_side_effects)] -use std::io::Cursor; +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek}; -use crate::{Budget, Entry, LimitHit, Sink}; +use super::vol::{Attrs, Emit, Got, Next, Piece, Pieces, Plan, Vol, give, le16, le32, read_file}; +use crate::source::{ByteSource, Reader}; +use crate::stream::Visit; +use crate::{Budget, LimitHit, Mtime}; -/// Bound on the tree walk; hitting it is reported, never a quiet stop. -const MAX_ENTRIES: usize = 20_000; -const MAX_DEPTH: u32 = 32; +/// The most directories a path passes through. +const MAX_DEPTH: usize = 256; -/// A read-only view of the image for `fatfs`, whose storage trait requires -/// `Write`. Writing is refused rather than supported, which keeps this -/// zero-copy: wrapping a `Cursor<Vec<u8>>` instead would duplicate the whole -/// reconstructed disk in memory. -struct ReadOnly<'a> { - inner: Cursor<&'a [u8]>, -} +/// Bytes of the allocation table kept at a time. +const PAGE: u64 = 4096; -impl std::io::Read for ReadOnly<'_> { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { - self.inner.read(buf) - } -} +/// How many of those are kept. +const PAGES: usize = 64; -impl std::io::Write for ReadOnly<'_> { - fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> { - Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} +/// Attribute bits. +const ATTR_VOLUME: u8 = 0x08; +const ATTR_DIR: u8 = 0x10; +/// Read-only, hidden, system and volume label together mark a long name's part. +const ATTR_LONG: u8 = 0x0F; -impl std::io::Seek for ReadOnly<'_> { - fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> { - self.inner.seek(pos) - } -} +/// The characters of CP437, the code page short names are in, from 0x80 on. +const CP437: &str = "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿âŒ�¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗â•�╜╛â”�└┴┬├─┼╞╟╚╔╩╦╠â•�╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌â–�▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√â�¿Â²â– \u{a0}"; /// Does this look like a FAT volume boot record? /// @@ -96,140 +98,1068 @@ pub(crate) fn is_fat(data: &[u8]) -> bool { || matches!(ty32, Some(b"FAT32")) } -pub(crate) fn extract_fat<R>( - data: &[u8], - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - if !is_fat(data) { - return Ok(None); +/// Where a directory is. +#[derive(Clone, Copy)] +enum Dir { + /// The root of a FAT12/16 volume: a run of sectors before the clusters. + Root, + /// A chain of clusters, from this one. + Chain(u32), +} + +/// What an entry of the allocation table says. +enum Link { + Next(u32), + End, + /// Free, bad or out of range: not a chain. + Broken, +} + +/// A file or a directory in a directory. +struct Item { + name: String, + dir: bool, + first: u32, + size: u32, + mtime: Option<Mtime>, +} + +/// What the boot sector says of the volume. +struct Fat { + /// Bytes in a cluster. + cluster: u64, + /// 12, 16 or 32. + bits: u32, + /// Clusters of data, numbered from 2. + clusters: u32, + /// Byte offsets of the table in use, of the fixed root directory and of + /// cluster 2. + table: u64, + root: u64, + root_len: u64, + data: u64, + /// The first cluster of a FAT32 root directory. + root_cluster: u32, + /// Pages of the table, by number. + pages: HashMap<u64, Vec<u8>>, +} + +impl Fat { + /// The byte of the table at `off`. + fn table_byte(&mut self, v: &mut Vol, off: u64) -> Option<u8> { + let (page, at) = (off.checked_div(PAGE)?, off.checked_rem(PAGE)?); + if !self.pages.contains_key(&page) { + let start = page.checked_mul(PAGE)?; + let want = usize::try_from(PAGE).ok()?; + let got = v.at(self.table.checked_add(start)?, want).or_else(|| { + // The end of the volume may cut the last page short. + let left = v.len.checked_sub(self.table.checked_add(start)?)?; + v.at( + self.table.checked_add(start)?, + crate::bytes::to_usize(left.min(PAGE)), + ) + })?; + if self.pages.len() >= PAGES { + self.pages.clear(); + } + self.pages.insert(page, got); + } + self.pages + .get(&page)? + .get(crate::bytes::to_usize(at)) + .copied() } - let storage = ReadOnly { - inner: Cursor::new(data), - }; - let fs = match fatfs::FileSystem::new(storage, fatfs::FsOptions::new()) { - Ok(fs) => fs, - Err(e) => { - // The boot sector said FAT; failing to mount leaves every file in it - // unexamined, which must not pass quietly. - budget.count_entry()?; - return Ok(visit( - Entry::unsupported("<fat-volume>".to_string(), data.len() as u64, false, { - let _ = e; - "FAT filesystem could not be mounted" - }), - budget, - )); + + /// The `n` bytes of the table at `off`, as a little-endian number. + fn entry(&mut self, v: &mut Vol, off: u64, n: u64) -> Option<u32> { + let mut x = 0u32; + for i in (0..n).rev() { + let b = self.table_byte(v, off.checked_add(i)?)?; + x = x.checked_mul(256)?.checked_add(u32::from(b))?; } - }; + Some(x) + } - let mut seen = 0usize; - let mut stack = vec![(fs.root_dir(), String::new(), 0u32)]; - while let Some((dir, prefix, depth)) = stack.pop() { - if depth > MAX_DEPTH { - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - format!("<fat-below-depth-{MAX_DEPTH}>"), - 0, - false, - "FAT directory tree deeper than exav walks", - ), - budget, - ) { - return Ok(Some(r)); + /// What follows cluster `c` in its chain. + fn link(&mut self, v: &mut Vol, c: u32) -> Link { + if c < 2 || c > self.clusters.saturating_add(1) { + return Link::Broken; + } + let at = u64::from(c); + let raw = match self.bits { + // Two entries share three bytes. + 12 => self + .entry(v, at.saturating_add(at / 2), 2) + .map(|pair| if at % 2 == 1 { pair >> 4 } else { pair & 0xFFF }), + 16 => self.entry(v, at.saturating_mul(2), 2), + _ => self + .entry(v, at.saturating_mul(4), 4) + .map(|x| x & 0x0FFF_FFFF), + }; + let Some(raw) = raw else { + return Link::Broken; + }; + // The values from 0xFF8 (FAT12), 0xFFF8 and 0x0FFFFFF8 on end a chain. + let end = match self.bits { + 12 => 0xFF8, + 16 => 0xFFF8, + _ => 0x0FFF_FFF8, + }; + if raw >= end { + Link::End + } else if raw < 2 || raw > self.clusters.saturating_add(1) { + Link::Broken + } else { + Link::Next(raw) + } + } + + /// The byte offset of cluster `c`. + fn at(&self, c: u32) -> Option<u64> { + u64::from(c.checked_sub(2)?) + .checked_mul(self.cluster)? + .checked_add(self.data) + } + + /// The items of directory `dir`, up to `limit` of them. `left` counts the + /// clusters of directories the walk may still read. The flag is false + /// where the directory is damaged. + fn items(&mut self, v: &mut Vol, dir: Dir, left: &mut u64, limit: usize) -> (Vec<Item>, bool) { + let mut parse = Parser::default(); + let mut intact = true; + match dir { + Dir::Root => { + let bytes = crate::bytes::to_usize(self.root_len); + match v.at(self.root, bytes) { + Some(block) => { + parse.feed(&block, self.bits == 32, limit); + } + None => intact = false, + } } - continue; - } - for entry in dir.iter() { - let Ok(entry) = entry else { - // A directory whose entries stop parsing hides everything after - // them in that directory. - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - format!("<fat-dir:{prefix}>"), - 0, - false, - "unreadable FAT directory entry; the rest of that directory is unreachable", - ), - budget, - ) { - return Ok(Some(r)); + Dir::Chain(first) => { + let mut c = first; + let mut steps = 0u64; + 'chain: loop { + if *left == 0 || steps >= u64::from(self.clusters) { + intact = false; + break; + } + *left = left.saturating_sub(1); + steps = steps.saturating_add(1); + let block = self + .at(c) + .and_then(|o| v.at(o, crate::bytes::to_usize(self.cluster))); + let Some(block) = block else { + intact = false; + break; + }; + if parse.feed(&block, self.bits == 32, limit) { + break 'chain; + } + match self.link(v, c) { + Link::Next(next) => c = next, + Link::End => break, + Link::Broken => { + intact = false; + break; + } + } } + } + } + (parse.items, intact) + } + + /// The bytes of a file: held when they are at most `cap`, else read as + /// they go. + fn file<'a>(&'a mut self, v: &'a mut Vol<'_>, item: &Item, cap: u64) -> Got<'a> { + let size = u64::from(item.size); + if size == 0 { + return Got::Whole(Vec::new()); + } + let want = size.div_ceil(self.cluster); + let chain = Chain { + fat: self, + next: Some(item.first), + want, + got: 0, + broken: false, + }; + read_file(v, Pieces::new(chain, size), size, cap) + } +} + +/// The runs of clusters of a file's chain, found as they are read. +struct Chain<'a> { + fat: &'a mut Fat, + /// Where the next run starts, if the chain goes on. + next: Option<u32>, + /// The clusters of the file not yet in a run. + want: u64, + /// The clusters followed so far. + got: u64, + /// The chain breaks, loops or ends before the file does. + broken: bool, +} + +impl Plan for Chain<'_> { + fn next(&mut self, v: &mut Vol) -> Next { + if self.want == 0 { + return Next::End; + } + let Some(first) = self.next.filter(|_| !self.broken) else { + return Next::Broken; + }; + let mut c = first; + let mut n = 0u64; + loop { + // A chain longer than the volume has clusters comes back on itself. + if self.got >= u64::from(self.fat.clusters) { + self.broken = true; break; - }; - let name = entry.file_name(); - if name == "." || name == ".." { - continue; } - let path = if prefix.is_empty() { - name.clone() - } else { - format!("{prefix}/{name}") - }; - seen = seen.saturating_add(1); - if seen > MAX_ENTRIES { - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - format!("<fat-entries-beyond-{MAX_ENTRIES}>"), - 0, - false, - "too many FAT entries to walk them all", - ), - budget, - ) { - return Ok(Some(r)); + n = n.saturating_add(1); + self.got = self.got.saturating_add(1); + self.want = self.want.saturating_sub(1); + if self.want == 0 { + break; + } + match self.fat.link(v, c) { + Link::Next(next) if u64::from(next) == u64::from(c).saturating_add(1) => c = next, + Link::Next(next) => { + self.next = Some(next); + break; + } + Link::End | Link::Broken => { + self.broken = true; + break; } - return Ok(None); } - if entry.is_dir() { - stack.push((entry.to_dir(), path, depth.saturating_add(1))); + } + match self.fat.at(first) { + Some(at) if n > 0 => Next::Piece(Piece::Disk(at, n.saturating_mul(self.fat.cluster))), + _ => Next::Broken, + } + } +} + +/// Reads directory entries, across the clusters of a directory. +#[derive(Default)] +struct Parser { + items: Vec<Item>, + /// The long name being put together: its parts by position (1 from the + /// end), the checksum they carry and the count they say they are. + parts: Vec<Option<[u16; 13]>>, + sum: u8, +} + +impl Parser { + /// Read the entries of `block`. True where the directory ends in it, or + /// holds as many as `limit`. + fn feed(&mut self, block: &[u8], wide: bool, limit: usize) -> bool { + for e in block.as_chunks::<32>().0 { + if e[0] == 0 { + return true; + } + if self.items.len() >= limit { + return true; + } + if e[0] == 0xE5 { + self.parts.clear(); continue; } - - budget.count_entry()?; - let declared = entry.len(); - let cap = budget.reserve()?; - if declared > cap { - if let Some(r) = visit( - Entry::unsupported( - path, - declared, - false, - "FAT file exceeds the per-member size budget", - ), - budget, - ) { - return Ok(Some(r)); - } + let attr = e[11]; + if attr & 0x3F == ATTR_LONG { + self.part(e); + continue; + } + if attr & ATTR_VOLUME != 0 { + self.parts.clear(); + continue; + } + let name = self.name(e); + self.parts.clear(); + if name == "." || name == ".." { continue; } - let mut file = entry.to_file(); - let mut buf = Vec::with_capacity(declared as usize); - // Reading follows the cluster chain, so a fragmented file comes back - // whole here where a carve would return only its first extent. - if let Err(e) = std::io::Read::read_to_end(&mut file, &mut buf) { - let _ = e; - if let Some(r) = visit( - Entry::unsupported( - path, - declared, - false, - "FAT file could not be read from its cluster chain", - ), - budget, - ) { - return Ok(Some(r)); + let hi = if wide { u32::from(le16(e, 20)) } else { 0 }; + self.items.push(Item { + name, + dir: attr & ATTR_DIR != 0, + first: u32::from(le16(e, 26)) | hi.saturating_mul(1 << 16), + size: le32(e, 28), + mtime: dos_time(le16(e, 24), le16(e, 22)), + }); + } + false + } + + /// Take in an entry that is a part of a long name. + fn part(&mut self, e: &[u8]) { + let seq = usize::from(e[0] & 0x3F); + let mut chars = [0u16; 13]; + for (i, c) in chars.iter_mut().enumerate() { + let at = match i { + 0..=4 => 1usize.saturating_add(i.saturating_mul(2)), + 5..=10 => 14usize.saturating_add(i.saturating_sub(5).saturating_mul(2)), + _ => 28usize.saturating_add(i.saturating_sub(11).saturating_mul(2)), + }; + *c = le16(e, at); + } + // The first part met is the last of the name, marked, and 20 is the most. + if e[0] & 0x40 != 0 { + self.parts.clear(); + if seq == 0 || seq > 20 { + return; + } + self.parts.resize(seq, None); + self.sum = e[13]; + } else if self.parts.is_empty() || e[13] != self.sum { + self.parts.clear(); + return; + } + match seq.checked_sub(1).and_then(|i| self.parts.get_mut(i)) { + Some(slot) => *slot = Some(chars), + None => self.parts.clear(), + } + } + + /// The name of the short entry `e`: its long name where there is one that + /// is whole and is its own, else its short name. + fn name(&self, e: &[u8]) -> String { + if !self.parts.is_empty() + && self.parts.iter().all(Option::is_some) + && self.sum == short_sum(e) + { + let units: Vec<u16> = self + .parts + .iter() + .flatten() + .flatten() + .copied() + .take_while(|&u| u != 0) + .collect(); + return String::from_utf16_lossy(&units); + } + short_name(e) + } +} + +/// The checksum a long name carries of its short name. +fn short_sum(e: &[u8]) -> u8 { + e.iter().take(11).fold(0u8, |sum, &c| { + (sum >> 1).wrapping_add((sum & 1) << 7).wrapping_add(c) + }) +} + +/// The 8.3 name of a directory entry. +fn short_name(e: &[u8]) -> String { + // The case flags NT keeps of the base and of the extension. + let (lower_base, lower_ext) = (e[12] & 0x08 != 0, e[12] & 0x10 != 0); + let text = |b: &[u8], lower: bool| -> String { + let end = b + .iter() + .rposition(|&c| c != b' ') + .map_or(0, |i| i.saturating_add(1)); + b.iter() + .take(end) + .map(|&c| match c { + 0x80..=0xFF => CP437 + .chars() + .nth(usize::from(c).saturating_sub(0x80)) + .unwrap_or('?'), + _ if lower => char::from(c).to_ascii_lowercase(), + _ => char::from(c), + }) + .collect() + }; + let mut base = e[..8].to_vec(); + // A first byte of 0xE5 is stored as 0x05, 0xE5 marking a deleted entry. + if base[0] == 0x05 { + base[0] = 0xE5; + } + let (base, ext) = (text(&base, lower_base), text(&e[8..11], lower_ext)); + if ext.is_empty() { + base + } else { + format!("{base}.{ext}") + } +} + +/// A DOS date and time, local with no zone. +fn dos_time(date: u16, time: u16) -> Option<Mtime> { + let (day, month) = (date & 0x1F, (date >> 5) & 0x0F); + let (hour, minute, second) = ( + time >> 11, + (time >> 5) & 0x3F, + (time & 0x1F).saturating_mul(2), + ); + if date == 0 + || !(1..=12).contains(&month) + || day == 0 + || hour > 23 + || minute > 59 + || second > 59 + { + return None; + } + Some(Mtime::Local { + year: 1980u16.saturating_add(date >> 9), + month: u8::try_from(month).ok()?, + day: u8::try_from(day).ok()?, + hour: u8::try_from(hour).ok()?, + minute: u8::try_from(minute).ok()?, + second: u8::try_from(second).ok()?, + }) +} + +/// The volume's boot sector, or `None` where it describes none a system would +/// mount. +fn open(v: &mut Vol) -> Option<(Fat, Dir)> { + let b = v.at(0, 512)?; + let sector = u64::from(le16(&b, 11)); + let spc = u64::from(b[13]); + if !matches!(sector, 512 | 1024 | 2048 | 4096) || spc == 0 || !spc.is_power_of_two() { + return None; + } + let reserved = u64::from(le16(&b, 14)); + let fats = u64::from(b[16]); + let root_entries = u64::from(le16(&b, 17)); + let total = match le16(&b, 19) { + 0 => u64::from(le32(&b, 32)), + n => u64::from(n), + }; + let wide = le16(&b, 22) == 0; + let per_fat = if wide { + u64::from(le32(&b, 36)) + } else { + u64::from(le16(&b, 22)) + }; + if reserved == 0 || fats == 0 || per_fat == 0 { + return None; + } + // The root directory of FAT12/16 is a run of sectors, 32 bytes an entry. + let root_sectors = root_entries.saturating_mul(32).div_ceil(sector); + let first_data = reserved + .saturating_add(fats.saturating_mul(per_fat)) + .saturating_add(root_sectors); + // Nothing is read from past the volume. + let total = total.min(v.len.checked_div(sector)?); + let clusters = total + .checked_sub(first_data)? + .checked_div(spc) + .filter(|&n| n > 0)?; + // The number of clusters, not the label, says which FAT it is. + let bits = if wide { + 32 + } else if clusters < 4085 { + 12 + } else { + 16 + }; + // FAT32 can keep one copy of the table in use and not mirror it. + let active = if wide && le16(&b, 40) & 0x80 != 0 { + u64::from(le16(&b, 40) & 0x0F).min(fats.saturating_sub(1)) + } else { + 0 + }; + // The table holds an entry for every cluster, and two more. + let room = per_fat + .saturating_mul(sector) + .saturating_mul(8) + .checked_div(u64::from(bits))? + .saturating_sub(2); + let clusters = u32::try_from(clusters.min(room).min(0x0FFF_FFF5)).ok()?; + if clusters == 0 { + return None; + } + let table = reserved + .saturating_add(active.saturating_mul(per_fat)) + .saturating_mul(sector); + let fat = Fat { + cluster: sector.saturating_mul(spc), + bits, + clusters, + table, + root: reserved + .saturating_add(fats.saturating_mul(per_fat)) + .saturating_mul(sector), + root_len: root_sectors.saturating_mul(sector), + data: first_data.saturating_mul(sector), + root_cluster: le32(&b, 44), + pages: HashMap::new(), + }; + let root = if wide { + Dir::Chain(fat.root_cluster) + } else { + Dir::Root + }; + Some((fat, root)) +} + +/// Walk a FAT volume given whole as a source. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + super::volumes::entries(budget, visit, "", |budget, sink| { + walk_volume(Reader::new(src), src.len() as u64, budget, sink) + }) +} + +/// Walk the FAT volume `disk`, which is `len` bytes long, handing each file to +/// `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + len: u64, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + let max = crate::bytes::to_usize(budget.limits().max_buffer_bytes); + let mut v = Vol { + disk: &mut disk, + len, + max, + }; + match v.at(0, 512) { + Some(boot) if is_fat(&boot) => {} + _ => return Ok(None), + } + let Some((mut fat, root)) = open(&mut v) else { + return Err(LimitHit::corrupt( + "FAT open: the boot sector or the tables are unreadable".into(), + )); + }; + + // One more than the member limit, so the entry past it is counted and the + // walk stops there rather than ending short. + let limit = crate::bytes::to_usize(budget.limits().max_members).saturating_add(1); + let mut left = u64::from(fat.clusters); + let mut damaged = false; + // A directory is walked once, however many entries name it. + let mut seen: HashSet<u32> = HashSet::new(); + if let Dir::Chain(first) = root { + seen.insert(first); + } + let mut stack = vec![(root, String::new(), 0usize)]; + while let Some((dir, prefix, depth)) = stack.pop() { + let (items, intact) = fat.items(&mut v, dir, &mut left, limit); + damaged |= !intact; + let mut below = Vec::new(); + for item in items { + budget.count_entry()?; + let path = if prefix.is_empty() { + item.name.clone() + } else { + format!("{prefix}/{}", item.name) + }; + if item.dir { + if item.first < 2 || depth >= MAX_DEPTH { + damaged = true; + } else if seen.insert(item.first) { + below.push((Dir::Chain(item.first), path, depth.saturating_add(1))); } continue; } - budget.commit(buf.len() as u64); - if let Some(r) = visit(Entry::new(path, buf), budget) { + let attrs = Attrs { + mode: None, + mtime: item.mtime, + }; + if let Some(r) = give(budget, &mut *visit, path, attrs, |cap| { + fat.file(&mut v, &item, cap) + })? { return Ok(Some(r)); } } + stack.extend(below.into_iter().rev()); + } + if damaged { + return Err(LimitHit::corrupt( + "FAT walk: the volume is damaged; the files before the damage were scanned".into(), + )); } Ok(None) } + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::super::vol::collected; + use super::*; + use crate::Limits; + use std::io::Cursor; + + /// A directory entry. + fn entry(name: &[u8; 11], attr: u8, nt: u8, first: u16, size: u32) -> [u8; 32] { + let mut e = [0u8; 32]; + e[..11].copy_from_slice(name); + e[11] = attr; + e[12] = nt; + e[26..28].copy_from_slice(&first.to_le_bytes()); + e[28..32].copy_from_slice(&size.to_le_bytes()); + e + } + + /// The entries of a long name for the short entry with the name `short`, + /// last part first, as they are in a directory. + fn parts(name: &str, short: &[u8; 11]) -> Vec<[u8; 32]> { + let mut units: Vec<u16> = name.encode_utf16().collect(); + if !units.len().is_multiple_of(13) { + units.push(0); + } + while !units.len().is_multiple_of(13) { + units.push(0xFFFF); + } + let sum = short_sum(short); + let n = units.len() / 13; + let mut out = Vec::new(); + for (i, chunk) in units.chunks(13).enumerate().rev() { + let mut e = [0u8; 32]; + e[0] = (i + 1) as u8 | if i + 1 == n { 0x40 } else { 0 }; + e[11] = 0x0F; + e[13] = sum; + for (k, u) in chunk.iter().enumerate() { + let at = match k { + 0..=4 => 1 + 2 * k, + 5..=10 => 14 + 2 * (k - 5), + _ => 28 + 2 * (k - 11), + }; + e[at..at + 2].copy_from_slice(&u.to_le_bytes()); + } + out.push(e); + } + out + } + + fn names(block: &[[u8; 32]]) -> Vec<String> { + let mut p = Parser::default(); + p.feed(&block.concat(), false, 100); + p.items.into_iter().map(|i| i.name).collect() + } + + #[test] + fn a_short_name_has_its_case_flags_and_its_code_page() { + let n = |name: &[u8; 11], nt: u8| short_name(&entry(name, 0x20, nt, 2, 0)); + assert_eq!(n(b"README TXT", 0), "README.TXT"); + assert_eq!(n(b"README TXT", 0x18), "readme.txt"); + assert_eq!(n(b"README TXT", 0x08), "readme.TXT"); + assert_eq!(n(b"README TXT", 0x10), "README.txt"); + assert_eq!(n(b"MAKEFILE ", 0x08), "makefile"); + // A name that begins with 0xE5 is stored with 0x05. + assert_eq!(n(b"\x05AB DAT", 0), "\u{3c3}AB.DAT"); + // Bytes from 0x80 are CP437. + assert_eq!(n(b"\x80\x81\xB0 TXT", 0), "\u{c7}\u{fc}\u{2591}.TXT"); + } + + #[test] + fn a_long_name_is_taken_when_it_is_whole_and_its_checksum_is_the_short_names() { + let short = *b"LONGNA~1TXT"; + let e = entry(&short, 0x20, 0, 2, 1); + let long = "a long name.text"; + let mut block = parts(long, &short); + block.push(e); + assert_eq!(names(&block), [long]); + + // A name of exactly 13 characters has no end marker. + let mut block = parts("thirteen.char", &short); + block.push(e); + assert_eq!(names(&block), ["thirteen.char"]); + + // The checksum of another short name. + let mut other = parts(long, b"OTHER TXT"); + other.push(e); + assert_eq!(names(&other), ["LONGNA~1.TXT"]); + + // A part missing. + let mut cut = parts("a name that takes three parts of thirteen", &short); + cut.remove(1); + cut.push(e); + assert_eq!(names(&cut), ["LONGNA~1.TXT"]); + + // Parts that begin with no last part are not a name. + let mut orphan = parts("a name that takes two parts", &short); + orphan.remove(0); + orphan.push(e); + assert_eq!(names(&orphan), ["LONGNA~1.TXT"]); + + // A deleted entry between the parts and the short entry ends the name. + let mut gone = parts(long, &short); + gone.push(entry(b"\xE5ELETED TXT", 0x20, 0, 9, 1)); + gone.push(e); + assert_eq!(names(&gone), ["LONGNA~1.TXT"]); + + // The name of a short entry is not carried to the next. + let mut two = parts(long, &short); + two.push(e); + two.push(entry(b"NEXT TXT", 0x20, 0, 3, 1)); + assert_eq!(names(&two), [long, "NEXT.TXT"]); + } + + #[test] + fn a_long_name_is_unicode() { + let short = *b"CAF~1 TXT"; + let mut block = parts("caf\u{e9} \u{65e5}\u{672c} \u{1f600}.txt", &short); + block.push(entry(&short, 0x20, 0, 2, 1)); + assert_eq!(names(&block), ["caf\u{e9} \u{65e5}\u{672c} \u{1f600}.txt"]); + } + + #[test] + fn a_directory_is_read_to_its_end_marker() { + let block = [ + entry(b"VOLUME ", 0x08, 0, 0, 0), + entry(b". ", 0x10, 0, 2, 0), + entry(b".. ", 0x10, 0, 0, 0), + entry(b"A TXT", 0x20, 0, 3, 5), + entry(b"\xE5DELETE TXT", 0x20, 0, 4, 5), + entry(b"SUB ", 0x10, 0, 5, 0), + [0u8; 32], + entry(b"AFTER TXT", 0x20, 0, 6, 5), + ]; + let mut p = Parser::default(); + assert!(p.feed(&block.concat(), false, 100)); + let got: Vec<_> = p.items.iter().map(|i| (i.name.as_str(), i.dir)).collect(); + assert_eq!(got, [("A.TXT", false), ("SUB", true)]); + // The first cluster of FAT32 has its high half in another field. + let mut e = entry(b"BIG BIN", 0x20, 0, 0x1234, 1); + e[20..22].copy_from_slice(&0x0005u16.to_le_bytes()); + for (wide, first) in [(false, 0x1234), (true, 0x5_1234)] { + let mut p = Parser::default(); + p.feed(&e, wide, 100); + assert_eq!(p.items[0].first, first); + } + } + + #[test] + fn a_time_is_a_wall_clock_one_or_none() { + // 2024-03-05 14:30:12. + let date = ((2024 - 1980) << 9) | (3 << 5) | 5; + let time = (14 << 11) | (30 << 5) | 6; + assert_eq!( + dos_time(date, time), + Some(Mtime::Local { + year: 2024, + month: 3, + day: 5, + hour: 14, + minute: 30, + second: 12 + }) + ); + assert_eq!(dos_time(0, time), None); + assert_eq!(dos_time((2024 - 1980) << 9 | (13 << 5) | 5, time), None); + assert_eq!(dos_time(date & !0x1F, time), None); + assert_eq!(dos_time(date, 25 << 11), None); + } + + /// A volume of 512-byte sectors, one to a cluster, with the table of `bits` + /// and the clusters numbered from 2. + fn volume(bits: u32, fat: &[u16]) -> (Fat, Vec<u8>) { + let mut d = vec![0u8; 4096]; + for (c, &v) in fat.iter().enumerate() { + if bits == 16 { + d[c * 2..c * 2 + 2].copy_from_slice(&v.to_le_bytes()); + } else { + let at = c + c / 2; + let pair = u16::from_le_bytes([d[at], d[at + 1]]); + let pair = if c % 2 == 1 { + (pair & 0xF) | (v << 4) + } else { + (pair & 0xF000) | v + }; + d[at..at + 2].copy_from_slice(&pair.to_le_bytes()); + } + } + let f = Fat { + cluster: 512, + bits, + clusters: 100, + table: 0, + root: 0, + root_len: 0, + data: 0, + root_cluster: 0, + pages: HashMap::new(), + }; + (f, d) + } + + fn link(f: &mut Fat, d: Vec<u8>, c: u32) -> String { + let mut cur = Cursor::new(d); + let mut v = Vol { + disk: &mut cur, + len: 4096, + max: 1 << 20, + }; + match f.link(&mut v, c) { + Link::Next(n) => format!("next {n}"), + Link::End => "end".into(), + Link::Broken => "broken".into(), + } + } + + #[test] + fn the_entries_of_a_table_are_followed_in_each_width() { + // 12 bits: two entries in three bytes. + let t12 = [0xFF8, 0xFFF, 3, 0x05B, 0xFF8, 0xFFF, 0, 1, 200]; + for (c, want) in [ + (2, "next 3"), + (3, "next 91"), + (4, "end"), + (5, "end"), + // Free, not a chain. + (6, "broken"), + // Cluster 1 is none, and 200 is past the clusters there are. + (7, "broken"), + (8, "broken"), + (1, "broken"), + (101, "broken"), + ] { + let (mut f, d) = volume(12, &t12); + assert_eq!(link(&mut f, d, c), want, "FAT12 cluster {c}"); + } + let t16 = [0xFFF8, 0xFFFF, 3, 0xFFFF, 0, 0xFFF7, 0xFFF8]; + for (c, want) in [ + (2, "next 3"), + (3, "end"), + (4, "broken"), + (5, "broken"), + (6, "end"), + ] { + let (mut f, d) = volume(16, &t16); + assert_eq!(link(&mut f, d, c), want, "FAT16 cluster {c}"); + } + // 32 bits keep 28 of them. + let mut d = vec![0u8; 4096]; + d[8..12].copy_from_slice(&0xF000_0003u32.to_le_bytes()); + d[12..16].copy_from_slice(&0x0FFF_FFFFu32.to_le_bytes()); + let (mut f, _) = volume(32, &[]); + assert_eq!(link(&mut f, d.clone(), 2), "next 3"); + assert_eq!(link(&mut f, d, 3), "end"); + } + + /// A boot sector of a FAT16 volume of 4200 sectors. + fn boot() -> Vec<u8> { + let mut b = vec![0u8; 512]; + b[0] = 0xEB; + b[3..11].copy_from_slice(b"MSDOS5.0"); + b[11..13].copy_from_slice(&512u16.to_le_bytes()); + b[13] = 1; + b[14..16].copy_from_slice(&1u16.to_le_bytes()); + b[16] = 2; + b[17..19].copy_from_slice(&512u16.to_le_bytes()); + b[19..21].copy_from_slice(&4200u16.to_le_bytes()); + b[22..24].copy_from_slice(&17u16.to_le_bytes()); + b[54..62].copy_from_slice(b"FAT16 "); + b[510..512].copy_from_slice(&[0x55, 0xAA]); + b + } + + fn opened(boot: &[u8]) -> Option<(u32, u32)> { + let mut d = vec![0u8; 4200 * 512]; + d[..512].copy_from_slice(boot); + let len = d.len() as u64; + let mut cur = Cursor::new(d); + let mut v = Vol { + disk: &mut cur, + len, + max: 1 << 20, + }; + open(&mut v).map(|(f, _)| (f.bits, f.clusters)) + } + + #[test] + fn the_number_of_clusters_says_which_fat_it_is() { + // 4200 sectors less one reserved, 2 tables of 17 and 32 of root. + assert_eq!(opened(&boot()), Some((16, 4133))); + let mut small = boot(); + small[13] = 2; + assert_eq!(opened(&small), Some((12, 2066))); + // The table of a FAT32 volume is in another field. + let mut wide = boot(); + wide[17..19].fill(0); + wide[22..24].fill(0); + wide[36..40].copy_from_slice(&17u32.to_le_bytes()); + wide[44..48].copy_from_slice(&2u32.to_le_bytes()); + assert_eq!(opened(&wide).map(|x| x.0), Some(32)); + } + + #[test] + fn a_fat32_volume_that_does_not_mirror_its_tables_is_read_through_the_one_in_use() { + let table = |ext_flags: u16| { + let mut b = boot(); + b[17..19].fill(0); + b[22..24].fill(0); + b[36..40].copy_from_slice(&17u32.to_le_bytes()); + b[40..42].copy_from_slice(&ext_flags.to_le_bytes()); + b[44..48].copy_from_slice(&2u32.to_le_bytes()); + let mut d = vec![0u8; 4200 * 512]; + d[..512].copy_from_slice(&b); + let len = d.len() as u64; + let mut cur = Cursor::new(d); + let mut v = Vol { + disk: &mut cur, + len, + max: 1 << 20, + }; + open(&mut v).map(|(f, _)| f.table) + }; + // Mirrored: the first. Not mirrored, the second in use: the second. + assert_eq!(table(0), Some(512)); + assert_eq!(table(0x80), Some(512)); + assert_eq!(table(0x81), Some(512 + 17 * 512)); + // One that is not there is the last there is. + assert_eq!(table(0x85), Some(512 + 17 * 512)); + } + + #[test] + fn a_boot_sector_that_describes_no_volume_is_not_opened() { + // What is written, and where. + let edits: [(&str, usize, &[u8]); 8] = [ + ("sector size", 11, &500u16.to_le_bytes()), + ("cluster size 0", 13, &[0]), + ("cluster size 3", 13, &[3]), + ("no reserved sectors", 14, &[0, 0]), + ("no tables", 16, &[0]), + ("empty tables", 22, &[0, 0]), + ("tables past the volume", 22, &5000u16.to_le_bytes()), + ("no sectors", 19, &[0, 0]), + ]; + for (what, at, bytes) in edits { + let mut b = boot(); + b[at..at + bytes.len()].copy_from_slice(bytes); + assert_eq!(opened(&b), None, "{what}"); + } + } + + /// A FAT16 volume holding `files` in its root, each in clusters in order. + fn image(files: &[(&[u8; 11], &[u8])]) -> Vec<u8> { + let mut d = vec![0u8; 4200 * 512]; + d[..512].copy_from_slice(&boot()); + // The first table at 512, the root at 35 * 512, cluster 2 at 67 * 512. + let (fat, root, data) = (512, 35 * 512, 67 * 512); + d[fat..fat + 4].copy_from_slice(&[0xF8, 0xFF, 0xFF, 0xFF]); + let mut next = 2usize; + for (i, (name, bytes)) in files.iter().enumerate() { + let n = bytes.len().div_ceil(512).max(1); + for c in next..next + n { + let v: u16 = if c + 1 == next + n { + 0xFFFF + } else { + c as u16 + 1 + }; + d[fat + c * 2..fat + c * 2 + 2].copy_from_slice(&v.to_le_bytes()); + } + let at = data + (next - 2) * 512; + d[at..at + bytes.len()].copy_from_slice(bytes); + let e = entry(name, 0x20, 0, next as u16, bytes.len() as u32); + d[root + i * 32..root + i * 32 + 32].copy_from_slice(&e); + next += n; + } + d + } + + /// A member as the tests see it: its name, its length and what it reports. + type Member = (String, usize, Option<&'static str>); + + fn walked(d: &[u8], limits: Limits) -> (Vec<Member>, Result<(), LimitHit>) { + let mut out = Vec::new(); + let mut budget = Budget::new(limits); + let len = d.len() as u64; + let end = walk_volume(Cursor::new(d), len, &mut budget, &mut |e, _| { + let e = e.whole(); + out.push((e.name, e.data.len(), e.unsupported)); + None::<()> + }); + (out, end.map(|_| ())) + } + + #[test] + fn the_files_of_a_root_come_out_with_what_their_chains_hold() { + let big = vec![7u8; 1300]; + let d = image(&[(b"A TXT", b"hello"), (b"BIG BIN", &big)]); + let (e, end) = walked(&d, Limits::default()); + end.unwrap(); + assert_eq!( + e, + [ + ("A.TXT".to_string(), 5, None), + ("BIG.BIN".to_string(), 1300, None) + ] + ); + } + + #[test] + fn a_chain_that_runs_in_a_circle_is_read_as_far_as_the_size_asks_and_no_further() { + let mut d = image(&[(b"LOOP BIN", &vec![1u8; 1024])]); + // Make the file 5 clusters long by its size: cluster 3 leads back to 2. + let root = 35 * 512; + d[root + 28..root + 32].copy_from_slice(&2560u32.to_le_bytes()); + d[512 + 6..512 + 8].copy_from_slice(&2u16.to_le_bytes()); + let (e, end) = walked(&d, Limits::default()); + end.unwrap(); + assert_eq!(e, [("LOOP.BIN".to_string(), 2560, None)]); + } + + #[test] + fn a_chain_that_ends_or_breaks_early_is_a_file_cut_short() { + // Cluster 2 ends the chain, or leads to cluster 1, which is none. + for next in [0xFFFFu16, 0xFFF7, 1] { + let mut d = image(&[(b"CUT BIN", &vec![1u8; 1024])]); + d[512 + 4..512 + 6].copy_from_slice(&next.to_le_bytes()); + let (e, end) = walked(&d, Limits::default()); + end.unwrap(); + assert_eq!( + e, + [( + "CUT.BIN".to_string(), + 512, + Some("file could not be read from the volume") + )], + "{next:#x}" + ); + } + } + + #[test] + fn the_entry_past_the_member_limit_is_counted_and_stops_the_walk() { + let d = image(&[ + (b"A TXT", b"a"), + (b"B TXT", b"b"), + (b"C TXT", b"c"), + ]); + let run = |max: u64| { + let limits = Limits { + max_members: max, + ..Limits::default() + }; + walked(&d, limits) + }; + let (e, end) = run(3); + end.unwrap(); + assert_eq!(e.len(), 3); + let (e, end) = run(2); + assert!(!end.unwrap_err().is_corrupt()); + assert_eq!(e.len(), 2); + } + + #[test] + fn a_file_over_the_buffer_limit_is_read_as_it_goes_and_the_walk_goes_on() { + let d = image(&[(b"BIG BIN", &vec![1u8; 4000]), (b"SMALL TXT", b"x")]); + let limits = Limits { + max_buffer_bytes: 1000, + ..Limits::default() + }; + let mut budget = Budget::new(limits); + let len = d.len() as u64; + let (e, end) = collected(|emit| walk_volume(Cursor::new(&d[..]), len, &mut budget, emit)); + end.unwrap(); + assert_eq!(e[0].0.name, "BIG.BIN"); + assert_eq!(e[0].0.data, vec![1u8; 4000]); + assert!(e[0].1, "a file past the limit is not held"); + assert_eq!(e[0].0.unsupported, None); + assert_eq!((e[1].0.name.as_str(), e[1].1), ("SMALL.TXT", false)); + } +} diff --git a/crates/exav-unpack/src/formats/gzip.rs b/crates/exav-unpack/src/formats/gzip.rs index e6667a61..9bea6ee6 100644 --- a/crates/exav-unpack/src/formats/gzip.rs +++ b/crates/exav-unpack/src/formats/gzip.rs @@ -3,7 +3,7 @@ use std::io::{BufReader, Read}; use crate::source::{ByteSource, Reader}; -use crate::stream::{stream_single, Visit}; +use crate::stream::{Visit, stream_single}; use crate::{Budget, LimitHit}; pub(crate) fn walk<T>( diff --git a/crates/exav-unpack/src/formats/hfsplus.rs b/crates/exav-unpack/src/formats/hfsplus.rs new file mode 100644 index 00000000..bb0abe53 --- /dev/null +++ b/crates/exav-unpack/src/formats/hfsplus.rs @@ -0,0 +1,1327 @@ +//! HFS+ and HFSX: the files of a volume, read where they lie. +//! +//! The volume is a `Read + Seek`, read where the walk goes: the volume header, +//! the catalog's nodes, and each file's extents. Only a file is ever held, +//! bounded by the buffer limit. +//! +//! A scanner wants every file's bytes, not a navigable tree, so the catalog +//! B-tree is read once through its leaves, which are chained in key order, and +//! the paths are rebuilt from the folder records. Nothing is searched by name: +//! the case folding that HFS+ lookups need is not here, and an HFSX volume +//! reads the same as an HFS+ one. What is searched, by binary keys, is the +//! extents overflow tree (the extents of a fragmented file past its eighth) +//! and the attributes tree (the `com.apple.decmpfs` attribute of a compressed +//! file, read by `formats/decmpfs`). +//! +//! ```text +//! volume header (1024 bytes in) block size, the five special forks +//! catalog file B-tree: a folder record or a file record +//! per (parent id, name); a file record holds +//! the data fork and the resource fork +//! extents overflow file B-tree: the extents past a fork's eighth +//! attributes file B-tree: extended attributes +//! ``` +//! +//! All fields are big-endian. Implemented from Apple's TN1150. +//! +//! A file's resource fork is not content here unless it carries a compressed +//! file's bytes: any other is reported, and the data fork is read all the +//! same. A symbolic link is not read. A journal is not replayed. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] + +use std::cmp::Ordering; +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; + +use super::decmpfs; +use super::vol::{ + Attrs, Emit, Found, Got, List, Piece, Pieces, Vol, be16, be32, be64, hand, read_file, +}; +use crate::{Budget, Entry, LimitHit}; + +/// The catalog id of the root folder, whose children the paths start from. +const ROOT: u32 = 2; + +/// Deeper than any real tree: a node's height counts down to a leaf's 1. +const MAX_HEIGHT: u8 = 16; + +/// The most folders a path passes through. +const MAX_PATH_DEPTH: usize = 256; + +/// The most lookups in the extents overflow tree for the extents of one fork: +/// each gives up to eight, so a fork in this many fragments is not followed +/// to its end. +const MAX_OVERFLOW_LOOKUPS: u32 = 1 << 16; + +/// The most an attribute read from its fork is, bytes. +const ATTR_MAX: u64 = 1 << 16; + +/// A fork type in an extents overflow key. +const DATA: u8 = 0x00; +const RESOURCE: u8 = 0xFF; + +/// `S_IFMT` and `S_IFLNK` of a file's mode. +const S_IFMT: u16 = 0o170000; +const S_IFLNK: u16 = 0o120000; + +/// A run of allocation blocks. +#[derive(Clone, Copy, Default)] +struct Extent { + start: u32, + count: u32, +} + +/// A fork's size and its first eight extents, as a catalog record or the +/// volume header holds them. +#[derive(Clone, Default)] +struct Fork { + size: u64, + extents: [Extent; 8], +} + +impl Fork { + /// The fork data (80 bytes) at `at`. + fn parse(d: &[u8], at: usize) -> Fork { + let mut fork = Fork { + size: be64(d, at), + ..Fork::default() + }; + for (i, e) in fork.extents.iter_mut().enumerate() { + let o = at.saturating_add(16).saturating_add(i.saturating_mul(8)); + *e = Extent { + start: be32(d, o), + count: be32(d, o.saturating_add(4)), + }; + } + fork + } +} + +/// The blocks of a fork in order, which map a position in it to the volume. +struct Map { + block: u64, + /// (the fork's block this extent starts at, the extent) + list: Vec<(u64, Extent)>, + blocks: u64, +} + +impl Map { + fn new(block: u64) -> Self { + Map { + block, + list: Vec::new(), + blocks: 0, + } + } + + fn push(&mut self, e: Extent) { + if e.count > 0 { + self.list.push((self.blocks, e)); + self.blocks = self.blocks.saturating_add(u64::from(e.count)); + } + } + + /// The bytes the extents hold. + fn bytes(&self) -> u64 { + self.blocks.saturating_mul(self.block) + } + + /// Append `len` bytes of the fork from `off` to `out`. False when the + /// extents do not hold them or the volume does not have them; what was + /// appended before that stays. + fn read(&self, v: &mut Vol, off: u64, len: usize, out: &mut Vec<u8>) -> bool { + let Some(end) = off.checked_add(len as u64) else { + return false; + }; + if end > self.bytes() { + return false; + } + let mut pos = off; + while pos < end { + let at = self + .list + .partition_point(|(first, _)| *first <= pos.checked_div(self.block).unwrap_or(0)); + let Some((first, e)) = at.checked_sub(1).and_then(|i| self.list.get(i)) else { + return false; + }; + let into = pos.saturating_sub(first.saturating_mul(self.block)); + let room = u64::from(e.count) + .saturating_mul(self.block) + .saturating_sub(into); + let take = end.saturating_sub(pos).min(room); + let Some(phys) = u64::from(e.start) + .saturating_mul(self.block) + .checked_add(into) + else { + return false; + }; + if take == 0 || !v.append(phys, crate::bytes::to_usize(take), out) { + return false; + } + pos = pos.saturating_add(take); + } + true + } +} + +/// A node of a B-tree: its descriptor and its records. +struct Node { + data: Vec<u8>, + kind: u8, + height: u8, + next: u32, + count: usize, +} + +const LEAF: u8 = 0xFF; +const INDEX: u8 = 0x00; + +impl Node { + fn parse(data: Vec<u8>) -> Option<Node> { + let count = usize::from(be16(&data, 10)); + // The offset table, one more entry than there are records, ends the node. + let table = count.checked_add(1)?.checked_mul(2)?; + if data.len() < table.checked_add(14)? { + return None; + } + Some(Node { + kind: *data.get(8)?, + height: *data.get(9)?, + next: be32(&data, 0), + count, + data, + }) + } + + /// Record `i`, which lies between its offset and the next record's. + fn rec(&self, i: usize) -> Option<&[u8]> { + if i >= self.count { + return None; + } + let size = self.data.len(); + let offset = |k: usize| { + let at = size.checked_sub(k.checked_add(1)?.checked_mul(2)?)?; + Some(usize::from(be16(&self.data, at))) + }; + let (start, end) = (offset(i)?, offset(i.checked_add(1)?)?); + let table = size.checked_sub(self.count.checked_add(1)?.checked_mul(2)?)?; + if start < 14 || start > end || end > table { + return None; + } + self.data.get(start..end) + } +} + +/// The offset of what follows a record's key: its `u16` length, and the +/// key, padded to an even length. +fn after_key(rec: &[u8]) -> usize { + usize::from(be16(rec, 0)).saturating_add(3) & !1 +} + +/// A B-tree file: the catalog, the extents overflow file or the attributes +/// file. +struct Tree { + map: Map, + node_size: usize, + root: u32, + height: u8, + first_leaf: u32, +} + +impl Tree { + /// The tree in the fork `map` describes, from its header node. + fn open(v: &mut Vol, map: Map) -> Option<Tree> { + let mut head = Vec::new(); + if !map.read(v, 0, 64, &mut head) || head.get(8) != Some(&1) { + return None; + } + let node_size = usize::from(be16(&head, 32)); + if !(512..=32768).contains(&node_size) || !node_size.is_power_of_two() { + return None; + } + let height = u8::try_from(be16(&head, 14)).ok()?; + (height <= MAX_HEIGHT).then_some(Tree { + node_size, + root: be32(&head, 16), + height, + first_leaf: be32(&head, 24), + map, + }) + } + + fn node(&self, v: &mut Vol, n: u32) -> Option<Node> { + let size = self.node_size as u64; + let mut data = Vec::new(); + self.map + .read( + v, + u64::from(n).checked_mul(size)?, + self.node_size, + &mut data, + ) + .then_some(())?; + Node::parse(data) + } + + /// The record `cmp` finds equal, where `cmp` orders a record by its key + /// against the one wanted. The descent takes, in an index node, the last + /// record whose key is not past the wanted one. + fn find(&self, v: &mut Vol, cmp: &dyn Fn(&[u8]) -> Ordering) -> Option<Vec<u8>> { + let (mut n, mut height) = (self.root, self.height); + while n != 0 { + let node = self.node(v, n)?; + if node.height != height { + return None; + } + let recs = (0..node.count).map(|i| node.rec(i)); + if node.kind == LEAF && height == 1 { + for rec in recs { + match cmp(rec?) { + Ordering::Equal => return rec.map(<[u8]>::to_vec), + Ordering::Greater => return None, + Ordering::Less => {} + } + } + return None; + } + if node.kind != INDEX || height < 2 { + return None; + } + let mut child = None; + for rec in recs { + let rec = rec?; + if cmp(rec) == Ordering::Greater { + break; + } + child = Some(be32(rec, after_key(rec))); + } + n = child?; + height = height.saturating_sub(1); + } + None + } + + /// Every record of the leaves, in order, until `each` returns false. False + /// where the tree is damaged: a leaf that is not one, one that cannot be + /// read, a chain longer than the file holds nodes. + fn scan(&self, v: &mut Vol, mut each: impl FnMut(&[u8]) -> bool) -> bool { + let limit = self.map.bytes().checked_div(self.node_size as u64); + let (mut n, mut seen) = (self.first_leaf, 0u64); + // A chain that loops back on itself is as long as the volume's size + // allows (billions of nodes), so it is looked for: the node taken at + // each power of two of the steps, and met again, is a loop (Brent's). + let (mut mark, mut power, mut since) = (0u32, 1u64, 0u64); + while n != 0 { + seen = seen.saturating_add(1); + if Some(seen) > limit || n == mark { + return false; + } + since = since.saturating_add(1); + if since == power { + (mark, power, since) = (n, power.saturating_mul(2), 0); + } + let Some(node) = self.node(v, n) else { + return false; + }; + if node.kind != LEAF { + return false; + } + for i in 0..node.count { + let Some(rec) = node.rec(i) else { + return false; + }; + if !each(rec) { + return true; + } + } + n = node.next; + } + true + } +} + +/// A file of the catalog. +struct File { + id: u32, + parent: u32, + name: String, + /// Marked as compressed (decmpfs), as macOS marks one. + compressed: bool, + data: Fork, + resource: Fork, +} + +/// What the catalog holds: the folders by id, and the files in key order. +#[derive(Default)] +struct Catalog { + folders: HashMap<u32, (u32, String)>, + files: Vec<File>, +} + +impl Catalog { + /// Keep the folder or file record `rec` (a key and its body), unless it + /// is another kind of record or a symbolic link. + fn add(&mut self, rec: &[u8]) { + // The key: its length, the parent's id, the name's length and the + // name in UTF-16, all inside the key's length. + let name_len = usize::from(be16(rec, 6)); + let name_bytes = name_len.saturating_mul(2); + let in_key = usize::from(be16(rec, 0)) >= name_bytes.saturating_add(6); + let Some(units) = + crate::bytes::at(rec, 8, name_bytes).filter(|_| in_key && name_len <= 255) + else { + return; + }; + let units: Vec<u16> = units + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_be_bytes(*c)) + .collect(); + let name = String::from_utf16_lossy(&units); + let parent = be32(rec, 2); + let Some(body) = rec.get(after_key(rec)..) else { + return; + }; + match be16(body, 0) { + 1 if body.len() >= 88 => { + self.folders.insert(be32(body, 8), (parent, name)); + } + 2 if body.len() >= 248 && be16(body, 42) & S_IFMT != S_IFLNK => { + let owner_flags = u32::from(body.get(41).copied().unwrap_or(0)); + self.files.push(File { + id: be32(body, 8), + parent, + name, + compressed: owner_flags & decmpfs::COMPRESSED_FLAG != 0, + data: Fork::parse(body, 88), + resource: Fork::parse(body, 168), + }); + } + _ => {} + } + } + + /// The path of `name` in folder `parent`, from the root. A folder whose + /// chain does not reach the root (a record missing, or a loop) stands + /// where the chain broke, by its id. + fn path(&self, parent: u32, name: &str) -> String { + let mut parts = vec![name]; + let mut at = parent; + for _ in 0..MAX_PATH_DEPTH { + if at == ROOT { + break; + } + let Some((up, folder)) = self.folders.get(&at) else { + break; + }; + parts.push(folder); + at = *up; + } + let lost = (at != ROOT).then(|| format!("<hfs-folder-{at}>")); + parts.extend(lost.as_deref()); + parts.reverse(); + format!("/{}", parts.join("/")) + } +} + +/// The volume's trees and geometry. +struct Hfs { + block: u64, + extents: Tree, + attributes: Option<Tree>, +} + +/// The extents overflow key `rec` against (`id`, `fork`, `start`): file id +/// first, then fork type, then the fork's block the extents start at. +fn cmp_extent_key(rec: &[u8], id: u32, fork: u8, start: u32) -> Ordering { + be32(rec, 4) + .cmp(&id) + .then(rec.get(2).copied().unwrap_or(0).cmp(&fork)) + .then(be32(rec, 8).cmp(&start)) +} + +/// The attributes key `rec` against (`id`, `name`): file id, then the name by +/// 16-bit code units, then the first block (0 for a value held whole). +fn cmp_attr_key(rec: &[u8], id: u32, name: &[u16]) -> Ordering { + let len = usize::from(be16(rec, 12)); + let mine = (0..len).map(|i| be16(rec, 14usize.saturating_add(i.saturating_mul(2)))); + be32(rec, 4) + .cmp(&id) + .then_with(|| mine.cmp(name.iter().copied())) + .then(be32(rec, 8).cmp(&0)) +} + +impl Hfs { + /// The blocks of a fork: its eight extents and, past them, the extents + /// the overflow tree has for (`id`, `fork`). + fn map(&self, v: &mut Vol, fork: &Fork, id: u32, ty: u8) -> Map { + let mut map = Map::new(self.block); + for e in fork.extents { + map.push(e); + } + let need = fork.size.div_ceil(self.block); + for _ in 0..MAX_OVERFLOW_LOOKUPS { + if map.blocks >= need { + break; + } + let Ok(start) = u32::try_from(map.blocks) else { + break; + }; + let found = self + .extents + .find(v, &|rec| cmp_extent_key(rec, id, ty, start)); + // The key is 10 bytes, then eight extents. + let Some(rec) = found.filter(|r| r.len() >= 76) else { + break; + }; + let before = map.blocks; + for i in 0..8usize { + let o = 12usize.saturating_add(i.saturating_mul(8)); + map.push(Extent { + start: be32(&rec, o), + count: be32(&rec, o.saturating_add(4)), + }); + } + if map.blocks == before { + break; + } + } + map + } + + /// The whole of a fork, or `None` when it is longer than `bound` or its + /// extents do not give it. + fn read_fork(&self, v: &mut Vol, fork: &Fork, id: u32, ty: u8, bound: u64) -> Option<Vec<u8>> { + if fork.size > bound { + return None; + } + let mut out = Vec::new(); + let len = crate::bytes::to_usize(fork.size); + self.map(v, fork, id, ty) + .read(v, 0, len, &mut out) + .then_some(out) + } + + /// The `com.apple.decmpfs` attribute of file `id`. + fn decmpfs_attr(&self, v: &mut Vol, id: u32) -> Option<Vec<u8>> { + let tree = self.attributes.as_ref()?; + let name: Vec<u16> = decmpfs::ATTR.encode_utf16().collect(); + let rec = tree.find(v, &|rec| cmp_attr_key(rec, id, &name))?; + let value = rec.get(after_key(&rec)..)?; + match be32(value, 0) { + // recordType, two reserved words, the size, the bytes + 0x10 => { + let len = usize::try_from(be32(value, 12)).ok()?; + crate::bytes::at(value, 16, len).map(<[u8]>::to_vec) + } + // recordType, a reserved word, the fork data + 0x20 => { + let fork = Fork::parse(value, 8); + // Its eight extents only: an attribute this long is not one. + let mut map = Map::new(self.block); + for e in fork.extents { + map.push(e); + } + let mut out = Vec::new(); + let len = crate::bytes::to_usize(fork.size.min(ATTR_MAX)); + (fork.size <= ATTR_MAX && map.read(v, 0, len, &mut out)).then_some(out) + } + _ => None, + } + } +} + +impl Hfs { + /// The bytes of `f`: held when they are at most `cap`, else read as they + /// go. A compressed file is unpacked, and is not read as it goes. + fn file<'a>(&self, v: &'a mut Vol<'_>, f: &File, cap: u64) -> Got<'a> { + if f.compressed { + if let Some(attr) = self.decmpfs_attr(v, f.id) { + let kind = decmpfs::header(&attr).map(|h| h.kind); + let fork = kind + .filter(|&k| decmpfs::in_fork(k)) + // A fork holds the bytes in blocks, a little over their number. + .and_then(|_| { + let bound = cap.saturating_add(cap / 32).saturating_add(4096); + self.read_fork(v, &f.resource, f.id, RESOURCE, bound) + }); + return decmpfs::unpack(&attr, fork.as_deref(), cap); + } + if f.data.size == 0 { + return Got::Why("compressed file (decmpfs) without its attribute"); + } + } + let map = self.map(v, &f.data, f.id, DATA); + let block = self.block; + let plan = List { + pieces: map + .list + .iter() + .map(|(_, e)| { + Piece::Disk( + u64::from(e.start).saturating_mul(block), + u64::from(e.count).saturating_mul(block), + ) + }) + .collect::<Vec<_>>() + .into_iter(), + // The extents hold what the fork says it does. + intact: map.bytes() >= f.data.size, + }; + read_file(v, Pieces::new(plan, f.data.size), f.data.size, cap) + } +} + +/// Whether `head`, the start of a volume, holds the header of an HFS+ or an +/// HFSX volume (version 4 and 5). +pub(crate) fn is_hfsplus(head: &[u8]) -> bool { + matches!( + (be16(head, 1024), be16(head, 1026)), + (0x482B, 4) | (0x4858, 5) + ) +} + +/// Walk the HFS+ or HFSX volume `disk`, which starts at its boot area, +/// handing each file to `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + let len = disk + .seek(SeekFrom::End(0)) + .map_err(|e| LimitHit::corrupt(format!("HFS+ volume: {e}")))?; + let max = crate::bytes::to_usize(budget.limits().max_buffer_bytes); + let mut v = Vol { + disk: &mut disk, + len, + max, + }; + let Some((hfs, catalog)) = open(&mut v) else { + return Err(LimitHit::corrupt( + "hfs+ open: the volume header or a tree is unreadable".into(), + )); + }; + + // The catalog, kept up to the member limit; a volume with more is walked + // as far as that and then stops as any container does at the limit. + let limit = crate::bytes::to_usize(budget.limits().max_members); + let mut cat = Catalog::default(); + let mut capped = false; + let intact = catalog.scan(&mut v, |rec| { + if cat.files.len().saturating_add(cat.folders.len()) >= limit { + capped = true; + return false; + } + cat.add(rec); + true + }); + + for f in &cat.files { + let path = cat.path(f.parent, &f.name); + budget.count_entry()?; + let cap = budget.reserve()?; + let got = hfs.file(&mut v, f, cap); + // A compressed file's resource fork is its content, not a fork to report. + if f.resource.size > 0 && !f.compressed { + let note = Entry::unsupported(path.clone(), 0, false, "HFS+ resource fork not read"); + if let Some(r) = visit(Found::Held(note), budget) { + return Ok(Some(r)); + } + // A file that is only a resource fork has nothing else to give. + if f.data.size == 0 { + continue; + } + } + if let Some(r) = hand(budget, &mut *visit, path, Attrs::default(), got) { + return Ok(Some(r)); + } + } + if capped { + return Err(LimitHit::of_kind( + crate::LimitKind::MaxFiles, + format!("file count > {limit}"), + )); + } + if !intact { + return Err(LimitHit::corrupt( + "hfs+ walk: catalog damaged; the files before the damage were scanned".into(), + )); + } + Ok(None) +} + +/// The volume's header and trees. +fn open(v: &mut Vol) -> Option<(Hfs, Tree)> { + let h = v.at(1024, 512)?; + let (sig, version) = (be16(&h, 0), be16(&h, 2)); + // "H+" is version 4, "HX" (case-sensitive) version 5. + if !matches!((sig, version), (0x482B, 4) | (0x4858, 5)) { + return None; + } + let block = be32(&h, 40); + if block < 512 || !block.is_power_of_two() || block > 1 << 30 { + return None; + } + let block = u64::from(block); + // Nothing is read from past the volume the header describes. + v.len = v.len.min(u64::from(be32(&h, 44)).saturating_mul(block)); + + let inline = |at: usize| { + let fork = Fork::parse(&h, at); + let mut map = Map::new(block); + for e in fork.extents { + map.push(e); + } + (fork, map) + }; + // The extents file has no overflow of its own: its extents are the eight. + let (_, map) = inline(192); + let mut hfs = Hfs { + block, + extents: Tree::open(v, map)?, + attributes: None, + }; + let catalog = Fork::parse(&h, 272); + let map = hfs.map(v, &catalog, 4, DATA); + let catalog = Tree::open(v, map)?; + let attributes = Fork::parse(&h, 352); + if attributes.size > 0 { + let map = hfs.map(v, &attributes, 8, DATA); + hfs.attributes = Tree::open(v, map); + } + Some((hfs, catalog)) +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::super::vol::collected; + use super::*; + use crate::Limits; + use std::io::{Cursor, Write}; + + /// The allocation block and the node size of the volumes built here. + const B: usize = 512; + + fn be16(v: u16) -> [u8; 2] { + v.to_be_bytes() + } + + fn be32(v: u32) -> [u8; 4] { + v.to_be_bytes() + } + + /// The fork data (80 bytes) of `size` bytes in `(start, count)` extents. + fn fork(size: u64, extents: &[(u32, u32)]) -> Vec<u8> { + let mut f = size.to_be_bytes().to_vec(); + f.extend_from_slice(&[0; 8]); + for i in 0..8 { + let (s, c) = extents.get(i).copied().unwrap_or((0, 0)); + f.extend_from_slice(&be32(s)); + f.extend_from_slice(&be32(c)); + } + f + } + + /// A catalog key: the parent's id, the name. + fn key(parent: u32, name: &str) -> Vec<u8> { + let units: Vec<u16> = name.encode_utf16().collect(); + let mut k = be16((6 + 2 * units.len()) as u16).to_vec(); + k.extend_from_slice(&be32(parent)); + k.extend_from_slice(&be16(units.len() as u16)); + k.extend(units.iter().flat_map(|u| u.to_be_bytes())); + k + } + + fn folder(parent: u32, name: &str, id: u32) -> Vec<u8> { + let mut r = key(parent, name); + let mut body = vec![0u8; 88]; + body[..2].copy_from_slice(&be16(1)); + body[8..12].copy_from_slice(&be32(id)); + r.extend(body); + r + } + + /// A file record: `mode`, the owner flags, the data fork and the resource + /// fork (each 80 bytes). + fn file( + parent: u32, + name: &str, + id: u32, + mode: u16, + flags: u8, + data: &[u8], + rsrc: &[u8], + ) -> Vec<u8> { + let mut r = key(parent, name); + let mut body = vec![0u8; 248]; + body[..2].copy_from_slice(&be16(2)); + body[8..12].copy_from_slice(&be32(id)); + body[41] = flags; + body[42..44].copy_from_slice(&be16(mode)); + body[88..168].copy_from_slice(data); + body[168..248].copy_from_slice(rsrc); + r.extend(body); + r + } + + /// The size of the nodes: a file record fills a node of the smallest size. + const N: usize = 4096; + + /// A node holding `recs`. + fn node(kind: u8, height: u8, next: u32, recs: &[Vec<u8>]) -> Vec<u8> { + let mut n = vec![0u8; N]; + n[..4].copy_from_slice(&be32(next)); + n[8] = kind; + n[9] = height; + n[10..12].copy_from_slice(&be16(recs.len() as u16)); + let mut at = 14; + let mut offsets = Vec::new(); + for r in recs { + n[at..at + r.len()].copy_from_slice(r); + offsets.push(at); + at += r.len(); + } + offsets.push(at); + for (i, o) in offsets.iter().enumerate() { + n[N - 2 * (i + 1)..N - 2 * i].copy_from_slice(&be16(*o as u16)); + } + n + } + + fn leaf(next: u32, recs: &[Vec<u8>]) -> Vec<u8> { + node(0xFF, 1, next, recs) + } + + /// The file of a tree: its header node, then `nodes`. + fn tree(depth: u16, root: u32, first_leaf: u32, nodes: &[Vec<u8>]) -> Vec<u8> { + let mut h = node(1, 0, 0, &[]); + h[14..16].copy_from_slice(&be16(depth)); + h[16..20].copy_from_slice(&be32(root)); + h[24..28].copy_from_slice(&be32(first_leaf)); + h[32..34].copy_from_slice(&be16(N as u16)); + h[36..40].copy_from_slice(&be32(nodes.len() as u32 + 1)); + let mut t = h; + for n in nodes { + t.extend_from_slice(n); + } + t + } + + /// A volume of 512-byte blocks being laid out: the boot area, the header's + /// block, then what is added. + struct Img { + d: Vec<u8>, + } + + impl Img { + fn new() -> Img { + Img { d: vec![0; 3 * B] } + } + + /// `bytes` in blocks of their own: (the first block, how many). + fn add(&mut self, bytes: &[u8]) -> (u32, u32) { + let start = (self.d.len() / B) as u32; + self.d.extend_from_slice(bytes); + self.d.resize(self.d.len().div_ceil(B) * B, 0); + (start, (self.d.len() / B) as u32 - start) + } + + /// A fork holding `bytes`, in one extent. + fn data(&mut self, bytes: &[u8]) -> Vec<u8> { + let (s, c) = self.add(bytes); + fork(bytes.len() as u64, &[(s, c)]) + } + + /// The volume: the three trees (each the file `tree` made) and the + /// header over them. + fn finish(mut self, catalog: &[u8], extents: &[u8], attributes: &[u8]) -> Vec<u8> { + let mut forks = Vec::new(); + // Every volume has an extents file, a tree with no records at least. + let empty = tree(0, 0, 0, &[]); + let extents = if extents.is_empty() { &empty } else { extents }; + for t in [extents, catalog, attributes] { + let f = match t.is_empty() { + true => fork(0, &[]), + false => { + let (s, c) = self.add(t); + fork(t.len() as u64, &[(s, c)]) + } + }; + forks.push(f); + } + let total = (self.d.len() / B) as u32; + let h = &mut self.d[1024..1536]; + h[..2].copy_from_slice(b"H+"); + h[2..4].copy_from_slice(&be16(4)); + h[40..44].copy_from_slice(&be32(B as u32)); + h[44..48].copy_from_slice(&be32(total)); + h[192..272].copy_from_slice(&forks[0]); + h[272..352].copy_from_slice(&forks[1]); + h[352..432].copy_from_slice(&forks[2]); + self.d + } + } + + fn run(img: &[u8], limits: Limits) -> (Vec<Entry>, Result<(), LimitHit>) { + let mut budget = Budget::new(limits); + let mut out = Vec::new(); + let end = walk_volume(Cursor::new(img.to_vec()), &mut budget, &mut |e, _| { + out.push(e.whole()); + None::<()> + }); + (out, end.map(|_| ())) + } + + fn named(entries: &[Entry]) -> Vec<(&str, &[u8])> { + entries.iter().map(|e| (&e.name[..], &e.data[..])).collect() + } + + const FILE: u16 = 0o100644; + const LINK: u16 = 0o120777; + + /// A volume of folders and files in two chained leaves of the catalog. + fn tree_of_folders() -> Vec<u8> { + let mut img = Img::new(); + let (a, b, c) = ( + img.data(b"in root"), + img.data(b"in dir"), + img.data(b"in sub"), + ); + let none = fork(0, &[]); + let first = [ + folder(1, "Vol", 2), + file(2, "a.txt", 20, FILE, 0, &a, &none), + folder(2, "dir", 16), + file(2, "link", 23, LINK, 0, &none, &none), + ]; + let second = [ + file(16, "b.txt", 21, FILE, 0, &b, &none), + folder(16, "sub", 17), + file(17, "c.txt", 22, FILE, 0, &c, &none), + ]; + let cat = tree(1, 1, 1, &[leaf(2, &first), leaf(0, &second)]); + img.finish(&cat, &tree(0, 0, 0, &[]), &[]) + } + + #[test] + fn the_files_of_a_tree_of_folders_come_out_under_their_paths() { + let (entries, end) = run(&tree_of_folders(), Limits::default()); + assert!(end.is_ok()); + // The symbolic link is not read; the leaves are walked in their chain. + assert_eq!( + named(&entries), + [ + ("/a.txt", &b"in root"[..]), + ("/dir/b.txt", b"in dir"), + ("/dir/sub/c.txt", b"in sub"), + ] + ); + } + + #[test] + fn a_file_in_a_folder_the_catalog_lacks_is_named_by_the_folder_it_lost() { + let mut img = Img::new(); + let data = img.data(b"x"); + let none = fork(0, &[]); + let recs = [ + folder(1, "Vol", 2), + // Folders 50 and 51 are each other's parent: a loop. + folder(51, "f50", 50), + folder(50, "f51", 51), + file(99, "orphan", 30, FILE, 0, &data, &none), + file(50, "looped", 31, FILE, 0, &data, &none), + ]; + let cat = tree(1, 1, 1, &[leaf(0, &recs)]); + let (entries, end) = run(&img.finish(&cat, &[], &[]), Limits::default()); + assert!(end.is_ok()); + let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names[0], "/<hfs-folder-99>/orphan"); + // The loop ends where the path depth does, and says where it broke. + assert!(names[1].starts_with("/<hfs-folder-5"), "{}", names[1]); + assert!(names[1].ends_with("/looped")); + } + + /// The extents past a fork's eighth are in the extents overflow tree, found + /// through its index. + #[test] + fn a_fragmented_file_is_put_together_from_the_overflow_tree() { + let mut img = Img::new(); + // Twelve blocks of one file, each between two blocks of another. + let pieces: Vec<Vec<u8>> = (0..12u8).map(|i| vec![b'a' + i; B]).collect(); + let mut at = Vec::new(); + for p in &pieces { + at.push(img.add(p)); + img.add(&[0xee; B]); + } + let want: Vec<u8> = pieces.concat(); + let inline: Vec<(u32, u32)> = at[..8].to_vec(); + let data = fork(want.len() as u64, &inline); + let none = fork(0, &[]); + + let ext_rec = |id: u32, start: u32, exts: &[(u32, u32)]| { + let mut r = be16(10).to_vec(); + r.extend_from_slice(&[0, 0]); + r.extend_from_slice(&be32(id)); + r.extend_from_slice(&be32(start)); + r.extend_from_slice(&fork(0, exts)[16..]); + r + }; + // Records for other files before and after, in two leaves under an index. + let other = |id| ext_rec(id, 0, &[(1, 1)]); + let ours = ext_rec(30, 8, &at[8..]); + let first = leaf(2, &[other(5), other(29)]); + let second = leaf(0, &[ours.clone(), other(31)]); + let index = node( + 0, + 2, + 0, + &[ + [&other(5)[..12], &be32(1)].concat(), + [&ours[..12], &be32(2)].concat(), + ], + ); + let extents = tree(2, 3, 1, &[first, second, index]); + + let cat = tree( + 1, + 1, + 1, + &[leaf( + 0, + &[folder(1, "V", 2), file(2, "f", 30, FILE, 0, &data, &none)], + )], + ); + let (entries, end) = run(&img.finish(&cat, &extents, &[]), Limits::default()); + assert!(end.is_ok()); + assert_eq!(named(&entries), [("/f", &want[..])]); + } + + /// An attribute record: file id, name, inline value. + fn attr(id: u32, name: &str, value: &[u8]) -> Vec<u8> { + let units: Vec<u16> = name.encode_utf16().collect(); + let mut r = be16((12 + 2 * units.len()) as u16).to_vec(); + r.extend_from_slice(&[0, 0]); + r.extend_from_slice(&be32(id)); + r.extend_from_slice(&be32(0)); + r.extend_from_slice(&be16(units.len() as u16)); + r.extend(units.iter().flat_map(|u| u.to_be_bytes())); + if r.len() % 2 == 1 { + r.push(0); + } + r.extend_from_slice(&be32(0x10)); + r.extend_from_slice(&[0; 8]); + r.extend_from_slice(&be32(value.len() as u32)); + r.extend_from_slice(value); + r + } + + fn deflated(data: &[u8]) -> Vec<u8> { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(data).unwrap(); + z.finish().unwrap() + } + + fn decmpfs_attr(kind: u32, size: usize, payload: &[u8]) -> Vec<u8> { + let mut a = b"fpmc".to_vec(); + a.extend_from_slice(&kind.to_le_bytes()); + a.extend_from_slice(&(size as u64).to_le_bytes()); + a.extend_from_slice(payload); + a + } + + /// Files macOS compressed have no data fork. Their bytes are in the + /// `com.apple.decmpfs` attribute, found by its file id and name among + /// the others in the attributes tree, or in the resource fork. + #[test] + fn compressed_files_are_unpacked_from_their_attribute_or_resource_fork() { + let mut img = Img::new(); + let text = b"compressed text, compressed text, compressed text".to_vec(); + let none = fork(0, &[]); + + // Type 4: one zlib block, the table 4 bytes into the fork. + let z = deflated(&text); + let mut rsrc = vec![0, 0, 0, 0, 1, 0, 0, 0, 12, 0, 0, 0]; + rsrc.extend_from_slice(&(z.len() as u32).to_le_bytes()); + rsrc.extend_from_slice(&z); + let rsrc = img.data(&rsrc); + + const PACKED: u8 = 0x20; + let recs = [ + folder(1, "V", 2), + file(2, "inline", 40, FILE, PACKED, &none, &none), + file(2, "forked", 41, FILE, PACKED, &none, &rsrc), + // Not marked: its attribute is not looked for. + file(2, "plain", 42, FILE, 0, &img.data(b"plain"), &none), + ]; + let cat = tree(1, 1, 1, &[leaf(0, &recs)]); + let inline = decmpfs_attr(3, text.len(), &deflated(&text)); + let forked = decmpfs_attr(4, text.len(), &[]); + let other = decmpfs_attr(3, 5, &deflated(b"wrong")); + let first = leaf( + 2, + &[ + attr(39, "com.apple.decmpfs", &other), + attr(40, "com.apple.FinderInfo", b"zz"), + ], + ); + let second = leaf( + 0, + &[ + attr(40, "com.apple.decmpfs", &inline), + attr(41, "com.apple.decmpfs", &forked), + attr(42, "com.apple.decmpfs", &other), + ], + ); + let key_of = |r: &Vec<u8>| r[..2 + usize::from(u16::from_be_bytes([r[0], r[1]]))].to_vec(); + let index = node( + 0, + 2, + 0, + &[ + [ + key_of(&attr(39, "com.apple.decmpfs", &[])), + be32(1).to_vec(), + ] + .concat(), + [ + key_of(&attr(40, "com.apple.decmpfs", &[])), + be32(2).to_vec(), + ] + .concat(), + ], + ); + let attrs = tree(2, 3, 1, &[first, second, index]); + let (entries, end) = run( + &img.finish(&cat, &tree(0, 0, 0, &[]), &attrs), + Limits::default(), + ); + assert!(end.is_ok()); + // The resource fork of a compressed file is its content, not a fork to report. + assert_eq!( + named(&entries), + [ + ("/inline", &text[..]), + ("/forked", &text[..]), + ("/plain", b"plain") + ] + ); + assert!(entries.iter().all(|e| e.unsupported.is_none())); + } + + #[test] + fn a_compressed_file_whose_attribute_is_missing_is_reported_not_empty() { + let img = Img::new(); + let none = fork(0, &[]); + let recs = [ + folder(1, "V", 2), + file(2, "gone", 40, FILE, 0x20, &none, &none), + ]; + let cat = tree(1, 1, 1, &[leaf(0, &recs)]); + let (entries, _) = run(&img.finish(&cat, &[], &[]), Limits::default()); + assert_eq!(entries.len(), 1); + assert!( + entries[0] + .unsupported + .unwrap() + .contains("without its attribute") + ); + } + + #[test] + fn a_resource_fork_is_reported_and_the_data_fork_is_still_read() { + let mut img = Img::new(); + let data = img.data(b"data fork"); + let rsrc = fork(5, &[]); + let only = fork(0, &[]); + let recs = [ + folder(1, "V", 2), + file(2, "both", 30, FILE, 0, &data, &rsrc), + file(2, "fork-only", 31, FILE, 0, &only, &rsrc), + ]; + let cat = tree(1, 1, 1, &[leaf(0, &recs)]); + let (entries, _) = run(&img.finish(&cat, &[], &[]), Limits::default()); + let got: Vec<_> = entries + .iter() + .map(|e| (&e.name[..], e.unsupported, e.data.len())) + .collect(); + let note = Some("HFS+ resource fork not read"); + assert_eq!( + got, + [ + ("/both", note, 0), + ("/both", None, 9), + ("/fork-only", note, 0) + ] + ); + } + + #[test] + fn a_file_the_volume_holds_only_part_of_is_reported_with_what_there_is() { + let mut img = Img::new(); + let (s, _) = img.add(&[7; B]); + // 1000 bytes declared, one block mapped. + let data = fork(1000, &[(s, 1)]); + let recs = [ + folder(1, "V", 2), + file(2, "short", 30, FILE, 0, &data, &fork(0, &[])), + ]; + let cat = tree(1, 1, 1, &[leaf(0, &recs)]); + let (entries, end) = run(&img.finish(&cat, &[], &[]), Limits::default()); + assert!(end.is_ok()); + assert_eq!( + entries[0].unsupported, + Some("file could not be read from the volume") + ); + assert_eq!(entries[0].data, vec![7; B]); + } + + #[test] + fn files_past_the_buffer_limit_are_read_as_they_go_across_their_extents() { + let mut img = Img::new(); + let (first, _) = img.add(&[1; B]); + let (second, _) = img.add(&[2; B]); + // The second block of the file is before the first on the volume. + let data = fork(2 * B as u64, &[(second, 1), (first, 1)]); + let small = img.data(b"x"); + let none = fork(0, &[]); + let recs = [ + folder(1, "V", 2), + file(2, "big", 30, FILE, 0, &data, &none), + file(2, "small", 31, FILE, 0, &small, &none), + ]; + let cat = tree(1, 1, 1, &[leaf(0, &recs)]); + let vol = img.finish(&cat, &[], &[]); + let mut budget = Budget::new(Limits { + max_buffer_bytes: 100, + ..Limits::default() + }); + let (e, end) = collected(|emit| walk_volume(Cursor::new(vol.clone()), &mut budget, emit)); + end.unwrap(); + let (big, was) = e.iter().find(|(m, _)| m.name == "/big").unwrap(); + assert!(*was, "past the limit, so not held"); + assert_eq!(big.data, [vec![2u8; B], vec![1u8; B]].concat()); + let (small, was) = e.iter().find(|(m, _)| m.name == "/small").unwrap(); + assert!(!*was); + assert_eq!(small.data, b"x"); + } + + #[test] + fn a_catalog_larger_than_the_member_limit_is_walked_to_it_and_then_stops() { + let limits = Limits { + max_members: 3, + ..Limits::default() + }; + let (entries, end) = run(&tree_of_folders(), limits); + // The folder of the volume, "a.txt" and "dir" are the three records + // kept; what is kept is scanned. + assert_eq!(named(&entries), [("/a.txt", &b"in root"[..])]); + let hit = end.expect_err("the member limit"); + assert_eq!(hit.kind, crate::LimitKind::MaxFiles); + } + + #[test] + fn a_damaged_leaf_ends_the_walk_after_the_files_before_it() { + let mut img = tree_of_folders(); + // The catalog is the second tree written: find its second leaf by the + // names it holds, and make it an index node. + let at = img + .windows(12) + .position(|w| w == [0, 0x10, 0, 0, 0, 0x10, 0, 5, 0, b'b', 0, b'.']) + .unwrap(); + img[at - 14 + 8] = 0; + let (entries, end) = run(&img, Limits::default()); + assert_eq!(named(&entries), [("/a.txt", &b"in root"[..])]); + assert!(end.expect_err("damage").is_corrupt()); + } + + #[test] + fn nothing_is_a_volume_but_a_header_with_a_block_size_and_trees() { + let good = tree_of_folders(); + for (at, byte) in [(1024, b'X'), (1027, 9), (1064, 3)] { + let mut bad = good.clone(); + bad[at] = byte; + let (entries, end) = run(&bad, Limits::default()); + assert!(entries.is_empty(), "byte {at}"); + assert!(end.expect_err("not a volume").is_corrupt(), "byte {at}"); + } + assert!(run(&good[..1200], Limits::default()).1.is_err()); + } + + /// Whatever a volume says, the walk ends: every byte of the header and + /// the trees changed, in turn, to each of a few values. + #[test] + fn damaged_volumes_end_the_walk_without_a_panic() { + let good = tree_of_folders(); + for at in (1024..good.len()).step_by(3) { + for byte in [0x00, 0x01, 0x7f, 0xff] { + let mut bad = good.clone(); + bad[at] = byte; + let _ = run(&bad, Limits::default()); + } + } + } + + /// A disk of `image` and then zeros up to `len`: a virtual disk of a size + /// its header declares and nothing behind. + struct Padded { + image: Vec<u8>, + len: u64, + at: u64, + } + + impl Read for Padded { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + let n = (buf.len() as u64).min(self.len.saturating_sub(self.at)) as usize; + for (i, b) in buf[..n].iter_mut().enumerate() { + *b = self.image.get(self.at as usize + i).copied().unwrap_or(0); + } + self.at += n as u64; + Ok(n) + } + } + + impl std::io::Seek for Padded { + fn seek(&mut self, to: SeekFrom) -> std::io::Result<u64> { + self.at = match to { + SeekFrom::Start(n) => n, + SeekFrom::End(d) => self.len.saturating_add_signed(d), + SeekFrom::Current(d) => self.at.saturating_add_signed(d), + }; + Ok(self.at) + } + } + + /// Two leaves that are each other's next, in a catalog whose fork says it + /// is 32 GiB (a fuzzer's 19 KB disk image of this took 21 s under the + /// sanitizer, and a declared 2^62 bytes would not end): the chain is + /// bounded by the fork, 8 million nodes here, and each is a valid leaf with + /// no record the catalog keeps, so no member limit ends it. + #[test] + fn a_leaf_chain_that_loops_is_damage_not_a_walk_to_the_end_of_the_fork() { + let t = tree(1, 1, 1, &[leaf(2, &[]), leaf(1, &[])]); + let mut img = Img::new().finish(&t, &[], &[]); + // The catalog's one extent is 2^26 blocks of 512 bytes, from its start. + let fork_at = 1024 + 272; + let extent_count = fork_at + 16 + 4; + img[extent_count..extent_count + 4].copy_from_slice(&be32(1 << 26)); + img[fork_at..fork_at + 8].copy_from_slice(&(512u64 << 26).to_be_bytes()); + img[1024 + 44..1024 + 48].copy_from_slice(&be32(u32::MAX)); + let disk = Padded { + image: img, + len: 1 << 40, + at: 0, + }; + let mut budget = Budget::new(Limits::default()); + let started = std::time::Instant::now(); + let end = walk_volume(disk, &mut budget, &mut |_, _| None::<()>); + assert!(started.elapsed().as_secs() < 2, "{:?}", started.elapsed()); + assert!(end.expect_err("a loop").is_corrupt()); + } +} diff --git a/crates/exav-unpack/src/formats/ishield_z.rs b/crates/exav-unpack/src/formats/ishield_z.rs index 900b108f..14c3b3db 100644 --- a/crates/exav-unpack/src/formats/ishield_z.rs +++ b/crates/exav-unpack/src/formats/ishield_z.rs @@ -7,32 +7,104 @@ //! //! Members are compressed with the **PKWARE Data Compression Library** codec //! ("implode"), which is not the same as ZIP's method 8 deflate and not the -//! same as ZIP method 6 either. Decoding is delegated to `unshield` (MIT), whose -//! only real dependency is `explode` (MIT), a leaf DCL decoder. +//! same as ZIP method 6 either; [`super::dcl_explode`] decodes it. +//! +//! The table of contents is read as `unshield` 0.2.0 (Aaron Griffith, MIT; +//! attributed in `NOTICE`) reads it, with two differences: every member is +//! listed in table order, where `unshield` keys them by path and keeps only +//! the last of two that share one, and a name that is not UTF-8 is read +//! lossily instead of failing the whole archive. //! //! Names come out as `path/name` and are used only for reporting and `.cdb` //! matching — nothing here resolves one against a filesystem. -use std::io::Cursor; - +use crate::bytes::at; use crate::{Budget, Entry, LimitHit, Sink}; /// Bound on the member walk; hitting it is reported, never a quiet stop. const MAX_ENTRIES: usize = 20_000; +/// Where the first member's data begins, the end of the header. +const FILE_OFFSET: usize = 255; + pub(crate) fn is_ishield_z(data: &[u8]) -> bool { super::sniff::is(data, crate::Format::IshieldZ) } -/// A member's reported name. `FileInfo::path` is already the full path; only -/// the separator differs — InstallShield wrote backslashes — so it is -/// normalised to read like every other container's. -/// -/// The archive is addressed by the raw `path` string, never by this. +/// A member's reported name. `path` is already the full path; only the +/// separator differs — InstallShield wrote backslashes — so it is normalised +/// to read like every other container's. fn display_name(path: &str) -> String { path.replace('\\', "/").trim_matches('/').to_string() } +struct Member { + path: String, + /// Where its compressed bytes start: members follow each other from + /// [`FILE_OFFSET`] in table order. + offset: usize, + /// Its COMPRESSED size. + size: usize, +} + +enum Toc { + Members(Vec<Member>), + /// More members declared than [`MAX_ENTRIES`]. + TooMany, +} + +/// The table of contents. `None` when it cannot be read. +/// +/// The directory records come first, each with its member count, then the +/// member records, directory by directory. A record's `chunk` is its length: +/// a zero one makes every record read the same bytes again, so the member +/// count is summed and bounded before any member record is read. +fn read_toc(data: &[u8]) -> Option<Toc> { + let u16at = |d: &[u8], o: usize| -> Option<usize> { + Some(u16::from_le_bytes(at(d, o, 2)?.try_into().ok()?) as usize) + }; + let u32at = |d: &[u8], o: usize| -> Option<usize> { + let v = u32::from_le_bytes(at(d, o, 4)?.try_into().ok()?); + Some(crate::bytes::to_usize(v as u64)) + }; + let string = |d: &[u8], o: usize, len: usize| -> Option<String> { + Some(String::from_utf8_lossy(at(d, o, len)?).into_owned()) + }; + let toc = data.get(u32at(data, 41)?..u32at(data, 18)?)?; + let mut next = 0usize; + let mut dirs = Vec::new(); + let mut declared = 0usize; + for _ in 0..u16at(data, 49)? { + let files = u16at(toc, next)?; + let chunk = u16at(toc, next.checked_add(2)?)?; + let name_len = u16at(toc, next.checked_add(4)?)?; + dirs.push((files, string(toc, next.checked_add(6)?, name_len)?)); + declared += files; + next = next.checked_add(chunk)?; + } + if declared > MAX_ENTRIES { + return Some(Toc::TooMany); + } + let mut members = Vec::with_capacity(declared); + let mut offset = FILE_OFFSET; + for (files, dir) in dirs { + for _ in 0..files { + let size = u32at(toc, next.checked_add(7)?)?; + let chunk = u16at(toc, next.checked_add(23)?)?; + let name_len = *toc.get(next.checked_add(29)?)? as usize; + let name = string(toc, next.checked_add(30)?, name_len)?; + let path = match dir.is_empty() { + true => name, + false => format!("{dir}\\{name}"), + }; + members.push(Member { path, offset, size }); + next = next.checked_add(chunk)?; + offset = offset.checked_add(size)?; + } + } + Some(Toc::Members(members)) +} + pub(crate) fn extract_ishield_z<R>( data: &[u8], budget: &mut Budget, @@ -41,13 +113,24 @@ pub(crate) fn extract_ishield_z<R>( if !is_ishield_z(data) { return Ok(None); } - let mut archive = match unshield::Archive::new(Cursor::new(data)) { - Ok(a) => a, - Err(e) => { + let members = match read_toc(data) { + Some(Toc::Members(m)) => m, + Some(Toc::TooMany) => { + budget.count_entry()?; + return Ok(visit( + Entry::unsupported( + format!("<installshield-z-entries-beyond-{MAX_ENTRIES}>"), + 0, + false, + "too many InstallShield Z members to walk them all", + ), + budget, + )); + } + None => { // The header said InstallShield Z and its own arithmetic checked // out; failing to open now leaves every member unexamined, which // must not pass quietly. - let _ = e; budget.count_entry()?; return Ok(visit( Entry::unsupported( @@ -61,32 +144,20 @@ pub(crate) fn extract_ishield_z<R>( } }; - // The listing is borrowed from the archive and `load` needs it mutably, so - // the paths are taken first. This is a table of contents, not content: it is - // bounded by `MAX_ENTRIES` and holds no member bytes. - let mut listing: Vec<(String, usize)> = Vec::new(); - let mut truncated = false; - for f in archive.list() { - if listing.len() >= MAX_ENTRIES { - truncated = true; - break; - } - listing.push((f.path.clone(), f.size)); - } - - for (path, comp_size) in listing { - let name = display_name(&path); + for m in members { + let name = display_name(&m.path); + let comp_size = m.size as u64; budget.count_entry()?; let cap = budget.reserve()?; - // `size` in the table of contents is the COMPRESSED length — that is - // what `load_compressed` reads — so it cannot stand in for the output - // size. What it does bound is the input: a member whose compressed form - // is already over the cap cannot decode to anything under it. - if comp_size as u64 > cap { + // The table's size is the COMPRESSED length, so it cannot stand in + // for the output size. What it does bound is the input: a member whose + // compressed form is already over the cap cannot decode to anything + // under it. + if comp_size > cap { if let Some(r) = visit( Entry::unsupported( name, - comp_size as u64, + comp_size, false, "InstallShield Z member exceeds the per-member size budget", ), @@ -96,76 +167,45 @@ pub(crate) fn extract_ishield_z<R>( } continue; } - // Addressed by the raw path the listing gave; nothing constructs a name - // of its own here. + // The codec's matches run to 518 bytes, so a bounded input can expand + // a few hundred times over: the member is decoded up to the cap, and an + // over-cap result is still reported. Discarded bytes that nobody hears + // about are the failure mode this crate exists to avoid. // - // `Archive::load` explodes a member whole, with no cap: the codec's - // matches run to 518 bytes, so a bounded input can expand a few hundred - // times over. The compressed bytes are taken instead and read through - // the decoder up to the cap. An over-cap result is still reported: - // discarded bytes that nobody hears about are the failure mode this - // crate exists to avoid. - let loaded = archive - .load_compressed(&path) - .map_err(|_| ()) - .and_then(|raw| { - let s = crate::salvage(explode::ExplodeReader::new(Cursor::new(raw)), cap); - if s.over_cap || !s.undecoded { - Ok(s) - } else { - Err(()) - } - }); - match loaded.map(|s| (s.over_cap, s.data)) { - Ok((true, _)) => { - if let Some(r) = visit( - Entry::unsupported( - name, - comp_size as u64, - false, - "InstallShield Z member decompressed past the per-member \ - size budget", - ), - budget, - ) { - return Ok(Some(r)); - } - } - Ok((false, bytes)) => { - budget.commit(bytes.len() as u64); - let mut e = Entry::new(name, bytes); - e.comp_size = comp_size as u64; - if let Some(r) = visit(e, budget) { - return Ok(Some(r)); - } - } - Err(()) => { - // One member exav cannot decode must not stop the rest. - if let Some(r) = visit( - Entry::unsupported( - name, - comp_size as u64, - false, - "InstallShield Z member could not be decompressed", - ), - budget, - ) { - return Ok(Some(r)); - } + // A member whose range runs past the file is decoded as far as the + // file goes. What the codec decoded before a stream failed or ended + // early is kept and scanned, and the member is reported. + let raw = data.get(m.offset..).unwrap_or_default(); + let raw = &raw[..m.size.min(raw.len())]; + let range_cut = raw.len() < m.size; + let s = super::dcl_explode::explode(raw, cap); + if s.over_cap { + if let Some(r) = visit( + Entry::unsupported( + name, + comp_size, + false, + "InstallShield Z member decompressed past the per-member \ + size budget", + ), + budget, + ) { + return Ok(Some(r)); } + continue; + } + budget.commit(s.data.len() as u64); + let mut e = Entry::new(name, s.data); + e.comp_size = comp_size; + if s.undecoded || s.cut_short || range_cut { + e.unsupported = Some( + "InstallShield Z member failed to decompress in full; the bytes \ + before the failure were scanned", + ); + } + if let Some(r) = visit(e, budget) { + return Ok(Some(r)); } - } - if truncated { - budget.count_entry()?; - return Ok(visit( - Entry::unsupported( - format!("<installshield-z-entries-beyond-{MAX_ENTRIES}>"), - 0, - false, - "too many InstallShield Z members to walk them all", - ), - budget, - )); } Ok(None) } diff --git a/crates/exav-unpack/src/formats/iso.rs b/crates/exav-unpack/src/formats/iso.rs index 78264559..d3df8850 100644 --- a/crates/exav-unpack/src/formats/iso.rs +++ b/crates/exav-unpack/src/formats/iso.rs @@ -89,13 +89,13 @@ pub(crate) fn walk<T>( } /// The ISO 9660 regions, and the sectors the files start at. -type StreamedTree = (Vec<Region>, HashSet<u64>); +type StreamedTree = (Vec<Region>, HashSet<(u64, u64)>); /// Walk the ISO 9660 directory tree(s) via targeted reads (directory records /// are small) and return each file as a region; file extents, the bulk of a /// disc image, are streamed by the caller. `max_buffer` bounds each directory /// read. Both the primary and Joliet trees are walked; a file present in both -/// (by extent) is emitted once. The extents emitted come back too, for the UDF +/// (by extent and length) is emitted once. The extents emitted come back too, for the UDF /// walk that follows to skip. pub(crate) fn stream_offsets<R: Read + Seek>( source: &mut R, @@ -123,7 +123,7 @@ pub(crate) fn stream_offsets<R: Read + Seek>( return Err(e); } let mut out = Vec::new(); - let mut seen_files: HashSet<u64> = HashSet::new(); + let mut seen_files: HashSet<(u64, u64)> = HashSet::new(); if roots.is_empty() { return Ok((out, seen_files)); } @@ -132,7 +132,9 @@ pub(crate) fn stream_offsets<R: Read + Seek>( // A directory is walked once per tree, however many records name it: a // record can point back at its own extent. let mut queued: HashSet<(bool, u64)> = roots.iter().map(|&(j, lba, _)| (j, lba)).collect(); - let mut dirs: Vec<(bool, u64, u64)> = roots; + // Popped from the end: the Joliet roots, which `vd_roots` puts first, are + // walked first, and a file in both trees takes its long name. + let mut dirs: Vec<(bool, u64, u64)> = roots.into_iter().rev().collect(); while let Some((joliet, lba, len)) = dirs.pop() { visited = visited.saturating_add(1); if visited > MAX_DIRS { @@ -188,7 +190,10 @@ pub(crate) fn stream_offsets<R: Read + Seek>( if queued.insert((joliet, child_lba)) { dirs.push((joliet, child_lba, child_len)); } - } else if seen_files.insert(child_lba) { + // A file is its extent and its length: two records at one start with + // different lengths are two files, and the empty one must not take + // the place of the other. + } else if seen_files.insert((child_lba, child_len)) { let raw = rec .get(33..33usize.saturating_add(name_len.min(rec_len.saturating_sub(33)))) .unwrap_or(&[]); diff --git a/crates/exav-unpack/src/formats/javaclass.rs b/crates/exav-unpack/src/formats/javaclass.rs index 51712d97..bd52c1e6 100644 --- a/crates/exav-unpack/src/formats/javaclass.rs +++ b/crates/exav-unpack/src/formats/javaclass.rs @@ -156,7 +156,7 @@ mod tests { data.extend_from_slice(&0xCAFEBABEu32.to_be_bytes()); // magic data.extend_from_slice(&0u16.to_be_bytes()); // minor data.extend_from_slice(&52u16.to_be_bytes()); // major (Java 8) - // constant_pool_count = slots + 1 (1-indexed, count is entries + 1). + // constant_pool_count = slots + 1 (1-indexed, count is entries + 1). data.extend_from_slice(&(pool_slots + 1).to_be_bytes()); data.extend_from_slice(pool_bytes); data @@ -213,7 +213,7 @@ mod tests { data.extend_from_slice(&0u16.to_be_bytes()); data.extend_from_slice(&52u16.to_be_bytes()); data.extend_from_slice(&1001u16.to_be_bytes()); // count => 1000 entries - // One complete Utf8, then EOF mid-pool. + // One complete Utf8, then EOF mid-pool. push_utf8(&mut data, b"present"); data.push(TAG_UTF8); // dangling tag, no length/body follows @@ -221,10 +221,12 @@ mod tests { let entries = extract(Format::JavaClass, &data, &mut budget).unwrap(); // At most the one fully-parsed string is surfaced; no panic. assert_eq!(entries.len(), 1); - assert!(entries[0] - .data - .windows(b"present".len()) - .any(|w| w == b"present")); + assert!( + entries[0] + .data + .windows(b"present".len()) + .any(|w| w == b"present") + ); } #[test] diff --git a/crates/exav-unpack/src/formats/lha.rs b/crates/exav-unpack/src/formats/lha.rs index 92e1c874..e89c0eae 100644 --- a/crates/exav-unpack/src/formats/lha.rs +++ b/crates/exav-unpack/src/formats/lha.rs @@ -74,7 +74,7 @@ pub(crate) fn walk<T>( budget: &mut Budget, visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { - use crate::stream::{emit_stream, MemberMeta}; + use crate::stream::{MemberMeta, emit_stream}; check_first_header(src)?; let mut dec = delharc::LhaDecodeReader::new(crate::source::Reader::new(src)) .map_err(|e| LimitHit::new(format!("lha: {e}")))?; diff --git a/crates/exav-unpack/src/formats/lz4.rs b/crates/exav-unpack/src/formats/lz4.rs index e8a24a8a..4c1edd6b 100644 --- a/crates/exav-unpack/src/formats/lz4.rs +++ b/crates/exav-unpack/src/formats/lz4.rs @@ -53,7 +53,7 @@ fn le_u32(d: &[u8], off: usize) -> Option<u32> { /// Decompress one LZ4 block, appending to `out`. Matches may reach back before /// `block_start` when blocks are linked, which is why the whole output buffer is /// passed rather than a per-block one. -fn block(src: &[u8], out: &mut Vec<u8>, cap: usize) -> Result<(), &'static str> { +pub(crate) fn block(src: &[u8], out: &mut Vec<u8>, cap: usize) -> Result<(), &'static str> { let mut i = 0usize; while i < src.len() { let token = src[i]; diff --git a/crates/exav-unpack/src/formats/lzfse.rs b/crates/exav-unpack/src/formats/lzfse.rs new file mode 100644 index 00000000..0729ece9 --- /dev/null +++ b/crates/exav-unpack/src/formats/lzfse.rs @@ -0,0 +1,1110 @@ +//! LZFSE, Apple's codec for DMG `lzfse` runs, decoded: its raw (`bvx-`), +//! LZVN (`bvxn`) and FSE (`bvx1`, `bvx2`) blocks, up to the end-of-stream +//! block (`bvx$`). +//! +//! A port of the decoder of Apple's reference implementation (`lzfse`, +//! BSD-3-Clause, attributed in `NOTICE`). It decodes a slice whole into a +//! capped buffer, so the reference's resumable state and its wide, unchecked +//! copies are gone. A damaged stream the reference decodes is decoded to the +//! same bytes: its literal buffer and decoding tables are kept across blocks +//! as it keeps them, since a damaged block reads what earlier ones left +//! there. Where it would read memory it never wrote instead (an LZVN match +//! reusing a distance before there is one, or reaching back past the +//! output's start), the stream is invalid here. + +use crate::Salvaged; + +const END_OF_STREAM: u32 = 0x2478_7662; // bvx$ +const UNCOMPRESSED: u32 = 0x2d78_7662; // bvx- +const COMPRESSED_V1: u32 = 0x3178_7662; // bvx1 +const COMPRESSED_V2: u32 = 0x3278_7662; // bvx2 +const COMPRESSED_LZVN: u32 = 0x6e78_7662; // bvxn + +const L_SYMBOLS: usize = 20; +const M_SYMBOLS: usize = 20; +const D_SYMBOLS: usize = 64; +const LITERAL_SYMBOLS: usize = 256; +const L_STATES: usize = 64; +const M_STATES: usize = 64; +const D_STATES: usize = 256; +const LITERAL_STATES: usize = 1024; +const MATCHES_PER_BLOCK: u32 = 10_000; +const LITERALS_PER_BLOCK: u32 = 4 * MATCHES_PER_BLOCK; +/// The reference's literal buffer, which a damaged block's matches may read +/// past the literals it decoded. +const LITERAL_BUFFER: usize = LITERALS_PER_BLOCK as usize + 64; +/// `sizeof(lzfse_compressed_block_header_v1)`, padding included. +const V1_HEADER: usize = 772; +/// Where a v2 header's frequency tables start. +const V2_FREQ: usize = 32; + +const L_EXTRA_BITS: [u8; L_SYMBOLS] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 5, 8]; +const L_BASE: [i32; L_SYMBOLS] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 28, 60, +]; +const M_EXTRA_BITS: [u8; M_SYMBOLS] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 5, 8, 11]; +const M_BASE: [i32; M_SYMBOLS] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 24, 56, 312, +]; +const D_EXTRA_BITS: [u8; D_SYMBOLS] = [ + 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 7, + 8, 8, 8, 8, 9, 9, 9, 9, 10, 10, 10, 10, 11, 11, 11, 11, 12, 12, 12, 12, 13, 13, 13, 13, 14, 14, + 14, 14, 15, 15, 15, 15, +]; +const D_BASE: [i32; D_SYMBOLS] = [ + 0, 1, 2, 3, 4, 6, 8, 10, 12, 16, 20, 24, 28, 36, 44, 52, 60, 76, 92, 108, 124, 156, 188, 220, + 252, 316, 380, 444, 508, 636, 764, 892, 1020, 1276, 1532, 1788, 2044, 2556, 3068, 3580, 4092, + 5116, 6140, 7164, 8188, 10236, 12284, 14332, 16380, 20476, 24572, 28668, 32764, 40956, 49148, + 57340, 65532, 81916, 98300, 114684, 131068, 163836, 196604, 229372, +]; + +enum Stop { + /// The input ended before the end-of-stream block. + Truncated, + /// The input is not an LZFSE stream. + Invalid, + /// The output reached the cap. + Full, +} + +/// Decode `input` up to `cap` bytes, salvaging what came out before an +/// error, as [`crate::salvage`] reports a reader's. +pub(crate) fn decode(input: &[u8], cap: u64) -> Salvaged { + let mut out = Out::new(cap); + let stop = run(input, &mut out); + out.finish(stop) +} + +/// [`decode`] of a bare LZVN stream, as macOS stores a compressed file's +/// blocks (`formats/decmpfs`): the opcodes of a `bvxn` block's payload, up to +/// its end-of-stream opcode, with no block header. +pub(crate) fn decode_lzvn(input: &[u8], cap: u64) -> Salvaged { + let mut out = Out::new(cap); + let stop = lzvn(input, &mut out).err(); + out.finish(stop) +} + +/// The output, which LZVN and LZFSE matches reach back into across blocks. +struct Out { + buf: Vec<u8>, + limit: usize, +} + +impl Out { + /// An output of at most `cap` bytes (one more is held, to see the cap passed). + fn new(cap: u64) -> Self { + Out { + buf: Vec::new(), + limit: usize::try_from(cap).unwrap_or(usize::MAX).saturating_add(1), + } + } + + fn finish(self, stop: Option<Stop>) -> Salvaged { + let kept = self.limit - 1; + let over_cap = self.buf.len() > kept; + let mut data = self.buf; + data.truncate(kept); + Salvaged { + data, + over_cap, + undecoded: matches!(stop, Some(Stop::Invalid)), + cut_short: matches!(stop, Some(Stop::Truncated)), + } + } + + fn literal(&mut self, bytes: &[u8]) -> Result<(), Stop> { + let room = self.limit - self.buf.len(); + self.buf.extend_from_slice(&bytes[..bytes.len().min(room)]); + match bytes.len() > room { + true => Err(Stop::Full), + false => Ok(()), + } + } + + /// `len` bytes from `dist` back, byte by byte: the copy may overlap + /// what it writes. + fn copy(&mut self, dist: usize, len: usize) -> Result<(), Stop> { + if dist == 0 || dist > self.buf.len() { + return Err(Stop::Invalid); + } + for _ in 0..len { + if self.buf.len() == self.limit { + return Err(Stop::Full); + } + self.buf.push(self.buf[self.buf.len() - dist]); + } + Ok(()) + } +} + +fn u32_at(d: &[u8], at: usize) -> Option<u32> { + Some(u32::from_le_bytes( + crate::bytes::at(d, at, 4)?.try_into().ok()?, + )) +} + +/// What the reference keeps in its decoder state from block to block, zeros +/// at first: a block only overwrites the part it decodes or lays out. +struct Kept { + literals: Vec<u8>, + literal: Vec<Entry>, + l: Vec<ValueEntry>, + m: Vec<ValueEntry>, + d: Vec<ValueEntry>, +} + +/// `None` at the end-of-stream block. +fn run(input: &[u8], out: &mut Out) -> Option<Stop> { + let mut pos = 0; + let mut kept = Kept { + literals: vec![0; LITERAL_BUFFER], + literal: vec![Entry::default(); LITERAL_STATES], + l: vec![ValueEntry::default(); L_STATES], + m: vec![ValueEntry::default(); M_STATES], + d: vec![ValueEntry::default(); D_STATES], + }; + loop { + let Some(magic) = u32_at(input, pos) else { + return Some(Stop::Truncated); + }; + let r = match magic { + END_OF_STREAM => return None, + UNCOMPRESSED => uncompressed(input, pos, out), + COMPRESSED_LZVN => lzvn_block(input, pos, out), + COMPRESSED_V1 | COMPRESSED_V2 => fse_block(input, pos, magic, &mut kept, out), + _ => Err(Stop::Invalid), + }; + match r { + Ok(next) => pos = next, + Err(e) => return Some(e), + } + } +} + +fn uncompressed(input: &[u8], pos: usize, out: &mut Out) -> Result<usize, Stop> { + let n = u32_at(input, pos + 4).ok_or(Stop::Truncated)? as usize; + let data = &input[(pos + 8).min(input.len())..]; + out.literal(&data[..n.min(data.len())])?; + match data.len() < n { + true => Err(Stop::Truncated), + false => Ok(pos + 8 + n), + } +} + +fn lzvn_block(input: &[u8], pos: usize, out: &mut Out) -> Result<usize, Stop> { + let n_raw = u32_at(input, pos + 4).ok_or(Stop::Truncated)? as usize; + let n_payload = u32_at(input, pos + 8).ok_or(Stop::Truncated)? as usize; + let rest = &input[pos + 12..]; + let payload = &rest[..n_payload.min(rest.len())]; + let end = out.buf.len().saturating_add(n_raw); + // The block's output stops at its declared size, as at the output limit: + // whichever comes first ends it, after the part of an instruction that + // fits. + let saved = out.limit; + out.limit = saved.min(end); + let result = lzvn(payload, out); + out.limit = saved; + match result { + Err(Stop::Full) if end < saved => Err(Stop::Invalid), + Err(Stop::Full) => Err(Stop::Full), + Ok(used) if used == n_payload && out.buf.len() == end => Ok(pos + 12 + n_payload), + // Out of payload before the end opcode: cut if the input ends there. + Err(Stop::Truncated) if rest.len() < n_payload => Err(Stop::Truncated), + _ => Err(Stop::Invalid), + } +} + +/// Decode an LZVN payload. The bytes it used, once it reaches its +/// end-of-stream opcode. `Truncated` when the payload ends before that. +fn lzvn(src: &[u8], out: &mut Out) -> Result<usize, Stop> { + enum Op { + LiteralMatch { + len: usize, + l: usize, + m: usize, + d: Option<usize>, + }, + Match { + len: usize, + m: usize, + }, + Literal { + len: usize, + l: usize, + }, + Nop, + End, + Undefined, + } + let mut i = 0; + let mut d = 0usize; + loop { + // Every opcode but the end is followed by at least one byte, the next + // opcode's. + let left = src.len() - i; + let opc = *src.get(i).ok_or(Stop::Truncated)?; + let op = match opc { + 6 => Op::End, + 14 | 22 => Op::Nop, + 0xe0 => Op::Literal { + len: 2, + l: *src.get(i + 1).ok_or(Stop::Truncated)? as usize + 16, + }, + 0xe1..=0xef => Op::Literal { + len: 1, + l: (opc & 15) as usize, + }, + 0xf0 => Op::Match { + len: 2, + m: *src.get(i + 1).ok_or(Stop::Truncated)? as usize + 16, + }, + 0xf1..=0xff => Op::Match { + len: 1, + m: (opc & 15) as usize, + }, + 0x70..=0x7f | 0xd0..=0xdf => Op::Undefined, + 0xa0..=0xbf => Op::LiteralMatch { + len: 3, + l: ((opc >> 3) & 3) as usize, + m: 0, + d: None, + }, + _ => match opc & 7 { + 7 => Op::LiteralMatch { + len: 3, + l: (opc >> 6) as usize, + m: ((opc >> 3) & 7) as usize + 3, + d: None, + }, + 6 if opc >= 64 => Op::LiteralMatch { + len: 1, + l: (opc >> 6) as usize, + m: ((opc >> 3) & 7) as usize + 3, + d: Some(d), + }, + 6 => Op::Undefined, + _ => Op::LiteralMatch { + len: 2, + l: (opc >> 6) as usize, + m: ((opc >> 3) & 7) as usize + 3, + d: None, + }, + }, + }; + match op { + Op::End => { + return match left >= 8 { + true => Ok(i + 8), + false => Err(Stop::Truncated), + }; + } + Op::Undefined => return Err(Stop::Invalid), + Op::Nop => { + if left <= 1 { + return Err(Stop::Truncated); + } + i += 1; + } + Op::Literal { len, l } => { + if left <= len + l { + return Err(Stop::Truncated); + } + out.literal(&src[i + len..i + len + l])?; + i += len + l; + } + Op::Match { len, m } => { + if left <= len { + return Err(Stop::Truncated); + } + i += len; + out.copy(d, m)?; + } + Op::LiteralMatch { + len, + l, + mut m, + d: dist, + } => { + if left <= len + l { + return Err(Stop::Truncated); + } + d = match (len, dist) { + (_, Some(prev)) => prev, + (2, None) => ((opc & 7) as usize) << 8 | src[i + 1] as usize, + (_, None) => { + let v = u16::from_le_bytes([src[i + 1], src[i + 2]]) as usize; + if opc & 0xe0 == 0xa0 { + m = ((((opc & 7) as usize) << 2) | (v & 3)) + 3; + v >> 2 + } else { + v + } + } + }; + out.literal(&src[i + len..i + len + l])?; + i += len + l; + out.copy(d, m)?; + } + } + } +} + +/// A block's header, as the reference's `lzfse_compressed_block_header_v1`. +struct Header { + n_literals: u32, + n_matches: u32, + n_literal_payload: usize, + n_lmd_payload: usize, + literal_bits: i32, + literal_state: [u16; 4], + lmd_bits: i32, + l_state: u16, + m_state: u16, + d_state: u16, + /// `l_freq`, `m_freq`, `d_freq` and `literal_freq`, in that order. + freq: [u16; L_SYMBOLS + M_SYMBOLS + D_SYMBOLS + LITERAL_SYMBOLS], +} + +fn fse_block( + input: &[u8], + pos: usize, + magic: u32, + kept: &mut Kept, + out: &mut Out, +) -> Result<usize, Stop> { + let (h, header_size) = match magic { + COMPRESSED_V2 => header_v2(input, pos)?, + _ => header_v1(input, pos)?, + }; + // Header fields: on wasm32 their sum can pass `usize::MAX`. + let lmd_start = (pos + header_size).checked_add(h.n_literal_payload); + let block_end = lmd_start.and_then(|s| s.checked_add(h.n_lmd_payload)); + let (Some(lmd_start), Some(block_end)) = (lmd_start, block_end.filter(|&e| e <= input.len())) + else { + return Err(Stop::Truncated); + }; + check(&h)?; + let (l_freq, rest) = h.freq.split_at(L_SYMBOLS); + let (m_freq, rest) = rest.split_at(M_SYMBOLS); + let (d_freq, literal_freq) = rest.split_at(D_SYMBOLS); + decoder_table(&mut kept.literal, literal_freq); + value_table(&mut kept.l, l_freq, &L_EXTRA_BITS, &L_BASE); + value_table(&mut kept.m, m_freq, &M_EXTRA_BITS, &M_BASE); + value_table(&mut kept.d, d_freq, &D_EXTRA_BITS, &D_BASE); + let Kept { + literals, + literal: literal_table, + l: l_table, + m: m_table, + d: d_table, + } = kept; + + { + // The reference bounds even this stream's first bytes by the input's + // start, not the payload's: a payload of 7 bytes is read as 8. + let mut s = Stream::new(input, 0, lmd_start, h.literal_bits)?; + let mut state = h.literal_state; + // `check` bounds `n_literals` by the buffer. + let n = (h.n_literals as usize).next_multiple_of(4); + for quad in literals[..n].as_chunks_mut::<4>().0 { + s.flush()?; + for (byte, st) in quad.iter_mut().zip(state.iter_mut()) { + let e = literal_table.get(*st as usize).ok_or(Stop::Invalid)?; + *st = e.delta.wrapping_add(s.pull(e.k)? as u16); + *byte = e.symbol; + } + } + } + + let mut s = Stream::new(input, lmd_start, block_end, h.lmd_bits)?; + let (mut l_state, mut m_state, mut d_state) = (h.l_state, h.m_state, h.d_state); + let mut lit = 0usize; + // No distance yet: a match that reuses it is invalid. + let mut d: i64 = -1; + for _ in 0..h.n_matches { + s.flush()?; + let l = s.value(l_table, &mut l_state)? as usize; + let m = s.value(m_table, &mut m_state)? as usize; + let new_d = s.value(d_table, &mut d_state)?; + if new_d != 0 { + d = i64::from(new_d); + } + // The reference's bound, one byte short of its buffer's end. + let literal = literals + .get(lit..lit + l) + .filter(|_| lit + l < LITERAL_BUFFER); + let literal = literal.ok_or(Stop::Invalid)?; + if d < 0 || d as usize > out.buf.len() + l { + return Err(Stop::Invalid); + } + out.literal(literal)?; + lit += l; + if m > 0 { + out.copy(d as usize, m)?; + } + } + Ok(block_end) +} + +fn header_v1(input: &[u8], pos: usize) -> Result<(Header, usize), Stop> { + let h = crate::bytes::at(input, pos, V1_HEADER).ok_or(Stop::Truncated)?; + let u32_ = |o: usize| u32::from_le_bytes(h[o..o + 4].try_into().unwrap()); + let u16_ = |o: usize| u16::from_le_bytes([h[o], h[o + 1]]); + let mut freq = [0u16; L_SYMBOLS + M_SYMBOLS + D_SYMBOLS + LITERAL_SYMBOLS]; + for (i, f) in freq.iter_mut().enumerate() { + *f = u16_(50 + 2 * i); + } + let header = Header { + n_literals: u32_(12), + n_matches: u32_(16), + n_literal_payload: u32_(20) as usize, + n_lmd_payload: u32_(24) as usize, + literal_bits: u32_(28) as i32, + literal_state: [u16_(32), u16_(34), u16_(36), u16_(38)], + lmd_bits: u32_(40) as i32, + l_state: u16_(44), + m_state: u16_(46), + d_state: u16_(48), + freq, + }; + Ok((header, V1_HEADER)) +} + +fn header_v2(input: &[u8], pos: usize) -> Result<(Header, usize), Stop> { + let fixed = crate::bytes::at(input, pos, V2_FREQ).ok_or(Stop::Truncated)?; + let packed = |i: usize| u64::from_le_bytes(fixed[8 + 8 * i..16 + 8 * i].try_into().unwrap()); + let (v0, v1, v2) = (packed(0), packed(1), packed(2)); + let field = |v: u64, offset: u32, bits: u32| ((v >> offset) & ((1 << bits) - 1)) as u32; + let header_size = field(v2, 0, 32) as usize; + if header_size < V2_FREQ { + return Err(Stop::Invalid); + } + let tables = + crate::bytes::at(input, pos + V2_FREQ, header_size - V2_FREQ).ok_or(Stop::Truncated)?; + let mut freq = [0u16; L_SYMBOLS + M_SYMBOLS + D_SYMBOLS + LITERAL_SYMBOLS]; + // No tables at all is allowed: every frequency is zero. + if !tables.is_empty() { + let (mut accum, mut accum_bits, mut next) = (0u32, 0u32, 0usize); + for f in freq.iter_mut() { + while next < tables.len() && accum_bits + 8 <= 32 { + accum |= u32::from(tables[next]) << accum_bits; + accum_bits += 8; + next += 1; + } + let (value, bits) = freq_value(accum); + if bits > accum_bits { + return Err(Stop::Invalid); + } + *f = value; + accum >>= bits; + accum_bits -= bits; + } + if accum_bits >= 8 || next != tables.len() { + return Err(Stop::Invalid); + } + } + let header = Header { + n_literals: field(v0, 0, 20), + n_literal_payload: field(v0, 20, 20) as usize, + n_matches: field(v0, 40, 20), + literal_bits: field(v0, 60, 3) as i32 - 7, + literal_state: [0, 10, 20, 30].map(|o| field(v1, o, 10) as u16), + n_lmd_payload: field(v1, 40, 20) as usize, + lmd_bits: field(v1, 60, 3) as i32 - 7, + l_state: field(v2, 32, 10) as u16, + m_state: field(v2, 42, 10) as u16, + d_state: field(v2, 52, 10) as u16, + freq, + }; + Ok((header, header_size)) +} + +/// One frequency of a v2 header's tables, and the bits it took. +fn freq_value(bits: u32) -> (u16, u32) { + const NBITS: [u8; 32] = [ + 2, 3, 2, 5, 2, 3, 2, 8, 2, 3, 2, 5, 2, 3, 2, 14, 2, 3, 2, 5, 2, 3, 2, 8, 2, 3, 2, 5, 2, 3, + 2, 14, + ]; + const VALUE: [u8; 32] = [ + 0, 2, 1, 4, 0, 3, 1, 0, 0, 2, 1, 5, 0, 3, 1, 0, 0, 2, 1, 6, 0, 3, 1, 0, 0, 2, 1, 7, 0, 3, + 1, 0, + ]; + let b = (bits & 31) as usize; + match NBITS[b] { + 8 => (8 + ((bits >> 4) & 0xf) as u16, 8), + 14 => (24 + ((bits >> 4) & 0x3ff) as u16, 14), + n => (u16::from(VALUE[b]), u32::from(n)), + } +} + +/// The reference's `lzfse_check_block_header_v1`. +fn check(h: &Header) -> Result<(), Stop> { + let sum = |f: &[u16]| f.iter().map(|&v| v as usize).sum::<usize>(); + let (l, rest) = h.freq.split_at(L_SYMBOLS); + let (m, rest) = rest.split_at(M_SYMBOLS); + let (d, literal) = rest.split_at(D_SYMBOLS); + let ok = h.n_literals <= LITERALS_PER_BLOCK + && h.n_matches <= MATCHES_PER_BLOCK + && h.literal_state + .iter() + .all(|&s| (s as usize) < LITERAL_STATES) + && (h.l_state as usize) < L_STATES + && (h.m_state as usize) < M_STATES + && (h.d_state as usize) < D_STATES + && sum(l) <= L_STATES + && sum(m) <= M_STATES + && sum(d) <= D_STATES + && sum(literal) <= LITERAL_STATES; + match ok { + true => Ok(()), + false => Err(Stop::Invalid), + } +} + +#[derive(Clone, Copy, Default)] +struct Entry { + k: u32, + symbol: u8, + delta: u16, +} + +#[derive(Clone, Copy, Default)] +struct ValueEntry { + total_bits: u32, + value_bits: u32, + delta: u16, + vbase: i32, +} + +/// The states each symbol reaches, as `fse_init_decoder_table` lays them +/// out in `t`. States past the frequencies' sum keep what they held. +fn decoder_table(t: &mut [Entry], freq: &[u16]) { + let mut at = 0; + for (symbol, &f) in freq.iter().enumerate() { + for (j, (k, delta)) in transitions(t.len(), f).enumerate() { + t[at + j] = Entry { + k, + symbol: symbol as u8, + delta, + }; + } + at += f as usize; + } +} + +fn value_table(t: &mut [ValueEntry], freq: &[u16], extra: &[u8], base: &[i32]) { + let mut at = 0; + for (symbol, &f) in freq.iter().enumerate() { + for (j, (k, delta)) in transitions(t.len(), f).enumerate() { + t[at + j] = ValueEntry { + total_bits: k + u32::from(extra[symbol]), + value_bits: u32::from(extra[symbol]), + delta, + vbase: base[symbol], + }; + } + at += f as usize; + } +} + +/// For each of a symbol's `f` states: the bits read and the next state's +/// base. [`check`] has bounded `f` by `nstates`. +fn transitions(nstates: usize, f: u16) -> impl Iterator<Item = (u32, u16)> { + let n = nstates as u32; + let f = u32::from(f); + let k = f.leading_zeros().saturating_sub(n.leading_zeros()); + let j0 = ((2 * n) >> k).saturating_sub(f); + (0..f).map(move |j| match j < j0 { + true => (k, (((f + j) << k) - n) as u16), + false => (k - 1, ((j - j0) << (k - 1)) as u16), + }) +} + +/// A bit stream read backwards from `end`, 56 to 63 bits held: the +/// reference's 64-bit `fse_in_stream`. Its first bytes are after `start`; +/// later reads may run past that, as the encoder's last flush does, but not +/// past the input's start. +struct Stream<'a> { + input: &'a [u8], + pos: usize, + accum: u64, + bits: u32, +} + +impl<'a> Stream<'a> { + fn new(input: &'a [u8], start: usize, end: usize, n: i32) -> Result<Self, Stop> { + let width = if n != 0 { 8 } else { 7 }; + if end < start + width { + return Err(Stop::Invalid); + } + let pos = end - width; + let mut b = [0u8; 8]; + b[..width].copy_from_slice(&input[pos..end]); + let accum = u64::from_le_bytes(b); + let bits = i64::from(n) + 8 * width as i64; + if !(56..64).contains(&bits) || accum >> bits != 0 { + return Err(Stop::Invalid); + } + Ok(Stream { + input, + pos, + accum, + bits: bits as u32, + }) + } + + fn flush(&mut self) -> Result<(), Stop> { + let n = (63 - self.bits) & !7; + let pos = self.pos.checked_sub(n as usize / 8).ok_or(Stop::Invalid)?; + let mut b = [0u8; 8]; + b[..n as usize / 8].copy_from_slice(&self.input[pos..self.pos]); + self.pos = pos; + if n > 0 { + self.accum = (self.accum << n) | u64::from_le_bytes(b); + } + self.bits += n; + Ok(()) + } + + fn pull(&mut self, n: u32) -> Result<u64, Stop> { + self.bits = self.bits.checked_sub(n).ok_or(Stop::Invalid)?; + let v = self.accum >> self.bits; + self.accum &= (1u64 << self.bits) - 1; + Ok(v) + } + + fn value(&mut self, t: &[ValueEntry], state: &mut u16) -> Result<i32, Stop> { + let e = t.get(*state as usize).ok_or(Stop::Invalid)?; + let v = self.pull(e.total_bits)? as u32; + *state = e.delta.wrapping_add((v >> e.value_bits) as u16); + Ok(e.vbase + (v & ((1 << e.value_bits) - 1)) as i32) + } +} + +#[cfg(test)] +mod tests { + use super::{decode, decode_lzvn}; + + fn encode(data: &[u8]) -> Vec<u8> { + let mut enc = Vec::new(); + lzfse_rust::encode_bytes(data, &mut enc).unwrap(); + enc + } + + /// Text with repeats near and far, so the encoder emits matches of every + /// length and distance class. + fn text(n: usize, seed: u64) -> Vec<u8> { + let mut x = seed; + let mut out: Vec<u8> = Vec::with_capacity(n); + while out.len() < n { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + match (x % 4, out.len()) { + (0, len) if len > 300 => { + let back = 1 + (x >> 8) as usize % len.min(200_000); + let len_ = 3 + (x >> 40) as usize % 300; + for _ in 0..len_ { + out.push(out[out.len() - back]); + } + } + (1, _) => out.push(b'a' + (x >> 20) as u8 % 26), + _ => out.extend_from_slice(b"the quick brown fox "), + } + } + out.truncate(n); + out + } + + fn magics(enc: &[u8]) -> bool { + enc.windows(4).any(|w| w == b"bvx2") + } + + #[test] + fn every_block_kind_decodes_to_what_was_encoded() { + for (n, kind) in [ + (0, &b"bvx$"[..]), + (11, b"bvx-"), + (3000, b"bvxn"), + (100_000, b"bvx2"), + (3_000_000, b"bvx2"), + ] { + let data = text(n, n as u64 + 1); + let enc = encode(&data); + assert!( + enc.windows(4).any(|w| w == kind), + "{n} bytes: no {kind:?} block" + ); + let s = decode(&enc, u64::MAX); + assert!(!s.undecoded && !s.cut_short && !s.over_cap, "{n} bytes"); + assert!(s.data == data, "{n} bytes decode differently"); + } + } + + /// An LZVN block with each opcode once, by hand, and what it decodes to, + /// built without the decoder. + fn every_lzvn_opcode() -> (Vec<u8>, Vec<u8>) { + fn copy(out: &mut Vec<u8>, d: usize, m: usize) { + for _ in 0..m { + out.push(out[out.len() - d]); + } + } + let mut p = Vec::new(); + let mut want = Vec::new(); + // Large literal: 16 + 4 bytes. + p.extend_from_slice(&[0xe0, 4]); + p.extend_from_slice(b"abcdefghijklmnopqrst"); + want.extend_from_slice(b"abcdefghijklmnopqrst"); + // Small distance (LLMMMDDD): L 1, M 4, D 5. + p.extend_from_slice(&[0b0100_1000, 5, b'X']); + want.push(b'X'); + copy(&mut want, 5, 4); + // Medium distance (101LLMMM): L 0, M 5, D 10. + p.extend_from_slice(&[0b1010_0000, (10 << 2) | 2, 0]); + copy(&mut want, 10, 5); + // Large distance (LLMMM111): L 0, M 3, D 12. + p.extend_from_slice(&[0b0000_0111, 12, 0]); + copy(&mut want, 12, 3); + // Previous distance (LLMMM110): L 2, M 5. + p.extend_from_slice(&[0b1001_0110, b'Y', b'Z']); + want.extend_from_slice(b"YZ"); + copy(&mut want, 12, 5); + // Small match: M 3. Large match: M 16 + 1. + p.push(0xf3); + copy(&mut want, 12, 3); + p.extend_from_slice(&[0xf0, 1]); + copy(&mut want, 12, 17); + // Small literal, a no-op, the end. + p.extend_from_slice(&[0xe2, b'!', b'?', 0x0e, 6, 0, 0, 0, 0, 0, 0, 0]); + want.extend_from_slice(b"!?"); + + let mut s = b"bvxn".to_vec(); + s.extend_from_slice(&(want.len() as u32).to_le_bytes()); + s.extend_from_slice(&(p.len() as u32).to_le_bytes()); + s.extend(p); + s.extend_from_slice(b"bvx$"); + (s, want) + } + + /// A stream of one bvx2 block, its header rewritten as the bvx1 header + /// the reference reads the same way. The frequencies are decoded here, + /// not by `header_v2`. + fn as_v1(enc: &[u8]) -> Vec<u8> { + assert!(enc.starts_with(b"bvx2")); + let packed = |i: usize| u64::from_le_bytes(enc[8 + 8 * i..16 + 8 * i].try_into().unwrap()); + let (v0, v1, v2) = (packed(0), packed(1), packed(2)); + let f = |v: u64, o: u32, b: u32| ((v >> o) & ((1u64 << b) - 1)) as u32; + let header_size = f(v2, 0, 32) as usize; + let mut freq = Vec::new(); + let mut table = enc[32..header_size].iter(); + let (mut acc, mut bits) = (0u64, 0u32); + while freq.len() < 360 { + while bits <= 24 { + acc |= u64::from(table.next().copied().unwrap_or(0)) << bits; + bits += 8; + } + let (v, n) = match acc & 31 { + b if b & 15 == 7 => (8 + ((acc >> 4) & 0xf), 8), + b if b & 15 == 15 => (24 + ((acc >> 4) & 0x3ff), 14), + b if b & 1 == 0 => ((b >> 1) & 1, 2), + b if b & 3 == 1 => (2 + ((b >> 2) & 1), 3), + b => (4 + ((b >> 3) & 3), 5), + }; + freq.push(v as u16); + acc >>= n; + bits -= n; + } + let (lit, lmd) = (f(v0, 20, 20), f(v1, 40, 20)); + let mut h = Vec::new(); + h.extend_from_slice(b"bvx1"); + for v in [ + u32::from_le_bytes(enc[4..8].try_into().unwrap()), + lit + lmd, + f(v0, 0, 20), + f(v0, 40, 20), + lit, + lmd, + (f(v0, 60, 3) as i32 - 7) as u32, + ] { + h.extend_from_slice(&v.to_le_bytes()); + } + for k in 0..4 { + h.extend_from_slice(&(f(v1, 10 * k, 10) as u16).to_le_bytes()); + } + h.extend_from_slice(&(f(v1, 60, 3) as i32 - 7).to_le_bytes()); + for o in [32, 42, 52] { + h.extend_from_slice(&(f(v2, o, 10) as u16).to_le_bytes()); + } + for v in freq { + h.extend_from_slice(&v.to_le_bytes()); + } + h.resize(772, 0); + let body = header_size + (lit + lmd) as usize; + h.extend_from_slice(&enc[header_size..body]); + assert_eq!(&enc[body..], b"bvx$", "one block"); + h.extend_from_slice(b"bvx$"); + h + } + + #[test] + fn a_block_with_uncompressed_tables_decodes() { + let data = text(30_000, 11); + let v1 = as_v1(&encode(&data)); + let s = decode(&v1, u64::MAX); + assert!(!s.undecoded && !s.cut_short); + assert!(s.data == data); + } + + #[test] + fn every_lzvn_opcode_decodes() { + let (stream, want) = every_lzvn_opcode(); + let s = decode(&stream, u64::MAX); + assert!(!s.undecoded && !s.cut_short); + assert_eq!(s.data, want); + } + + /// The payload of the block above is a bare LZVN stream, as decmpfs + /// stores one. + #[test] + fn a_bare_lzvn_stream_decodes() { + let (stream, want) = every_lzvn_opcode(); + let bare = &stream[12..stream.len() - 4]; + let s = decode_lzvn(bare, u64::MAX); + assert!(!s.undecoded && !s.cut_short && !s.over_cap); + assert_eq!(s.data, want); + + let s = decode_lzvn(&bare[..bare.len() - 8], u64::MAX); + assert!(s.cut_short && !s.undecoded, "no end opcode"); + let s = decode_lzvn(bare, 10); + assert!(s.over_cap); + assert_eq!(s.data, want[..10]); + } + + #[test] + fn incompressible_data_decodes() { + let data: Vec<u8> = text(200_000, 7) + .iter() + .enumerate() + .map(|(i, b)| b ^ (i.wrapping_mul(2654435761) >> 13) as u8) + .collect(); + let enc = encode(&data); + assert_eq!(decode(&enc, u64::MAX).data, data); + } + + #[test] + fn the_cap_holds() { + let data = text(100_000, 3); + let s = decode(&encode(&data), 1000); + assert!(s.over_cap && !s.undecoded); + assert_eq!(s.data, data[..1000]); + } + + #[test] + fn a_cut_stream_keeps_its_whole_blocks_and_says_so() { + let first = text(3000, 5); + let mut enc = encode(&first); + enc.truncate(enc.len() - 4); // the end-of-stream block + enc.extend(encode(&text(100_000, 6))); + assert!(magics(&enc)); + let s = decode(&enc[..enc.len() - 100], u64::MAX); + assert!(s.cut_short && !s.undecoded); + assert!(s.data.starts_with(&first)); + } + + #[test] + fn damage_is_invalid_not_a_panic() { + assert!(decode(b"bvxZ\0\0\0\0", 100).undecoded); + let enc = encode(&text(100_000, 9)); + for at in (0..enc.len()).step_by(97) { + for bit in [0x01, 0x10, 0x80] { + let mut bad = enc.clone(); + bad[at] ^= bit; + let _ = decode(&bad, 1 << 20); + } + } + } + + /// A stream of one LZVN block: "abc", then a match of 5 from 3 back (so + /// 8 bytes), then the end opcode, and the end-of-stream block. + fn lzvn_stream(n_raw: u32, n_payload: u32, with_end_opcode: bool) -> Vec<u8> { + let mut p = vec![0xe3, b'a', b'b', b'c', 0x10, 0x03]; + if with_end_opcode { + p.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]); + } + let mut s = b"bvxn".to_vec(); + s.extend_from_slice(&n_raw.to_le_bytes()); + s.extend_from_slice(&n_payload.to_le_bytes()); + s.extend(p); + s.extend_from_slice(b"bvx$"); + s + } + + #[test] + fn the_lzvn_stream_of_these_tests_decodes() { + let s = decode(&lzvn_stream(8, 14, true), 100); + assert!(!s.undecoded && !s.cut_short && !s.over_cap); + assert_eq!(s.data, b"abcabcab"); + } + + #[test] + fn an_lzvn_payload_longer_than_the_input_that_still_ends_is_damaged_not_cut() { + // The end opcode is reached, 6 bytes short of the declared payload. + let mut s = lzvn_stream(8, 20, true); + s.truncate(s.len() - 4); + let r = decode(&s, 100); + assert!( + r.undecoded && !r.cut_short, + "{:?}", + (r.undecoded, r.cut_short) + ); + assert_eq!(r.data, b"abcabcab"); + } + + #[test] + fn an_lzvn_payload_cut_before_its_end_opcode_is_cut() { + let mut s = lzvn_stream(8, 14, true); + s.truncate(12 + 6); + let r = decode(&s, 100); + assert!(r.cut_short && !r.undecoded); + // The match is left out: an instruction needs the next opcode's byte. + assert_eq!(r.data, b"abc"); + // The same payload with the input whole is damaged. + let r = decode(&lzvn_stream(8, 6, false), 100); + assert!(r.undecoded && !r.cut_short); + } + + #[test] + fn an_lzvn_instruction_past_the_block_size_is_damaged_and_clipped() { + let r = decode(&lzvn_stream(6, 14, true), 100); + assert!(r.undecoded && !r.over_cap); + assert_eq!(r.data, b"abcabc"); + } + + #[test] + fn the_output_limit_comes_before_a_block_size_it_is_inside_of() { + // The block's declared 6 bytes are past the cap of 4. + let r = decode(&lzvn_stream(6, 14, true), 4); + assert!(r.over_cap && !r.undecoded); + assert_eq!(r.data, b"abca"); + } + + /// The reference's verdict codes (`lzfse_oracle.c`) for what `decode` + /// found: output past the limit is reported first. + fn verdict(s: &crate::Salvaged) -> u8 { + match (s.over_cap, s.cut_short, s.undecoded) { + (true, _, _) => 1, + (_, true, _) => 2, + (_, _, true) => 3, + _ => 0, + } + } + + /// What `decode` says of a stream, against what the reference says of it + /// with a destination of `limit` bytes, run twice with different fills of + /// that destination. `None` when they agree. + /// + /// The reference counts a failed block's last instruction out of the + /// bytes it reports, writes the part of an LZVN match that fits the + /// block's size, and calls an LZVN payload that ends before its end opcode + /// "destination full", so on a failure the two outputs are held to agree + /// as far as both go, and to a failure of some kind. A stream whose output + /// depends on the fill is one the reference read unwritten memory for, + /// which is invalid here. + fn disagreement( + input: &[u8], + limit: u32, + reference: &(u8, Vec<u8>), + other_fill: &(u8, Vec<u8>), + ) -> Option<&'static str> { + let s = decode(input, u64::from(limit)); + let (status, out) = reference; + if reference != other_fill { + return (!s.undecoded && !s.over_cap).then_some("read of unwritten memory accepted"); + } + let ours = verdict(&s); + match status { + 0 if ours != 0 => Some("a stream the reference decodes is not"), + 0 if s.data != *out => Some("output differs"), + 0 => None, + _ if ours == 0 => Some("a stream the reference refuses is decoded"), + _ if !s.data.starts_with(out) && !out.starts_with(&s.data) => Some("outputs diverge"), + 1 if out.len() == limit as usize && (ours != 1 || s.data != *out) => { + Some("output past the limit differs") + } + 2 if ours == 3 => Some("cut stream called damaged"), + // An LZVN payload cut inside its end opcode, after all its output: + // the reference's size check calls it an error. + 3 if ours == 2 && s.data != *out => Some("damaged stream called cut"), + _ => None, + } + } + + /// Streams the reference decodes, cut or damaged in many ways, and the + /// bytes it wrote before it stopped, against `decode`. Needs the oracle + /// built by `tests/oracle/build.sh`: + /// `EXAV_LZFSE_ORACLE=... cargo test -p exav-unpack --lib -- --ignored lzfse_oracle`. + #[test] + #[ignore = "needs the reference decoder: tests/oracle/build.sh, EXAV_LZFSE_ORACLE"] + fn lzfse_oracle_agrees_on_damaged_streams() { + let rounds: usize = std::env::var("EXAV_DEBUG_ORACLE_ROUNDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3000); + let mut bases: Vec<(Vec<u8>, usize)> = Vec::new(); + for (n, seed) in [(0, 1), (11, 2), (3000, 3), (30_000, 4), (100_000, 5)] { + let enc = encode(&text(n, seed)); + bases.push((enc, 800)); + } + bases.push((as_v1(&encode(&text(30_000, 11))), 800)); + bases.push((every_lzvn_opcode().0, 200)); + let mut two = encode(&text(3000, 6)); + two.truncate(two.len() - 4); + two.extend(encode(&text(40_000, 7))); + bases.push((two, 800)); + let mut rng = crate::oracle::Rng(0x9e37_79b9_7f4a_7c15); + let mut jobs = Vec::new(); + for (base, hot) in &bases { + jobs.push((u32::MAX >> 8, base.clone())); + for _ in 0..rounds { + let bad = rng.damage(base, *hot); + let limit = match rng.below(3) { + 0 => 1 << 20, + 1 => rng.below(base.len() * 4 + 1) as u32, + _ => 1 << 24, + }; + jobs.push((limit, bad)); + } + } + let replies = crate::oracle::run("EXAV_LZFSE_ORACLE", &["0"], &jobs); + let others = crate::oracle::run("EXAV_LZFSE_ORACLE", &["255"], &jobs); + let mut bad = Vec::new(); + // The reference's verdict by ours. + let mut matrix = [[0usize; 4]; 4]; + for (((limit, input), reply), other) in jobs.iter().zip(&replies).zip(&others) { + let s = decode(input, u64::from(*limit)); + matrix[usize::from(reply.0)][usize::from(verdict(&s))] += 1; + if let Some(why) = disagreement(input, *limit, reply, other) { + let ours = (s.data.len(), s.over_cap, s.cut_short, s.undecoded); + crate::oracle::dump("lzfse", bad.len(), input, &reply.1, &s.data); + bad.push((why, *limit, (reply.0, reply.1.len()), ours, input.clone())); + } + } + eprintln!( + "{} streams; rows the reference's verdict (ok, full, cut, error), columns ours: {matrix:?}", + jobs.len() + ); + for (why, limit, reference, ours, input) in bad.iter().take(5) { + eprintln!("{why}: limit {limit}, {} bytes", input.len()); + eprintln!(" reference (status, written) {reference:?}"); + eprintln!(" ours (written, over cap, cut, undecoded) {ours:?}"); + let head: Vec<String> = input.iter().take(48).map(|b| format!("{b:02x}")).collect(); + eprintln!(" {}", head.join(" ")); + } + assert!( + bad.is_empty(), + "{} of {} streams differ", + bad.len(), + jobs.len() + ); + } +} diff --git a/crates/exav-unpack/src/formats/lzip.rs b/crates/exav-unpack/src/formats/lzip.rs index 772eb7e3..9d4b37c5 100644 --- a/crates/exav-unpack/src/formats/lzip.rs +++ b/crates/exav-unpack/src/formats/lzip.rs @@ -14,7 +14,7 @@ use lzma_rust2::LzmaStream; use super::lzma::SansIo; use crate::source::{ByteSource, Reader}; -use crate::stream::{stream_single, Visit}; +use crate::stream::{Visit, stream_single}; use crate::{Budget, LimitHit}; const HEADER: usize = 6; @@ -159,11 +159,11 @@ impl Read for Members<'_> { }; match member.read(buf) { Ok(0) => { - if let Some(m) = self.member.take() { - if let Err(e) = self.finish(m) { - self.done = true; - return Err(e); - } + if let Some(m) = self.member.take() + && let Err(e) = self.finish(m) + { + self.done = true; + return Err(e); } } Ok(n) => { diff --git a/crates/exav-unpack/src/formats/lzmaalone.rs b/crates/exav-unpack/src/formats/lzmaalone.rs new file mode 100644 index 00000000..977bd1ae --- /dev/null +++ b/crates/exav-unpack/src/formats/lzmaalone.rs @@ -0,0 +1,31 @@ +//! LZMA-alone (`.lzma`, the format of LZMA Utils before xz): a 13-byte header +//! (the properties byte, the dictionary size, the uncompressed size or all ones +//! when unknown) and one LZMA stream, which ends at the size or at an end +//! marker. There is no checksum. `lzma_rust2` parses the header and decodes; +//! the sniff is in [`super::sniff`]. +use std::io::Read; + +use lzma_rust2::LzmaStream; + +use super::lzma::SansIo; +use crate::source::{ByteSource, Reader}; +use crate::stream::{Visit, stream_single}; +use crate::{Budget, LimitHit}; + +/// Memory the decoder may take, in KiB: the dictionary the header asks for and +/// the probability tables. `lzma -9` asks for a 64 MiB dictionary. +const MEM_LIMIT_KIB: u32 = 128 * 1024; + +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let mut source = Reader::new(src); + stream_single(&mut source, budget, visit, "lzma-content", |r| { + Ok(Box::new(SansIo::new( + r, + LzmaStream::new_mem_limit(MEM_LIMIT_KIB, None), + )) as Box<dyn Read + '_>) + }) +} diff --git a/crates/exav-unpack/src/formats/lzo.rs b/crates/exav-unpack/src/formats/lzo.rs new file mode 100644 index 00000000..1b057af4 --- /dev/null +++ b/crates/exav-unpack/src/formats/lzo.rs @@ -0,0 +1,267 @@ +//! LZO1X decompression, the codec of the squashfs images of routers and set-top +//! boxes. Safe on any input: every read and every copy is bounds-checked, and +//! the output has a ceiling. +//! +//! The stream is a run of instructions, each a literal run or a match, the low +//! two bits of the last byte of a match saying how many literals (0 to 3) follow +//! it. A match's distance and length are in its opcode and the bytes after, in +//! one of four layouts by the opcode's size, and the stream ends with the match +//! of the longest distance and no distance at all (`11 00 00`). + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] + +/// What the decoder does next, the places the format's instructions lead to. +#[derive(Clone, Copy)] +enum Next { + /// An instruction that is a literal run, or a match. + Instruction, + /// The instruction after a literal run: a match, or one that is the short + /// match of a run of four literals or more. + AfterRun, + /// A match, its opcode being `t`. + Match, +} + +/// The decoder: the input and where it is, the output and its ceiling. +struct Lzo<'a> { + src: &'a [u8], + at: usize, + out: Vec<u8>, + max: usize, +} + +impl Lzo<'_> { + fn byte(&mut self) -> Result<usize, &'static str> { + let b = *self + .src + .get(self.at) + .ok_or("LZO input ends inside an instruction")?; + self.at = self.at.saturating_add(1); + Ok(usize::from(b)) + } + + /// A length that goes on in bytes of 255 while they are zero: the zeros, + /// then the last byte, and `base` besides. + fn extended(&mut self, base: usize) -> Result<usize, &'static str> { + let mut n = base; + loop { + let b = self.byte()?; + if b != 0 { + return Ok(n.saturating_add(b)); + } + n = n.saturating_add(255); + } + } + + fn literals(&mut self, n: usize) -> Result<(), &'static str> { + let end = self.at.checked_add(n).ok_or("LZO literal run overflows")?; + let lits = self + .src + .get(self.at..end) + .ok_or("LZO literal run goes past the input")?; + if self.out.len().saturating_add(n) > self.max { + return Err("LZO output exceeds the size budget"); + } + self.out.extend_from_slice(lits); + self.at = end; + Ok(()) + } + + /// `len` bytes from `dist` back, which may overlap what is being written. + fn copy(&mut self, dist: usize, len: usize) -> Result<(), &'static str> { + if dist == 0 || dist > self.out.len() { + return Err("LZO match points before the start of the output"); + } + if self.out.len().saturating_add(len) > self.max { + return Err("LZO output exceeds the size budget"); + } + let start = self.out.len().saturating_sub(dist); + for k in 0..len { + let b = self.out[start.saturating_add(k)]; + self.out.push(b); + } + Ok(()) + } +} + +/// Decompress an LZO1X stream of at most `max` bytes of output. +pub fn decompress(src: &[u8], max: usize) -> Result<Vec<u8>, &'static str> { + let mut d = Lzo { + src, + at: 0, + out: Vec::new(), + max, + }; + let mut next = Next::Instruction; + let mut t = 0usize; + // A first byte above 17 is a literal run of that many less 17. + if let Some(&first) = src.first().filter(|&&b| b > 17) { + d.at = 1; + t = usize::from(first).saturating_sub(17); + d.literals(t)?; + if t < 4 { + // The literals of a match that has not been read: its instruction is next. + t = d.byte()?; + next = Next::Match; + } else { + next = Next::AfterRun; + } + } + loop { + // How many literals follow the match just read. + let trail: usize; + match next { + Next::Instruction => { + if d.at >= src.len() { + return Ok(d.out); + } + t = d.byte()?; + if t >= 16 { + next = Next::Match; + continue; + } + if t == 0 { + t = d.extended(15)?; + } + d.literals(t.saturating_add(3))?; + next = Next::AfterRun; + continue; + } + Next::AfterRun => { + t = d.byte()?; + if t >= 16 { + next = Next::Match; + continue; + } + // A match of three bytes, far back, that follows a run of literals. + let dist = 1usize + .saturating_add(0x0800) + .saturating_add(t >> 2) + .saturating_add(d.byte()?.saturating_mul(4)); + d.copy(dist, 3)?; + trail = t & 3; + } + Next::Match => { + if t >= 64 { + let dist = 1usize + .saturating_add((t >> 2) & 7) + .saturating_add(d.byte()?.saturating_mul(8)); + d.copy(dist, (t >> 5).saturating_add(1))?; + trail = t & 3; + } else if t >= 32 { + let mut len = t & 31; + if len == 0 { + len = d.extended(31)?; + } + let (a, b) = (d.byte()?, d.byte()?); + let dist = 1usize + .saturating_add(a >> 2) + .saturating_add(b.saturating_mul(64)); + d.copy(dist, len.saturating_add(2))?; + trail = a & 3; + } else if t >= 16 { + let mut len = t & 7; + if len == 0 { + len = d.extended(7)?; + } + let (a, b) = (d.byte()?, d.byte()?); + let far = (t & 8).saturating_mul(2048); + let dist = far + .saturating_add(a >> 2) + .saturating_add(b.saturating_mul(64)); + // The longest distance, no distance: the end. + if dist == 0 { + return Ok(d.out); + } + d.copy(dist.saturating_add(0x4000), len.saturating_add(2))?; + trail = a & 3; + } else { + // Two bytes, near, after literals of one to three. + let dist = 1usize + .saturating_add(t >> 2) + .saturating_add(d.byte()?.saturating_mul(4)); + d.copy(dist, 2)?; + trail = t & 3; + } + } + } + if trail == 0 { + next = Next::Instruction; + } else { + d.literals(trail)?; + t = d.byte()?; + next = Next::Match; + } + } +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::*; + + #[test] + fn a_stream_of_literals_alone_ends_at_its_marker() { + // 17 + 5: five literals, then the end marker. + let mut s = vec![22]; + s.extend_from_slice(b"hello"); + s.extend_from_slice(&[0x11, 0, 0]); + assert_eq!(decompress(&s, 100).unwrap(), b"hello"); + } + + #[test] + fn a_long_run_of_literals_and_a_match_over_itself() { + // Literals "abc", then an M3 match: 001 00110 (length 6 + 2), and + // distance 1 (0 << 2, 0) with no literals after; then the end. + let mut s = vec![17 + 3]; + s.extend_from_slice(b"abc"); + s.extend_from_slice(&[0x20 | 6, 0, 0]); + s.extend_from_slice(&[0x11, 0, 0]); + assert_eq!(decompress(&s, 100).unwrap(), b"abccccccccc"); + } + + #[test] + fn the_output_has_a_ceiling_and_the_input_is_not_trusted() { + let mut s = vec![17 + 5]; + s.extend_from_slice(b"hello"); + s.extend_from_slice(&[0x11, 0, 0]); + assert!(decompress(&s, 4).is_err()); + // Ends inside a literal run, a match, and with no marker. + assert!(decompress(&[22, b'h', b'e'], 100).is_err()); + assert!(decompress(&[22, b'h', b'e', b'l', b'l', b'o', 0x20], 100).is_err()); + // A match before the start. + assert!(decompress(&[17 + 4, b'a', b'b', b'c', b'd', 0x20 | 3, 0xFC, 0xFF], 100).is_err()); + assert!(decompress(&[], 100).unwrap().is_empty()); + } + + /// The first block `lzop` makes of a text, at its levels 1 (LZO1X-1) and 9 + /// (LZO1X-999): the instructions of both encoders, near and far matches and + /// long runs of literals, put to one decoder (`fuzz/seeds/lzo/make.py`). + #[test] + fn the_blocks_lzop_makes_are_decoded_to_the_text() { + let mut text = Vec::new(); + for _ in 0..4 { + text.extend_from_slice(&b"the quick brown fox jumps over the lazy dog. ".repeat(40)); + for _ in 0..3 { + text.extend((0..=255u8).collect::<Vec<_>>()); + } + } + for level in [1, 9] { + let p = format!( + "{}/tests/fixtures/fs/lzo_text_{level}.bin", + env!("CARGO_MANIFEST_DIR") + ); + let block = crate::read_fixture(&p).unwrap(); + assert_eq!( + decompress(&block, text.len()).unwrap(), + text, + "level {level}" + ); + // One byte of room less than the text. + assert!(decompress(&block, text.len() - 1).is_err(), "level {level}"); + } + } +} diff --git a/crates/exav-unpack/src/formats/lzw.rs b/crates/exav-unpack/src/formats/lzw.rs index 3ad4d964..042efe70 100644 --- a/crates/exav-unpack/src/formats/lzw.rs +++ b/crates/exav-unpack/src/formats/lzw.rs @@ -168,12 +168,12 @@ impl<B: Bytes> Lzw<B> { // Define the next table entry from the previous code plus this // string's first byte. - if let Some(p) = self.prev { - if (self.next as usize) < table_cap { - self.prefix[self.next as usize] = p; - self.suffix[self.next as usize] = *self.stack.last().unwrap_or(&0); - self.next += 1; - } + if let Some(p) = self.prev + && (self.next as usize) < table_cap + { + self.prefix[self.next as usize] = p; + self.suffix[self.next as usize] = *self.stack.last().unwrap_or(&0); + self.next += 1; } self.prev = Some(code); return Some(true); diff --git a/crates/exav-unpack/src/formats/machofat.rs b/crates/exav-unpack/src/formats/machofat.rs index 1068dd97..1f0aecf6 100644 --- a/crates/exav-unpack/src/formats/machofat.rs +++ b/crates/exav-unpack/src/formats/machofat.rs @@ -195,9 +195,11 @@ mod tests { assert_eq!(entries[0].name, "macho-arch-0"); assert_eq!(entries[0].data, b"first-arch-image"); assert_eq!(entries[1].name, "macho-arch-1"); - assert!(entries - .iter() - .any(|e| e.data.windows(11).any(|w| w == b"MALWARETEST"))); + assert!( + entries + .iter() + .any(|e| e.data.windows(11).any(|w| w == b"MALWARETEST")) + ); } #[test] @@ -209,25 +211,31 @@ mod tests { blob.extend_from_slice(&[0xABu8; 200]); // constant pool: not valid arch records assert!(!looks_like_machofat(&crate::Probe::whole(&blob))); let mut budget = Budget::new(Limits::default()); - assert!(extract(Format::Machofat, &blob, &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Machofat, &blob, &mut budget) + .unwrap() + .is_empty() + ); } #[test] fn truncated_and_garbage_do_not_panic() { let mut budget = Budget::new(Limits::default()); // Too short for even the header. - assert!(extract(Format::Machofat, b"\xCA\xFE", &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Machofat, b"\xCA\xFE", &mut budget) + .unwrap() + .is_empty() + ); // Valid magic + count but a truncated arch table. let mut blob = FAT_MAGIC.to_be_bytes().to_vec(); blob.extend_from_slice(&2u32.to_be_bytes()); blob.extend_from_slice(&[0u8; 10]); // < 2 * 20 bytes of records - assert!(extract(Format::Machofat, &blob, &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Machofat, &blob, &mut budget) + .unwrap() + .is_empty() + ); // Arch offset/size pointing past EOF must be rejected, not sliced. let mut blob = FAT_MAGIC.to_be_bytes().to_vec(); blob.extend_from_slice(&1u32.to_be_bytes()); @@ -236,8 +244,10 @@ mod tests { blob.extend_from_slice(&0xFFFF_FFFFu32.to_be_bytes()); // offset past EOF blob.extend_from_slice(&0xFFFF_FFFFu32.to_be_bytes()); // size past EOF blob.extend_from_slice(&0u32.to_be_bytes()); // align - assert!(extract(Format::Machofat, &blob, &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Machofat, &blob, &mut budget) + .unwrap() + .is_empty() + ); } } diff --git a/crates/exav-unpack/src/formats/mod.rs b/crates/exav-unpack/src/formats/mod.rs index 6926a78f..94179ae4 100644 --- a/crates/exav-unpack/src/formats/mod.rs +++ b/crates/exav-unpack/src/formats/mod.rs @@ -2,6 +2,8 @@ //! extractor entry point are gated behind the matching Cargo feature (see the //! crate's `[features]`); a disabled format simply isn't compiled in. +#[cfg(feature = "dmg")] +mod apfs; #[cfg(feature = "ar")] pub(crate) mod ar; #[cfg(feature = "arj")] @@ -23,16 +25,27 @@ mod bzip2_rs; #[cfg(feature = "cab")] pub(crate) mod cab; #[cfg(feature = "cab")] +pub(crate) mod cab_join; +#[cfg(feature = "cab")] mod cab_parse; #[cfg(feature = "chm")] mod chm; #[cfg(feature = "cpio")] pub(crate) mod cpio; #[cfg(feature = "dmg")] +mod decmpfs; +#[cfg(feature = "dmg")] pub(crate) mod dmg; +#[cfg(feature = "dmg")] +mod hfsplus; // Code pages of AutoCAD drawings, which DXF and DWG strings are in. #[cfg(any(feature = "dxf", feature = "dwg"))] mod codepage; +// Text in a Windows code page number. +#[cfg(feature = "ole")] +pub mod charset; +#[cfg(feature = "ole")] +mod charset_tables; #[cfg(feature = "dwg")] pub mod dwg; #[cfg(feature = "dxf")] @@ -49,9 +62,18 @@ pub(crate) mod iso; pub(crate) mod lha; #[cfg(feature = "lzip")] pub(crate) mod lzip; +#[cfg(feature = "lzma")] +pub(crate) mod lzmaalone; +#[cfg(feature = "rpm")] +pub(crate) mod rpm; // Every feature that enables `lzma-rust2`. +#[cfg(feature = "ole")] +pub mod biff; +#[cfg(feature = "ole")] +pub mod cfb; #[cfg(any( feature = "lzip", + feature = "lzma", feature = "sevenz", feature = "swf", feature = "nsis", @@ -59,18 +81,18 @@ pub(crate) mod lzip; feature = "upx" ))] pub(crate) mod lzma; +#[cfg(feature = "offcrypto")] +pub mod offcrypto; #[cfg(feature = "ole")] mod ole; #[cfg(all(feature = "ole", feature = "decrypt"))] mod ole_crypto; #[cfg(feature = "pdf")] -mod pdf; +pub(crate) mod pdf; #[cfg(feature = "pdf")] -mod pdf_parse; +pub(crate) mod pdf_parse; #[cfg(feature = "ole")] mod xlm; -#[cfg(feature = "ole")] -mod xlm_functions; #[cfg(feature = "zstd")] pub(crate) mod zstd; // Shared PPMd7 model: used by both RAR (RAR3 PPMd blocks) and 7-Zip. @@ -90,6 +112,14 @@ pub(crate) mod azo; pub(crate) mod azo_tables; #[cfg(feature = "binhex")] mod binhex; +#[cfg(feature = "cramfs")] +pub(crate) mod cramfs; +#[cfg(feature = "ishieldz")] +mod dcl_explode; +#[cfg(feature = "erofs")] +pub(crate) mod erofs; +#[cfg(feature = "exfat")] +pub(crate) mod exfat; #[cfg(feature = "ext")] pub(crate) mod ext; #[cfg(feature = "fat")] @@ -104,6 +134,10 @@ pub(crate) mod ishield_z; mod lnk; #[cfg(feature = "lz4")] pub(crate) mod lz4; +#[cfg(feature = "dmg")] +mod lzfse; +#[cfg(feature = "squashfs")] +pub(crate) mod lzo; #[cfg(feature = "lzw")] pub(crate) mod lzw; #[cfg(feature = "machofat")] @@ -134,6 +168,10 @@ mod rar3_unpack; mod rar5_crypt; #[cfg(feature = "rar")] mod rar5_unpack; +#[cfg(feature = "romfs")] +pub(crate) mod romfs; +#[cfg(feature = "squashfs")] +pub(crate) mod squashfs; /// Passwords tried on an encrypted archive member after the caller's: the /// conventions of malware distribution. A password-protected dropper is a @@ -159,21 +197,59 @@ mod udif; mod upx; #[cfg(feature = "ole")] mod vba; +// Virtual disks read in place, and the partitions and filesystems walked on +// them: shared by every format that has a disk in it. +#[cfg(any(feature = "vhd", feature = "diskimage"))] +pub(crate) mod vdisk; #[cfg(feature = "vhd")] pub(crate) mod vhd; #[cfg(feature = "diskimage")] pub(crate) mod vhdx; #[cfg(feature = "diskimage")] pub(crate) mod vmdk; +#[cfg(any( + feature = "vhd", + feature = "diskimage", + feature = "partition", + feature = "ntfs", + feature = "fat", + feature = "exfat", + feature = "squashfs", + feature = "cramfs", + feature = "romfs", + feature = "erofs", + feature = "ext", + feature = "dmg" +))] +pub(crate) mod volumes; +// The volume the filesystems read in place are read through, and what they +// hand their files over as, which the walk of a disk takes from them. +#[cfg(any( + feature = "vhd", + feature = "diskimage", + feature = "partition", + feature = "ntfs", + feature = "dmg", + feature = "ext", + feature = "fat", + feature = "exfat", + feature = "squashfs", + feature = "cramfs", + feature = "romfs", + feature = "erofs" +))] +mod vol; #[cfg(feature = "wim")] pub(crate) mod wim; #[cfg(feature = "xar")] -mod xar; +pub(crate) mod xar; // DMG's xz runs use its `XzReader` only. #[cfg(any(feature = "xz", feature = "dmg"))] #[cfg_attr(not(feature = "xz"), allow(dead_code))] #[doc(hidden)] pub mod xz; +#[cfg(any(feature = "xz", feature = "dmg"))] +mod xz_nocheck; #[cfg(feature = "zip")] #[doc(hidden)] pub mod zip; @@ -195,7 +271,7 @@ pub(crate) mod pepack; #[cfg(feature = "pyc")] pub(crate) mod pyc; #[cfg(feature = "rtf")] -mod rtf; +pub mod rtf; #[cfg(feature = "screnc")] mod screnc; #[cfg(feature = "sfx")] @@ -217,7 +293,7 @@ pub(crate) use aimodel::{extract_aimodel, is_aimodel}; #[cfg(feature = "arj")] pub(crate) use arj::extract_arj; #[cfg(feature = "autoit")] -pub(crate) use autoit::{extract_autoit, is_autoit, MARKER_EA05, MARKER_EA06}; +pub(crate) use autoit::{MARKER_EA05, MARKER_EA06, extract_autoit, is_autoit}; #[cfg(feature = "binhex")] pub(crate) use binhex::{extract_binhex, looks_like_binhex}; #[cfg(feature = "chm")] @@ -233,7 +309,7 @@ pub(crate) use lnk::extract_lnk; #[cfg(feature = "machofat")] pub(crate) use machofat::looks_like_machofat; #[cfg(feature = "nsis")] -pub(crate) use nsis::{extract_nsis, is_nsis, NSIS_SIG}; +pub(crate) use nsis::{NSIS_SIG, extract_nsis, is_nsis}; #[cfg(feature = "ole")] pub(crate) use ole::extract_ole; #[cfg(feature = "onenote")] @@ -253,7 +329,7 @@ pub(crate) use rar::{extract_rar, join_volumes as join_rar_volumes}; #[cfg(feature = "rtf")] pub(crate) use rtf::extract_rtf; #[cfg(feature = "screnc")] -pub(crate) use screnc::{extract_screnc, looks_like_screnc, MARKER as SCRENC_MARKER}; +pub(crate) use screnc::{MARKER as SCRENC_MARKER, extract_screnc, looks_like_screnc}; #[cfg(feature = "sfx")] pub(crate) use sfx::looks_like_sfx; #[cfg(feature = "szdd")] @@ -262,8 +338,6 @@ pub(crate) use szdd::{extract_szdd, is_szdd}; pub(crate) use upx::{extract_upx, find_packheader, has_packheader_layout}; #[cfg(feature = "uuencode")] pub(crate) use uuencode::{extract_uuencode, looks_like_uuencode}; -#[cfg(feature = "xar")] -pub(crate) use xar::extract_xar; #[cfg(feature = "xdp")] pub(crate) use xdp::{extract_xdp, looks_like_xdp}; #[cfg(feature = "zip")] diff --git a/crates/exav-unpack/src/formats/nsis.rs b/crates/exav-unpack/src/formats/nsis.rs index bcd7d796..2c20555a 100644 --- a/crates/exav-unpack/src/formats/nsis.rs +++ b/crates/exav-unpack/src/formats/nsis.rs @@ -35,7 +35,6 @@ pub(crate) const NSIS_SIG: [u8; 16] = [ const FIRSTHEADER_LEN: usize = 0x1c; /// `firstheader.flags`: the installer carries no CRC-32 after its data. const FH_FLAGS_NO_CRC: u32 = 4; -const MAX_BLOCKS: usize = 10_000; const SOLID_MIN_OUTPUT: usize = 64; #[inline] @@ -102,12 +101,16 @@ pub(crate) fn extract_nsis<R>( // Non-solid: a `[u32 size][data]` block sequence. let mut pos = data_start; let mut n = 0usize; - while pos + 4 <= arch_end && n < MAX_BLOCKS { + // Each block is counted against the archive's member limit, an empty one + // too, and that is the bound on the walk: it ends in a limit that is said, + // where a count of its own ended it quietly with blocks left. + while pos + 4 <= arch_end { let word = u32le(data, pos); pos += 4; let compressed = word & 0x8000_0000 != 0; let size = (word & 0x7fff_ffff) as usize; if size == 0 { + budget.count_entry()?; n += 1; continue; } @@ -237,7 +240,7 @@ mod tests { fn is_nsis(data: &[u8]) -> bool { super::is_nsis(&crate::Probe::whole(data)) } - use flate2::{write::DeflateEncoder, Compression}; + use flate2::{Compression, write::DeflateEncoder}; use std::io::Write; /// A declared dictionary size is attacker input and is allocated UP FRONT. @@ -314,9 +317,11 @@ mod tests { entries.iter().all(|e| e.unsupported.is_none()), "{entries:?}" ); - assert!(entries - .iter() - .any(|e| e.data.windows(11).any(|w| w == b"MALWARETEST"))); + assert!( + entries + .iter() + .any(|e| e.data.windows(11).any(|w| w == b"MALWARETEST")) + ); } /// The CRC-32 after the data is not one more block. @@ -342,9 +347,11 @@ mod tests { assert_eq!(detect(&blob), Some(Format::Nsis)); let mut budget = Budget::new(Limits::default()); let entries = extract(Format::Nsis, &blob, &mut budget).unwrap(); - assert!(entries - .iter() - .any(|e| e.data.windows(11).any(|w| w == b"MALWARETEST"))); + assert!( + entries + .iter() + .any(|e| e.data.windows(11).any(|w| w == b"MALWARETEST")) + ); } #[test] @@ -353,9 +360,11 @@ mod tests { blob.extend_from_slice(&[0u8; 512]); assert!(!is_nsis(&blob)); let mut budget = Budget::new(Limits::default()); - assert!(extract(Format::Nsis, &blob, &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Nsis, &blob, &mut budget) + .unwrap() + .is_empty() + ); } #[test] @@ -364,9 +373,11 @@ mod tests { blob.extend_from_slice(&[0u8; 8]); blob.extend_from_slice(&NSIS_SIG); let mut budget = Budget::new(Limits::default()); - assert!(extract(Format::Nsis, &blob, &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Nsis, &blob, &mut budget) + .unwrap() + .is_empty() + ); } #[test] @@ -376,9 +387,11 @@ mod tests { let blob = synthetic_nsis(&junk); let mut budget = Budget::new(Limits::default()); let entries = extract(Format::Nsis, &blob, &mut budget).unwrap(); - assert!(entries - .iter() - .all(|e| e.unsupported.is_some() || !e.data.is_empty())); + assert!( + entries + .iter() + .all(|e| e.unsupported.is_some() || !e.data.is_empty()) + ); } #[test] diff --git a/crates/exav-unpack/src/formats/ntfs.rs b/crates/exav-unpack/src/formats/ntfs.rs index ea51b90c..76bbe44a 100644 --- a/crates/exav-unpack/src/formats/ntfs.rs +++ b/crates/exav-unpack/src/formats/ntfs.rs @@ -41,12 +41,22 @@ //! the size the record declares, the shortfall is reported *and* the bytes that //! were read are still handed over, rather than a prefix being passed off as the //! whole file. +//! +//! The volume is a `Read + Seek`, read where the walk goes: the boot sector, the +//! MFT record by record through `$MFT`'s own runs, and the runs of each file. +//! Only a file is ever held, bounded by the buffer limit. // Every sum and product on a header's number is checked or saturating here; a // plain one fails the build, so the next edit cannot add the unchecked kind. #![deny(clippy::arithmetic_side_effects)] -use crate::{Budget, Entry, LimitHit, Sink}; +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; + +use super::vol::{Attrs, Emit, Found, Next, Piece, Pieces, Plan, Vol, hand, read_file}; +use crate::source::{ByteSource, Reader}; +use crate::stream::Visit; +use crate::{Budget, Entry, LimitHit}; /// The OEM name every NTFS boot sector carries at offset 3. /// Every MFT record starts with this. @@ -177,6 +187,7 @@ fn apply_fixups(rec: &mut [u8], sector: usize) -> bool { /// One extent of a non-resident attribute: `lcn` is `None` for a sparse run, /// which reads as zeroes. +#[derive(Clone)] struct Run { lcn: Option<i64>, clusters: u64, @@ -240,8 +251,41 @@ fn data_runs(d: &[u8]) -> Vec<Run> { runs } -/// Read a non-resident attribute's bytes by following its runs. -fn read_runs(volume: &[u8], runs: &[Run], cluster: usize, real_size: usize) -> Vec<u8> { +/// The stretches of a non-resident attribute: each run in order, a sparse one +/// zeros. +struct Runs { + runs: std::vec::IntoIter<Run>, + cluster: u64, +} + +impl Plan for Runs { + fn next(&mut self, _: &mut Vol) -> Next { + let Some(r) = self.runs.next() else { + return Next::End; + }; + let len = r.clusters.saturating_mul(self.cluster); + match r.lcn { + None => Next::Piece(Piece::Zeros(len)), + Some(lcn) => match u64::try_from(lcn) + .ok() + .and_then(|l| l.checked_mul(self.cluster)) + { + Some(at) => Next::Piece(Piece::Disk(at, len)), + None => Next::Broken, + }, + } + } +} + +/// `visit`'s member `e`, held whole. +fn held<R>(visit: Emit<R>, e: Entry, budget: &mut Budget) -> Option<R> { + visit(Found::Held(e), budget) +} + +/// Read a non-resident attribute's bytes by following its runs. Short where +/// the runs stop being readable, and never longer than `real_size`, which the +/// caller has bounded: a sparse run is zeros held in memory. +fn read_runs(v: &mut Vol, runs: &[Run], cluster: usize, real_size: usize) -> Vec<u8> { let mut out = Vec::with_capacity(real_size.min(1 << 20)); for r in runs { if out.len() >= real_size { @@ -254,12 +298,11 @@ fn read_runs(volume: &[u8], runs: &[Run], cluster: usize, real_size: usize) -> V // victim's machine too. None => out.resize(out.len().saturating_add(take), 0), Some(lcn) if lcn >= 0 => { - let start = crate::bytes::to_usize(lcn as u64).saturating_mul(cluster); - match crate::bytes::at(volume, start, take) { - Some(s) => out.extend_from_slice(s), - // Past the end of the image: those bytes are absent rather - // than hidden, so what exists still stands. - None => break, + let start = (lcn as u64).saturating_mul(cluster as u64); + // Past the end of the image: those bytes are absent rather + // than hidden, so what exists still stands. + if !v.append(start, take, &mut out) { + break; } } Some(_) => break, @@ -269,6 +312,116 @@ fn read_runs(volume: &[u8], runs: &[Run], cluster: usize, real_size: usize) -> V out } +/// `len` bytes at `offset` of the attribute the runs describe, appended to +/// `out`. False when any of them is not readable. +fn read_runs_at( + v: &mut Vol, + runs: &[Run], + cluster: usize, + offset: u64, + len: usize, + out: &mut Vec<u8>, +) -> bool { + let Some(end) = offset.checked_add(len as u64) else { + return false; + }; + let cl = cluster as u64; + let mut pos = 0u64; + for r in runs { + if pos >= end { + break; + } + let run_end = pos.saturating_add(r.clusters.saturating_mul(cl)); + if run_end > offset { + let from = offset.max(pos); + let take = crate::bytes::to_usize(end.min(run_end).saturating_sub(from)); + match r.lcn { + None => out.resize(out.len().saturating_add(take), 0), + Some(lcn) if lcn >= 0 => { + let host = (lcn as u64) + .saturating_mul(cl) + .saturating_add(from.saturating_sub(pos)); + if !v.append(host, take, out) { + return false; + } + } + Some(_) => return false, + } + } + pos = run_end; + } + pos >= end +} + +/// `$MFT`, read a record at a time through its own runs: it can be +/// fragmented, so it cannot simply be read from its first cluster. +struct Mft { + runs: Vec<Run>, + /// Bytes of the table that are readable from its start. + size: u64, +} + +impl Mft { + /// The table `rec0`, record 0, describes. + fn open(rec0: &[u8], v: &mut Vol, geo: &Geometry) -> Option<Mft> { + let mut off = le_u16(rec0, 20) as usize; + while off.saturating_add(8) <= rec0.len() { + let atype = le_u32(rec0, off); + if atype == ATTR_END { + break; + } + let alen = le_u32(rec0, off.saturating_add(4)) as usize; + if alen < 16 || alen > rec0.len().saturating_sub(off) { + break; + } + if atype == ATTR_DATA && rec0.get(off.saturating_add(8)).copied().unwrap_or(0) != 0 { + let real_size = le_u64(rec0, off.saturating_add(0x30)); + let runs_off = off.saturating_add(le_u16(rec0, off.saturating_add(0x20)) as usize); + let runs = data_runs(rec0.get(runs_off..off.saturating_add(alen)).unwrap_or(&[])); + // The table cannot be larger than the volume it is in. It is + // only as long as its runs can be read. + let limit = real_size.min(v.len); + let cl = geo.cluster as u64; + let mut size = 0u64; + for r in &runs { + if size >= limit { + break; + } + let take = r + .clusters + .saturating_mul(cl) + .min(limit.saturating_sub(size)); + match r.lcn { + None => size = size.saturating_add(take), + Some(lcn) if lcn >= 0 => { + let start = (lcn as u64).saturating_mul(cl); + if start.checked_add(take).is_none_or(|e| e > v.len) { + break; + } + size = size.saturating_add(take); + } + Some(_) => break, + } + } + return (size > 0).then_some(Mft { runs, size }); + } + off = off.saturating_add(alen); + } + None + } + + /// Record `n`, as stored (the fix-ups not undone). + fn record(&self, v: &mut Vol, geo: &Geometry, n: u64) -> Option<Vec<u8>> { + let size = geo.record_size as u64; + let at = n.checked_mul(size)?; + if at.checked_add(size)? > self.size { + return None; + } + let mut out = Vec::with_capacity(geo.record_size); + read_runs_at(v, &self.runs, geo.cluster, at, geo.record_size, &mut out).then_some(out) + } +} + /// Every extent of the unnamed `$DATA`, gathered across MFT records. /// /// A file fragmented past what one record can describe has its run list split @@ -278,81 +431,120 @@ fn read_runs(volume: &[u8], runs: &[Run], cluster: usize, real_size: usize) -> V /// gets read. Which is exactly the kind of gap worth closing rather than /// reporting. fn data_extents( - rec: &[u8], - volume: &[u8], - mft: &[u8], + list: &[u8], + v: &mut Vol, + mft: &Mft, geo: &Geometry, sector: usize, -) -> Option<(Vec<Run>, usize)> { - // The list itself is an attribute, resident or not. - let list = find_attribute_list(rec, volume, geo)?; +) -> Result<Option<(Vec<Run>, usize)>, TooManyExtents> { let mut refs: Vec<(u64, u64)> = Vec::new(); // (starting VCN, MFT record) let mut i = 0usize; while i.saturating_add(26) <= list.len() { - let atype = le_u32(&list, i); - let entry_len = le_u16(&list, i.saturating_add(4)) as usize; + let atype = le_u32(list, i); + let entry_len = le_u16(list, i.saturating_add(4)) as usize; if entry_len < 26 { break; } let name_len = list.get(i.saturating_add(6)).copied().unwrap_or(0); // Only the unnamed `$DATA`; a named one is an alternate stream. if atype == ATTR_DATA && name_len == 0 { - let vcn = le_u64(&list, i.saturating_add(8)); + // More references than any file has: a list made to cost, which is + // not followed (the caller reports the file as not fully read). + if refs.len() >= MAX_LIST_REFS { + return Err(TooManyExtents); + } + let vcn = le_u64(list, i.saturating_add(8)); // The low 48 bits of the file reference are the record number. - let record = le_u64(&list, i.saturating_add(16)) & RECORD_NUMBER_MASK; + let record = le_u64(list, i.saturating_add(16)) & RECORD_NUMBER_MASK; refs.push((vcn, record)); } i = i.saturating_add(entry_len); } if refs.is_empty() { - return None; + return Ok(None); } + // An extent starts at one VCN, named once; the first of the entries that + // name it (the sort is stable) is the one. refs.sort_by_key(|&(vcn, _)| vcn); + refs.dedup_by_key(|&mut (vcn, _)| vcn); + // The extents each record holds, found once however many entries name + // the record: reading it for each entry costs the record's size each. + type Extents = HashMap<u64, (usize, Vec<Run>)>; + let mut records: HashMap<u64, Option<Extents>> = HashMap::new(); let mut runs: Vec<Run> = Vec::new(); let mut real_size = 0usize; for (vcn, record) in refs { - let at = crate::bytes::to_usize(record).checked_mul(geo.record_size)?; - let Some(slice) = crate::bytes::at(mft, at, geo.record_size) else { + let held = records.entry(record).or_insert_with(|| { + let mut r = mft.record(v, geo, record)?; + if r.get(0..4) != Some(RECORD_MAGIC.as_slice()) || !apply_fixups(&mut r, sector) { + return None; + } + Some(extents_of(&r)) + }); + let Some((size, extent)) = held.as_ref().and_then(|h| h.get(&vcn)) else { continue; }; - let mut r = slice.to_vec(); - if r.get(0..4) != Some(RECORD_MAGIC.as_slice()) || !apply_fixups(&mut r, sector) { - continue; + // Only the extent starting at VCN 0 carries the true size. + if vcn == 0 { + real_size = *size; } - let mut off = le_u16(&r, 20) as usize; - while off.saturating_add(8) <= r.len() { - let atype = le_u32(&r, off); - if atype == ATTR_END { - break; - } - let alen = le_u32(&r, off.saturating_add(4)) as usize; - if alen < 16 || alen > r.len().saturating_sub(off) { - break; - } - let non_resident = r.get(off.saturating_add(8)).copied().unwrap_or(0) != 0; - if atype == ATTR_DATA - && r.get(off.saturating_add(9)).copied().unwrap_or(0) == 0 - && non_resident - && le_u64(&r, off.saturating_add(16)) == vcn - { - // Only the extent starting at VCN 0 carries the true size. - if vcn == 0 { - real_size = crate::bytes::to_usize(le_u64(&r, off.saturating_add(0x30))); - } - let runs_off = off.saturating_add(le_u16(&r, off.saturating_add(0x20)) as usize); - runs.extend(data_runs( - r.get(runs_off..off.saturating_add(alen)).unwrap_or(&[]), - )); - break; - } - off = off.saturating_add(alen); + // Each record can hold thousands of runs, and a list names thousands + // of records: the runs of one file are bounded in all. + if runs.len().saturating_add(extent.len()) > MAX_FILE_RUNS { + return Err(TooManyExtents); } + runs.extend(extent.iter().cloned()); } if runs.is_empty() || real_size == 0 { - return None; + return Ok(None); + } + Ok(Some((runs, real_size))) +} + +/// The most `$DATA` entries of one attribute list that are followed. A file +/// with its runs in this many records has more than any volume this reads holds. +const MAX_LIST_REFS: usize = 1 << 16; + +/// The most data runs of one file followed, over all its extents: a million is +/// a file in a million fragments, and 24 MB of runs. +const MAX_FILE_RUNS: usize = 1 << 20; + +/// An attribute list that names more extents than [`MAX_LIST_REFS`], or more +/// runs than [`MAX_FILE_RUNS`]. +struct TooManyExtents; + +/// The unnamed non-resident `$DATA` attributes of an MFT record by the VCN each +/// starts at: its recorded size (the true one at VCN 0) and its runs. The first +/// attribute of a VCN is the one. +fn extents_of(r: &[u8]) -> HashMap<u64, (usize, Vec<Run>)> { + let mut found = HashMap::new(); + let mut off = le_u16(r, 20) as usize; + while off.saturating_add(8) <= r.len() { + let atype = le_u32(r, off); + if atype == ATTR_END { + break; + } + let alen = le_u32(r, off.saturating_add(4)) as usize; + if alen < 16 || alen > r.len().saturating_sub(off) { + break; + } + let non_resident = r.get(off.saturating_add(8)).copied().unwrap_or(0) != 0; + if atype == ATTR_DATA + && r.get(off.saturating_add(9)).copied().unwrap_or(0) == 0 + && non_resident + { + let vcn = le_u64(r, off.saturating_add(16)); + found.entry(vcn).or_insert_with(|| { + let size = crate::bytes::to_usize(le_u64(r, off.saturating_add(0x30))); + let runs_off = off.saturating_add(le_u16(r, off.saturating_add(0x20)) as usize); + let runs = data_runs(r.get(runs_off..off.saturating_add(alen)).unwrap_or(&[])); + (size, runs) + }); + } + off = off.saturating_add(alen); } - Some((runs, real_size)) + found } /// The `$ATTRIBUTE_LIST` value. @@ -362,7 +554,7 @@ fn data_extents( /// So the non-resident form is followed too, by walking its own data runs. /// Stopping at the resident case would leave the most fragmented files, which is /// to say the ones someone took the trouble to fragment, unread. -fn find_attribute_list(rec: &[u8], volume: &[u8], geo: &Geometry) -> Option<Vec<u8>> { +fn find_attribute_list(rec: &[u8], v: &mut Vol, geo: &Geometry) -> Option<Vec<u8>> { let mut off = le_u16(rec, 20) as usize; while off.saturating_add(8) <= rec.len() { let atype = le_u32(rec, off); @@ -385,7 +577,8 @@ fn find_attribute_list(rec: &[u8], volume: &[u8], geo: &Geometry) -> Option<Vec< let runs = data_runs(rec.get(runs_off..off.saturating_add(alen)).unwrap_or(&[])); // No table of this kind is larger than the volume, and a sparse run // costs memory for every zero it reads. - let bytes = read_runs(volume, &runs, geo.cluster, real_size.min(volume.len())); + let bound = crate::bytes::to_usize(v.len).min(v.max); + let bytes = read_runs(v, &runs, geo.cluster, real_size.min(bound)); // A list read short would silently drop the extents it names, so it // is treated as unusable rather than half-used. return (bytes.len() == real_size && real_size > 0).then_some(bytes); @@ -402,13 +595,15 @@ struct File { /// Set when the bytes are present but exav could not read them. unreadable: Option<&'static str>, deleted: bool, + /// The runs and the size of a file too long to hold, to be read as it goes. + stream: Option<(Vec<Run>, u64)>, } /// Parse one MFT record into a file, or `None` when it holds nothing to scan. fn parse_record( rec: &[u8], - volume: &[u8], - mft: &[u8], + v: &mut Vol, + mft: &Mft, geo: &Geometry, sector: usize, cap: usize, @@ -429,7 +624,14 @@ fn parse_record( } // The name is in this record, or, for a file fragmented hard enough to // have an attribute list, in an extension record the list names. - let name = record_name(rec).or_else(|| name_from_extensions(rec, volume, mft, geo, sector)); + // The attribute list is read once, for the name and for the data. + let mut list: Option<Option<Vec<u8>>> = None; + let name = record_name(rec).or_else(|| { + let l = list + .get_or_insert_with(|| find_attribute_list(rec, v, geo)) + .as_deref()?; + name_from_extensions(l, v, mft, geo, sector) + }); let mut file: Option<File> = None; let mut off = le_u16(rec, 20) as usize; @@ -455,6 +657,7 @@ fn parse_record( data: None, unreadable: Some("EFS-encrypted NTFS file"), deleted, + stream: None, }); } else if !non_resident { let vlen = le_u32(rec, off.saturating_add(16)) as usize; @@ -465,14 +668,20 @@ fn parse_record( data: Some(bytes), unreadable: None, deleted, + stream: None, }); } else { // A file fragmented past what one record can hold has its runs // split across several, listed in `$ATTRIBUTE_LIST`. Gather them // all; falling back to this record alone would return the first // extent and call it the whole file. - let spread = data_extents(rec, volume, mft, geo, sector); - if le_u64(rec, off.saturating_add(16)) != 0 && spread.is_none() { + let spread = list + .get_or_insert_with(|| find_attribute_list(rec, v, geo)) + .as_deref() + .map_or(Ok(None), |l| data_extents(l, v, mft, geo, sector)); + let refused = spread.is_err(); + let spread = spread.ok().flatten(); + if refused || (le_u64(rec, off.saturating_add(16)) != 0 && spread.is_none()) { // Only this extent is reachable. Report the shortfall, but // still scan the part that is here. let real_size = crate::bytes::to_usize(le_u64(rec, off.saturating_add(0x30))); @@ -480,24 +689,28 @@ fn parse_record( off.saturating_add(le_u16(rec, off.saturating_add(0x20)) as usize); let runs = data_runs(rec.get(runs_off..off.saturating_add(alen)).unwrap_or(&[])); - let part = read_runs( - volume, - &runs, - geo.cluster, - real_size.clamp(1, volume.len().max(1)), - ); + let size = real_size.clamp(1, crate::bytes::to_usize(v.len).max(1)); + let part = if size > cap { + Vec::new() + } else { + read_runs(v, &runs, geo.cluster, size) + }; file = Some(File { name: String::new(), data: Some(part), - unreadable: Some( + unreadable: Some(if refused { + "NTFS file whose attribute list names more extents than exav \ + follows; the extent that could be read was scanned" + } else { "NTFS file whose data runs span MFT records exav could not follow; \ - the extent that could be read was scanned", - ), + the extent that could be read was scanned" + }), deleted, + stream: None, }); } else { let (runs, real_size) = match spread { - Some(v) => v, + Some(found) => found, None => { let real_size = crate::bytes::to_usize(le_u64(rec, off.saturating_add(0x30))); @@ -511,24 +724,43 @@ fn parse_record( ) } }; - if real_size > cap { + if real_size > cap && attr_flags & ATTR_COMPRESSED == 0 { + // Read as it goes, its holes zeros, where the volume + // gives it. Runs short of the declared size are + // reported before it, as for a file that is held. + let covered = runs.iter().fold(0u64, |n, r| { + n.saturating_add(r.clusters.saturating_mul(geo.cluster as u64)) + }); + file = Some(File { + name: String::new(), + data: None, + unreadable: (covered < real_size as u64).then_some( + "NTFS file whose data runs did not cover its declared size; \ + the part that could be read was scanned", + ), + deleted, + stream: Some((runs, real_size as u64)), + }); + } else if real_size > cap { file = Some(File { name: String::new(), data: None, unreadable: Some("NTFS file exceeds the per-member size budget"), deleted, + stream: None, }); } else if attr_flags & ATTR_COMPRESSED != 0 { // A compression unit is 2^4 clusters in practice. let unit = Some(le_u16(rec, off.saturating_add(0x22))) .filter(|&s| s <= 16) .and_then(|s| 1usize.checked_shl(s.into())) - .and_then(|n| n.checked_mul(geo.cluster)); + .and_then(|n| n.checked_mul(geo.cluster)) + .filter(|&n| n <= cap); // Stored compressed, so never more than the file's size // plus a unit; a sparse run is not read past that. let raw = match unit { Some(u) => { - read_runs(volume, &runs, geo.cluster, real_size.saturating_add(u)) + read_runs(v, &runs, geo.cluster, real_size.saturating_add(u)) } None => Vec::new(), }; @@ -538,12 +770,14 @@ fn parse_record( data: Some(d), unreadable: None, deleted, + stream: None, }, None => File { name: String::new(), data: None, unreadable: Some("NTFS-compressed file that would not decompress"), deleted, + stream: None, }, }); } else if attr_flags & ATTR_SPARSE != 0 || runs.iter().any(|r| r.lcn.is_none()) @@ -552,12 +786,13 @@ fn parse_record( // sees too, so this is still the file. file = Some(File { name: String::new(), - data: Some(read_runs(volume, &runs, geo.cluster, real_size)), + data: Some(read_runs(v, &runs, geo.cluster, real_size)), unreadable: None, deleted, + stream: None, }); } else { - let bytes = read_runs(volume, &runs, geo.cluster, real_size); + let bytes = read_runs(v, &runs, geo.cluster, real_size); // Short of the size the record declares. Passing that off // as the file would be a silent truncation, so it is // reported — *and* the bytes that were read are still @@ -573,6 +808,7 @@ fn parse_record( the part that could be read was scanned", ), deleted, + stream: None, }); } } @@ -623,12 +859,12 @@ fn record_name(rec: &[u8]) -> Option<String> { break; } let non_resident = rec.get(off.saturating_add(8)).copied().unwrap_or(0) != 0; - if atype == ATTR_FILE_NAME && !non_resident { - if let Some(n) = file_name_attr(rec, off) { - if name.as_ref().is_none_or(|old| n.len() > old.len()) { - name = Some(n); - } - } + if atype == ATTR_FILE_NAME + && !non_resident + && let Some(n) = file_name_attr(rec, off) + && name.as_ref().is_none_or(|old| n.len() > old.len()) + { + name = Some(n); } off = off.saturating_add(alen); } @@ -638,33 +874,36 @@ fn record_name(rec: &[u8]) -> Option<String> { /// The name of a file whose record has none because its `$FILE_NAME` is in an /// extension record, which the `$ATTRIBUTE_LIST` names. fn name_from_extensions( - rec: &[u8], - volume: &[u8], - mft: &[u8], + list: &[u8], + v: &mut Vol, + mft: &Mft, geo: &Geometry, sector: usize, ) -> Option<String> { - let list = find_attribute_list(rec, volume, geo)?; let mut name: Option<String> = None; let mut i = 0usize; + // A record is read once however many entries name it (each read costs the + // record's size), and no more than [`MAX_LIST_REFS`] are. + let mut read: std::collections::HashSet<u64> = std::collections::HashSet::new(); // Bounded by the list, which is bounded by the volume. while i.saturating_add(26) <= list.len() { - let entry_len = le_u16(&list, i.saturating_add(4)) as usize; + let entry_len = le_u16(list, i.saturating_add(4)) as usize; if entry_len < 26 { break; } - if le_u32(&list, i) == ATTR_FILE_NAME { - let record = le_u64(&list, i.saturating_add(16)) & RECORD_NUMBER_MASK; - let at = crate::bytes::to_usize(record).checked_mul(geo.record_size)?; - if let Some(slice) = crate::bytes::at(mft, at, geo.record_size) { - let mut r = slice.to_vec(); - if r.get(0..4) == Some(RECORD_MAGIC.as_slice()) && apply_fixups(&mut r, sector) { - if let Some(n) = record_name(&r) { - if name.as_ref().is_none_or(|old| n.len() > old.len()) { - name = Some(n); - } - } - } + if le_u32(list, i) == ATTR_FILE_NAME { + let record = le_u64(list, i.saturating_add(16)) & RECORD_NUMBER_MASK; + if read.len() >= MAX_LIST_REFS { + break; + } + if read.insert(record) + && let Some(mut r) = mft.record(v, geo, record) + && r.get(0..4) == Some(RECORD_MAGIC.as_slice()) + && apply_fixups(&mut r, sector) + && let Some(n) = record_name(&r) + && name.as_ref().is_none_or(|old| n.len() > old.len()) + { + name = Some(n); } } i = i.saturating_add(entry_len); @@ -697,20 +936,48 @@ fn decompress(raw: &[u8], unit_bytes: usize, real_size: usize) -> Option<Vec<u8> Some(out) } -pub(crate) fn extract_ntfs<R>( - data: &[u8], +/// Walk a raw NTFS volume. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + super::volumes::entries(budget, visit, "", |budget, sink| { + walk_volume(Reader::new(src), src.len() as u64, budget, sink) + }) +} + +/// Walk the NTFS volume `disk`, which is `len` bytes long, handing each file +/// to `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + len: u64, budget: &mut Budget, - visit: Sink<R>, + visit: Emit<R>, ) -> Result<Option<R>, LimitHit> { - if !super::sniff::is(data, crate::Format::Ntfs) { + let mut v = Vol { + disk: &mut disk, + len, + max: crate::bytes::to_usize(budget.limits().max_buffer_bytes), + }; + if len < 512 { + return Ok(None); + } + let mut boot = vec![0u8; 512]; + v.disk + .seek(SeekFrom::Start(0)) + .and_then(|_| v.disk.read_exact(&mut boot)) + .map_err(|e| LimitHit::corrupt(format!("NTFS volume: {e}")))?; + if !super::sniff::is(&boot, crate::Format::Ntfs) { return Ok(None); } - let Some(geo) = geometry(data) else { + let Some(geo) = geometry(&boot) else { budget.count_entry()?; - return Ok(visit( + return Ok(held( + visit, Entry::unsupported( "<ntfs-volume>".to_string(), - data.len() as u64, + len, false, "implausible NTFS geometry", ), @@ -721,52 +988,54 @@ pub(crate) fn extract_ntfs<R>( // Record 0 is `$MFT` itself, and its `$DATA` gives the runs of the whole // table — which can be fragmented, so the MFT cannot simply be read // linearly from its first cluster. - let mft_start = crate::bytes::to_usize(geo.mft_lcn).saturating_mul(geo.cluster); - let Some(first) = crate::bytes::at(data, mft_start, geo.record_size) else { + let mft_start = geo.mft_lcn.saturating_mul(geo.cluster as u64); + let Some(mut rec0) = v.at(mft_start, geo.record_size) else { budget.count_entry()?; - return Ok(visit( + return Ok(held( + visit, Entry::unsupported( "<ntfs-volume>".to_string(), - data.len() as u64, + len, false, "NTFS master file table lies outside the image", ), budget, )); }; - let mut rec0 = first.to_vec(); - let sector = le_u16(data, 11) as usize; + let sector = le_u16(&boot, 11) as usize; if !apply_fixups(&mut rec0, sector) || rec0.get(0..4) != Some(RECORD_MAGIC.as_slice()) { budget.count_entry()?; - return Ok(visit( + return Ok(held( + visit, Entry::unsupported( "<ntfs-volume>".to_string(), - data.len() as u64, + len, false, "NTFS master file table record is not readable", ), budget, )); } - let mft = mft_bytes(&rec0, data, &geo); - if mft.is_empty() { + let Some(mft) = Mft::open(&rec0, &mut v, &geo) else { budget.count_entry()?; - return Ok(visit( + return Ok(held( + visit, Entry::unsupported( "<ntfs-volume>".to_string(), - data.len() as u64, + len, false, "NTFS master file table could not be read", ), budget, )); - } + }; - let records = mft.len().checked_div(geo.record_size).unwrap_or(0); + let records = crate::bytes::to_usize(mft.size.checked_div(geo.record_size as u64).unwrap_or(0)); let n = records.min(MAX_RECORDS); if records > MAX_RECORDS { budget.count_entry()?; - if let Some(r) = visit( + if let Some(r) = held( + visit, Entry::unsupported( format!("<ntfs-records-beyond-{MAX_RECORDS}>"), 0, @@ -780,23 +1049,20 @@ pub(crate) fn extract_ntfs<R>( } for i in FIRST_USER_RECORD..n { - let Some(slice) = i - .checked_mul(geo.record_size) - .and_then(|at| crate::bytes::at(&mft, at, geo.record_size)) - else { + let Some(mut rec) = mft.record(&mut v, &geo, i as u64) else { break; }; - if slice.get(0..4) != Some(RECORD_MAGIC.as_slice()) { + if rec.get(0..4) != Some(RECORD_MAGIC.as_slice()) { continue; // an unused slot } // Past this point the slot carries the `FILE` magic, so a record IS here // and whatever it names is a file on the volume. Failing to read it is a // gap to report, not a slot to skip — an unused slot was already handled // above, and conflating the two hides real files. - let mut rec = slice.to_vec(); if !apply_fixups(&mut rec, sector) { budget.count_entry()?; - if let Some(r) = visit( + if let Some(r) = held( + visit, Entry::unsupported( format!("<mft-record-{i}>"), 0, @@ -809,11 +1075,11 @@ pub(crate) fn extract_ntfs<R>( } continue; } - let cap = budget.reserve()? as usize; + let cap = crate::bytes::to_usize(budget.reserve()?); // `None` here means "not a user file with content" — a directory, or the // volume's own metadata — not a parse failure. Reporting it would flag // every healthy volume, which the reserved records 16..63 make loud. - let Some(f) = parse_record(&rec, data, &mft, &geo, sector, cap) else { + let Some(f) = parse_record(&rec, &mut v, &mft, &geo, sector, cap) else { continue; }; // A deleted record's runs may since have been reused by another file, so @@ -833,46 +1099,132 @@ pub(crate) fn extract_ntfs<R>( // of the two would either hide the gap or throw away content. if let Some(why) = f.unreadable { budget.count_entry()?; - if let Some(r) = visit( + if let Some(r) = held( + visit, Entry::unsupported(name.clone(), 0, why.contains("encrypted"), why), budget, ) { return Ok(Some(r)); } } - if let Some(d) = f.data { - if !d.is_empty() { - budget.count_entry()?; - budget.commit(d.len() as u64); - if let Some(r) = visit(Entry::new(name, d), budget) { - return Ok(Some(r)); - } + if let Some(d) = f.data + && !d.is_empty() + { + budget.count_entry()?; + budget.commit(d.len() as u64); + if let Some(r) = held(visit, Entry::new(name, d), budget) { + return Ok(Some(r)); + } + } else if let Some((runs, size)) = f.stream { + budget.count_entry()?; + let plan = Runs { + runs: runs.into_iter(), + cluster: geo.cluster as u64, + }; + let got = read_file(&mut v, Pieces::new(plan, size), size, 0); + if let Some(r) = hand(budget, &mut *visit, name, Attrs::default(), got) { + return Ok(Some(r)); } } } Ok(None) } -/// The whole MFT, gathered by following `$MFT`'s own data runs. -fn mft_bytes(rec0: &[u8], volume: &[u8], geo: &Geometry) -> Vec<u8> { - let mut off = le_u16(rec0, 20) as usize; - while off.saturating_add(8) <= rec0.len() { - let atype = le_u32(rec0, off); - if atype == ATTR_END { - break; - } - let alen = le_u32(rec0, off.saturating_add(4)) as usize; - if alen < 16 || alen > rec0.len().saturating_sub(off) { - break; - } - if atype == ATTR_DATA && rec0.get(off.saturating_add(8)).copied().unwrap_or(0) != 0 { - let real_size = crate::bytes::to_usize(le_u64(rec0, off.saturating_add(0x30))); - let runs_off = off.saturating_add(le_u16(rec0, off.saturating_add(0x20)) as usize); - let runs = data_runs(rec0.get(runs_off..off.saturating_add(alen)).unwrap_or(&[])); - // The table cannot be larger than the volume it is in. - return read_runs(volume, &runs, geo.cluster, real_size.min(volume.len())); +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::*; + use std::io::Cursor; + + /// A volume of 16 clusters of 4 bytes, each byte its own index. + fn volume() -> Cursor<Vec<u8>> { + Cursor::new((0..64u8).collect()) + } + + fn runs() -> Vec<Run> { + vec![ + Run { + lcn: Some(2), + clusters: 2, + }, + Run { + lcn: None, + clusters: 1, + }, + Run { + lcn: Some(8), + clusters: 1, + }, + ] + } + + /// The attribute those runs describe, 16 bytes. + fn attribute() -> Vec<u8> { + let v: Vec<u8> = (0..64).collect(); + [&v[8..16], &[0; 4][..], &v[32..36]].concat() + } + + fn range(vol: &mut Cursor<Vec<u8>>, runs: &[Run], at: u64, len: usize) -> Option<Vec<u8>> { + let mut v = Vol { + len: vol.get_ref().len() as u64, + disk: vol, + max: usize::MAX, + }; + let mut out = Vec::new(); + read_runs_at(&mut v, runs, 4, at, len, &mut out).then_some(out) + } + + #[test] + fn a_range_of_an_attribute_is_read_across_its_runs_and_holes() { + let want = attribute(); + for (at, len) in [(0, 16), (3, 9), (8, 4), (11, 2), (12, 4), (15, 1), (16, 0)] { + assert_eq!( + range(&mut volume(), &runs(), at, len), + Some(want[at as usize..at as usize + len].to_vec()), + "({at}, {len})" + ); } - off = off.saturating_add(alen); + // Past the end of the runs. + assert_eq!(range(&mut volume(), &runs(), 14, 4), None); + assert_eq!(range(&mut volume(), &runs(), u64::MAX - 2, 8), None); + } + + #[test] + fn a_range_is_none_where_a_run_is_not_readable() { + let past = vec![Run { + lcn: Some(15), + clusters: 2, + }]; + assert_eq!(range(&mut volume(), &past, 0, 4).map(|v| v.len()), Some(4)); + assert_eq!(range(&mut volume(), &past, 0, 8), None); + let negative = vec![Run { + lcn: Some(-1), + clusters: 2, + }]; + assert_eq!(range(&mut volume(), &negative, 0, 4), None); + let top = vec![Run { + lcn: Some(i64::MAX), + clusters: u64::MAX, + }]; + assert_eq!(range(&mut volume(), &top, 0, 4), None); + } + + /// A read the volume cannot satisfy appends nothing, however much it asks. + #[test] + fn a_read_past_the_volume_appends_nothing_and_allocates_nothing() { + let mut vol = volume(); + let mut v = Vol { + len: 64, + disk: &mut vol, + max: usize::MAX, + }; + let mut out = vec![9u8]; + assert!(!v.append(60, 5, &mut out)); + assert!(!v.append(0, usize::MAX, &mut out)); + assert!(!v.append(u64::MAX, 2, &mut out)); + assert_eq!(out, [9]); + assert!(v.append(60, 4, &mut out)); + assert_eq!(out, [9, 60, 61, 62, 63]); } - Vec::new() } diff --git a/crates/exav-unpack/src/formats/offcrypto.rs b/crates/exav-unpack/src/formats/offcrypto.rs new file mode 100644 index 00000000..8fa7f4f1 --- /dev/null +++ b/crates/exav-unpack/src/formats/offcrypto.rs @@ -0,0 +1,797 @@ +//! Decryption of Excel workbooks saved with a password ([MS-OFFCRYPTO] 2.3.5 +//! to 2.3.7, [MS-XLS] 2.2.10): RC4 (2.3.6), RC4 CryptoAPI (2.3.5) and XOR +//! obfuscation (2.3.7, BIFF2 to BIFF8), read from the `FilePass` record. +//! +//! The headline case is the default password `VelvetSweatshop`: Excel +//! encrypts with it a workbook that is only protected against changes, so it +//! opens with no prompt, and malware abuses that to hide macros and objects +//! from scanners that stop at "encrypted". The scanner and the converters of +//! exav-render both open such a workbook here, with the passwords they were +//! given and these defaults. +//! +//! Only the workbook stream is encrypted; the other storages of the compound +//! file (the VBA project among them) are not. + +use md5::{Digest, Md5}; +use sha1::Sha1; + +/// The password Excel uses for a workbook it protects against changes only. +pub const DEFAULT_PASSWORD: &str = "VelvetSweatshop"; + +/// The passwords tried on every encrypted workbook after the caller's own: +/// the default one, and none. +pub const DEFAULT_PASSWORDS: &[&str] = &[DEFAULT_PASSWORD, ""]; + +/// RC4 keystream XORed into `data`. +pub fn rc4(key: &[u8], data: &mut [u8]) { + if key.is_empty() { + return; + } + let mut s: [u8; 256] = std::array::from_fn(|i| i as u8); + let mut j = 0u8; + for i in 0..256 { + j = j.wrapping_add(s[i]).wrapping_add(key[i % key.len()]); + s.swap(i, j as usize); + } + let (mut i, mut j) = (0u8, 0u8); + for b in data { + i = i.wrapping_add(1); + j = j.wrapping_add(s[i as usize]); + s.swap(i as usize, j as usize); + *b ^= s[s[i as usize].wrapping_add(s[j as usize]) as usize]; + } +} + +fn utf16(s: &str) -> Vec<u8> { + s.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +fn u16_at(b: &[u8], at: usize) -> u16 { + b.get(at..at + 2) + .map_or(0, |s| u16::from_le_bytes([s[0], s[1]])) +} + +fn u32_at(b: &[u8], at: usize) -> u32 { + b.get(at..at + 4) + .map_or(0, |s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +/// A cursor over a record's payload that never reads past it: a short read +/// is flagged, not a panic. +struct Cur<'a> { + d: &'a [u8], + p: usize, + short: bool, +} + +impl<'a> Cur<'a> { + fn new(d: &'a [u8]) -> Self { + Cur { + d, + p: 0, + short: false, + } + } + + fn bytes(&mut self, n: usize) -> &'a [u8] { + match self.p.checked_add(n).and_then(|e| self.d.get(self.p..e)) { + Some(s) => { + self.p += n; + s + } + None => { + self.short = true; + self.p = self.d.len(); + &[] + } + } + } + + fn u16(&mut self) -> u16 { + u16_at(self.bytes(2), 0) + } + + fn u32(&mut self) -> u32 { + u32_at(self.bytes(4), 0) + } +} + +/// 2.3.7.1: the 16-bit verifier of a password. +pub fn xor_verifier(pw: &[u8]) -> u16 { + let mut v: u16 = 0; + let mut arr = vec![pw.len() as u8]; + arr.extend_from_slice(pw); + for &b in arr.iter().rev() { + let i1 = u16::from(v & 0x4000 != 0); + let i2 = (v << 1) & 0x7FFF; + v = (i1 | i2) ^ u16::from(b); + } + v ^ 0xCE4B +} + +/// The 16-byte XOR array of a password and the key FILEPASS stores, as +/// [MS-OFFCRYPTO] 2.3.7.5 builds it, kept rotated left by 3 so that a byte +/// decrypts as `rol3(byte) ^ array[i]`. +fn xor_array(pw: &[u8], key: u16) -> [u8; 16] { + const PAD: [u8; 15] = [ + 0xBB, 0xFF, 0xFF, 0xBA, 0xFF, 0xFF, 0xB9, 0x80, 0x00, 0xBE, 0x0F, 0x00, 0xBF, 0x0F, 0x00, + ]; + let mut a = [0u8; 16]; + for (i, v) in a.iter_mut().enumerate() { + *v = if i < pw.len() { + pw[i] + } else { + PAD[i - pw.len()] + }; + } + let [lo, hi] = key.to_le_bytes(); + for (i, v) in a.iter_mut().enumerate() { + let k = if i % 2 == 0 { lo } else { hi }; + *v = (*v ^ k).rotate_left(2); + } + a +} + +/// What decrypts a workbook, from its FILEPASS record and a password. +pub enum Decryptor { + Rc4 { h1: [u8; 5] }, + CryptoApi { h0: Vec<u8>, key_len: usize }, + Xor { array: [u8; 16] }, +} + +/// Why FILEPASS could not be used. +#[derive(Debug, PartialEq, Eq)] +pub enum Refused { + /// None of the passwords. + Password, + /// A method this reader does not have. + Method, +} + +impl Decryptor { + /// From a FILEPASS record's payload and the passwords to try, in order. + /// BIFF2 to BIFF5 have only XOR obfuscation, without the leading + /// encryption type. + pub fn from_filepass(d: &[u8], biff8: bool, passwords: &[&str]) -> Result<Decryptor, Refused> { + let mut c = Cur::new(d); + let kind = if biff8 { c.u16() } else { 0 }; + if kind == 0 { + let key = c.u16(); + let verifier = c.u16(); + if c.short { + return Err(Refused::Method); + } + // The scheme takes passwords of 1 to 15 bytes. + return passwords + .iter() + .map(|pw| pw.as_bytes()) + .find(|pw| (1..=15).contains(&pw.len()) && xor_verifier(pw) == verifier) + .map(|pw| Decryptor::Xor { + array: xor_array(pw, key), + }) + .ok_or(Refused::Password); + } + if kind != 1 { + return Err(Refused::Method); + } + let major = c.u16(); + let _minor = c.u16(); + if major == 1 { + let salt = c.bytes(16).to_vec(); + let verifier = c.bytes(16).to_vec(); + let hash = c.bytes(16).to_vec(); + if c.short { + return Err(Refused::Method); + } + for pw in passwords { + let h0 = Md5::digest(utf16(pw)); + let mut buf = Vec::with_capacity(336); + for _ in 0..16 { + buf.extend_from_slice(&h0[..5]); + buf.extend_from_slice(&salt); + } + let h1 = Md5::digest(&buf); + let mut t = [0u8; 5]; + t.copy_from_slice(&h1[..5]); + let dec = Decryptor::Rc4 { h1: t }; + let mut both = [verifier.clone(), hash.clone()].concat(); + rc4(&dec.key(0), &mut both); + if Md5::digest(&both[..16]).as_slice() == &both[16..] { + return Ok(dec); + } + } + return Err(Refused::Password); + } + if (2..=4).contains(&major) { + let _flags = c.u32(); + let hsize = c.u32() as usize; + let header = c.bytes(hsize); + let key_bits = u32_at(header, 16) as usize; + let key_len = match key_bits { + 0 => 5, + 40..=128 if key_bits.is_multiple_of(8) => key_bits / 8, + _ => return Err(Refused::Method), + }; + let salt_size = c.u32() as usize; + let salt = c.bytes(salt_size.min(16)).to_vec(); + let verifier = c.bytes(16).to_vec(); + let _hash_size = c.u32(); + let vhash = c.bytes(20).to_vec(); + if c.short { + return Err(Refused::Method); + } + for pw in passwords { + let mut h = Sha1::new(); + h.update(&salt); + h.update(utf16(pw)); + let dec = Decryptor::CryptoApi { + h0: h.finalize().to_vec(), + key_len, + }; + let mut both = [verifier.clone(), vhash.clone()].concat(); + rc4(&dec.key(0), &mut both); + if Sha1::digest(&both[..16]).as_slice() == &both[16..] { + return Ok(dec); + } + } + return Err(Refused::Password); + } + Err(Refused::Method) + } + + fn key(&self, block: u32) -> Vec<u8> { + match self { + Decryptor::Rc4 { h1 } => { + let mut h = Md5::new(); + h.update(h1); + h.update(block.to_le_bytes()); + h.finalize().to_vec() + } + Decryptor::CryptoApi { h0, key_len } => { + let mut h = Sha1::new(); + h.update(h0); + h.update(block.to_le_bytes()); + let f = h.finalize(); + if *key_len == 5 { + let mut k = f[..5].to_vec(); + k.resize(16, 0); + k + } else { + f[..(*key_len).min(16)].to_vec() + } + } + Decryptor::Xor { .. } => vec![], + } + } + + /// Decrypts, in place, every record of `data` after the one at + /// `filepass` (the offset of its header), but for the records and + /// fields that are never encrypted ([MS-XLS] 2.2.10). + pub fn decrypt_stream(&self, data: &mut [u8], filepass: usize) { + let mut p = filepass + 4 + u16_at(data, filepass + 2) as usize; + // (start of plain bytes, start of encrypted bytes, end, data start) + let mut ranges = Vec::new(); + while p + 4 <= data.len() { + let id = u16_at(data, p); + let len = u16_at(data, p + 2) as usize; + let start = p + 4; + let end = (start + len).min(data.len()); + let from = match id { + // BOF, FILEPASS, USREXCL, FILELOCK, INTERFACEHDR, RRDINFO, RRDHEAD. + 0x0809 | 0x0409 | 0x0209 | 0x0009 | 0x002F | 0x0194 | 0x0195 | 0x00E1 | 0x0138 + | 0x0196 => end, + // BOUNDSHEET's stream position. + 0x0085 => (start + 4).min(end), + _ => start, + }; + if from < end { + ranges.push((from, end, start, len)); + } + p = end; + } + match self { + Decryptor::Xor { array } => { + for (from, end, start, len) in ranges { + for (i, b) in data.iter_mut().enumerate().take(end).skip(from) { + let k = array[(start + len + (i - start)) & 15]; + *b = b.rotate_left(3) ^ k; + } + } + } + _ => { + let mut block = u32::MAX; + let mut ks = vec![0u8; 1024]; + for (from, end, _, _) in ranges { + for i in from..end { + let blk = (i / 1024) as u32; + if blk != block { + block = blk; + ks.iter_mut().for_each(|b| *b = 0); + rc4(&self.key(blk), &mut ks); + } + data[i] ^= ks[i % 1024]; + } + } + } + } + } +} + +/// The offset of the FILEPASS record of a workbook stream, if it has one: it +/// follows the opening BOF. +pub fn filepass_at(stream: &[u8]) -> Option<usize> { + let mut p = 0usize; + // BOF, then FILEPASS where it is written; a few records are looked at in + // case a writer put something between. + for _ in 0..8 { + if p + 4 > stream.len() { + return None; + } + if u16_at(stream, p) == 0x002F { + return Some(p); + } + p += 4 + usize::from(u16_at(stream, p + 2)); + } + None +} + +/// A workbook stream decrypted with the first password that opens it: +/// `Err(Refused::Password)` if none does. A stream with no FILEPASS comes back +/// as it is. The FILEPASS record of the result is blanked (its type zero, its +/// payload zeros), so that what reads the result does not take it for +/// encrypted again. +pub fn decrypt_workbook(stream: &[u8], passwords: &[&str]) -> Result<Vec<u8>, Refused> { + let mut out = stream.to_vec(); + let Some(fp) = filepass_at(stream) else { + return Ok(out); + }; + // BIFF2 to BIFF5 write only the key and verifier of XOR obfuscation, four + // bytes; from BIFF8 on there is an encryption type first. + let size = usize::from(u16_at(stream, fp + 2)); + let biff8 = size > 4; + let payload = stream.get(fp + 4..fp + 4 + size).ok_or(Refused::Method)?; + Decryptor::from_filepass(payload, biff8, passwords)?.decrypt_stream(&mut out, fp); + out[fp..fp + 2].fill(0); + out[fp + 4..fp + 4 + size].fill(0); + Ok(out) +} + +/// Encryption with the default password, for the tests of what reads encrypted +/// workbooks: the inverse of [`Decryptor::decrypt_stream`], written from the +/// specification apart from it. +#[cfg(any(test, feature = "test-util"))] +pub mod encrypt { + use super::*; + + /// A FILEPASS payload for `method` ("xor", "rc4", "cryptoapi40", + /// "cryptoapi128") and a fixed salt. + pub fn filepass(method: &str, biff8: bool) -> Vec<u8> { + let pw = DEFAULT_PASSWORD; + let salt: Vec<u8> = (0..16u8) + .map(|i| i.wrapping_mul(37).wrapping_add(11)) + .collect(); + let verifier: Vec<u8> = (0..16u8) + .map(|i| i.wrapping_mul(91).wrapping_add(3)) + .collect(); + let mut out = vec![]; + match method { + "xor" => { + if biff8 { + out.extend_from_slice(&0u16.to_le_bytes()); + } + out.extend_from_slice(&xor_key(pw.as_bytes()).to_le_bytes()); + out.extend_from_slice(&xor_verifier(pw.as_bytes()).to_le_bytes()); + } + "rc4" => { + out.extend_from_slice(&[1, 0, 1, 0, 1, 0]); + out.extend_from_slice(&salt); + let h0 = Md5::digest(utf16(pw)); + let mut buf = vec![]; + for _ in 0..16 { + buf.extend_from_slice(&h0[..5]); + buf.extend_from_slice(&salt); + } + let h1 = Md5::digest(&buf); + let mut key = Md5::new(); + key.update(&h1[..5]); + key.update(0u32.to_le_bytes()); + let mut both = [verifier.clone(), Md5::digest(&verifier).to_vec()].concat(); + rc4(&key.finalize(), &mut both); + out.extend_from_slice(&both); + } + m => { + let bits: u32 = if m == "cryptoapi40" { 40 } else { 128 }; + out.extend_from_slice(&[1, 0, 2, 0, 2, 0]); + out.extend_from_slice(&4u32.to_le_bytes()); + let csp: Vec<u8> = utf16("Microsoft Base Cryptographic Provider v1.0\0"); + let mut header = vec![]; + header.extend_from_slice(&4u32.to_le_bytes()); // flags: fCryptoAPI + header.extend_from_slice(&0u32.to_le_bytes()); + header.extend_from_slice(&0x6801u32.to_le_bytes()); // RC4 + header.extend_from_slice(&0x8004u32.to_le_bytes()); // SHA-1 + header.extend_from_slice(&bits.to_le_bytes()); + header.extend_from_slice(&1u32.to_le_bytes()); + header.extend_from_slice(&[0; 8]); + header.extend_from_slice(&csp); + out.extend_from_slice(&(header.len() as u32).to_le_bytes()); + out.extend_from_slice(&header); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&salt); + let mut h = Sha1::new(); + h.update(&salt); + h.update(utf16(pw)); + let h0 = h.finalize(); + let mut k = Sha1::new(); + k.update(h0); + k.update(0u32.to_le_bytes()); + let f = k.finalize(); + let key = if bits == 40 { + [&f[..5], &[0u8; 11][..]].concat() + } else { + f[..16].to_vec() + }; + let mut both = [verifier.clone(), Sha1::digest(&verifier).to_vec()].concat(); + rc4(&key, &mut both); + out.extend_from_slice(&both[..16]); + out.extend_from_slice(&20u32.to_le_bytes()); + out.extend_from_slice(&both[16..]); + } + } + out + } + + /// 2.3.7.2's CreateXorKey_Method1. + pub fn xor_key(pw: &[u8]) -> u16 { + const INITIAL: [u16; 15] = [ + 0xE1F0, 0x1D0F, 0xCC9C, 0x84C0, 0x110C, 0x0E10, 0xF1CE, 0x313E, 0x1872, 0xE139, 0xD40F, + 0x84F9, 0x280C, 0xA96A, 0x4EC3, + ]; + const MATRIX: [u16; 105] = [ + 0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09, 0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, + 0xC6F7, 0x9DCF, 0x2BBF, 0x4563, 0x8AC6, 0x05AD, 0x0B5A, 0x16B4, 0x2D68, 0x5AD0, 0x0375, + 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40, 0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, + 0xAA7A, 0x44D5, 0x6F45, 0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A, 0xEB23, 0xC667, + 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9, 0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4, 0x7B68, + 0xF6D0, 0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC, 0x45A0, 0x8B40, 0x06A1, + 0x0D42, 0x1A84, 0x3508, 0x6A10, 0xAA51, 0x4483, 0x8906, 0x022D, 0x045A, 0x08B4, 0x1168, + 0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C, 0x3730, 0x6E60, 0xDCC0, 0xA9A1, + 0x4363, 0x86C6, 0x1DAD, 0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC, 0x1021, + 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4, + ]; + let mut key = INITIAL[pw.len() - 1]; + let mut el = 0x68usize; + for &c in pw.iter().rev() { + let mut c = c; + for _ in 0..7 { + if c & 0x40 != 0 { + key ^= MATRIX[el]; + } + c = c.wrapping_mul(2); + el = el.wrapping_sub(1); + } + } + key + } + + /// Encrypts the records after FILEPASS (at offset `filepass`), as + /// [MS-OFFCRYPTO] 2.3.7.3 (EncryptData_Method1 with the array of + /// 2.3.7.2) and 2.3.6 describe it. + pub fn encrypt_stream(method: &str, data: &mut [u8], filepass: usize) { + let mut p = filepass + 4 + u16_at(data, filepass + 2) as usize; + let pw = DEFAULT_PASSWORD.as_bytes(); + let fp = data[filepass + 4..filepass + 4 + u16_at(data, filepass + 2) as usize].to_vec(); + let biff8 = fp.len() > 4; + let dec = Decryptor::from_filepass(&fp, biff8, &[DEFAULT_PASSWORD]).ok(); + let xa = method_1_array(pw); + while p + 4 <= data.len() { + let id = u16_at(data, p); + let len = u16_at(data, p + 2) as usize; + let start = p + 4; + let end = (start + len).min(data.len()); + let from = match id { + 0x0809 | 0x0409 | 0x0209 | 0x0009 | 0x002F | 0x0194 | 0x0195 | 0x00E1 | 0x0138 + | 0x0196 => end, + 0x0085 => start + 4, + _ => start, + }; + for i in from..end { + if method == "xor" { + let idx = (start + len + (i - start)) & 15; + data[i] = data[i].rotate_left(5) ^ xa[idx]; + } else if let Some(d) = &dec { + let mut ks = vec![0u8; i % 1024 + 1]; + rc4(&d.key((i / 1024) as u32), &mut ks); + data[i] ^= ks[i % 1024]; + } + } + p = end; + } + } + + /// 2.3.7.2's CreateXorArray_Method1, as the specification writes it. + fn method_1_array(pw: &[u8]) -> [u8; 16] { + const PAD: [u8; 15] = [ + 0xBB, 0xFF, 0xFF, 0xBA, 0xFF, 0xFF, 0xB9, 0x80, 0x00, 0xBE, 0x0F, 0x00, 0xBF, 0x0F, + 0x00, + ]; + let ror = |b: u8| b.rotate_right(1); + let key = xor_key(pw); + let (hi, lo) = ((key >> 8) as u8, key as u8); + let mut a = [0u8; 16]; + let mut index = pw.len(); + if index % 2 == 1 { + a[index] = ror(PAD[0] ^ hi); + index -= 1; + a[index] = ror(pw[pw.len() - 1] ^ lo); + } + while index > 0 { + index -= 1; + a[index] = ror(pw[index] ^ hi); + index -= 1; + a[index] = ror(pw[index] ^ lo); + } + let mut index = 15usize; + let mut pad = 15 - pw.len(); + while pad > 0 { + a[index] = ror(PAD[pad] ^ hi); + index -= 1; + pad -= 1; + a[index] = ror(PAD[pad] ^ lo); + index = index.wrapping_sub(1); + pad = pad.wrapping_sub(1); + if pad > 15 { + break; + } + } + a + } +} + +#[cfg(test)] +mod tests { + use super::encrypt::*; + use super::*; + + #[test] + fn rc4_test_vector() { + // RFC 6229, key 0x0102030405, first bytes of keystream. + let mut d = [0u8; 8]; + rc4(&[1, 2, 3, 4, 5], &mut d); + assert_eq!(d, [0xb2, 0x39, 0x63, 0x05, 0xf0, 0x3d, 0xc0, 0x27]); + } + + #[test] + fn verifier_of_the_default_password() { + // [MS-OFFCRYPTO] 2.3.7.1 on "VelvetSweatshop"; the stored key is + // CreateXorKey_Method1 of it. + let pw = DEFAULT_PASSWORD.as_bytes(); + let fp = filepass("xor", true); + assert!(matches!( + Decryptor::from_filepass(&fp, true, &[DEFAULT_PASSWORD]), + Ok(Decryptor::Xor { .. }) + )); + assert_eq!(u16_at(&fp, 4), xor_verifier(pw)); + // Another password's verifier is refused. + let mut bad = fp.clone(); + bad[4] ^= 1; + assert_eq!( + Decryptor::from_filepass(&bad, true, &[DEFAULT_PASSWORD]).err(), + Some(Refused::Password) + ); + } + + /// The password that opens a workbook is found among those given, in the + /// order given; none of them is a refusal. + #[test] + fn the_right_password_is_found_among_several() { + for m in ["xor", "rc4", "cryptoapi40", "cryptoapi128"] { + let fp = filepass(m, true); + let found = Decryptor::from_filepass(&fp, true, &["wrong", "", DEFAULT_PASSWORD]); + assert!(found.is_ok(), "{m}"); + assert_eq!( + Decryptor::from_filepass(&fp, true, &["wrong", ""]).err(), + Some(Refused::Password), + "{m}" + ); + assert_eq!( + Decryptor::from_filepass(&fp, true, &[]).err(), + Some(Refused::Password), + "{m}" + ); + } + } + + #[test] + fn our_xor_array_is_the_specifications() { + // The array built from the stored key, rotated, equals 2.3.7.2's + // CreateXorArray_Method1 rotated the same way. + let mut stream = vec![]; + // A FILEPASS (XOR, BIFF8), then a record of 40 known bytes. + let fp = filepass("xor", true); + stream.extend_from_slice(&0x002Fu16.to_le_bytes()); + stream.extend_from_slice(&(fp.len() as u16).to_le_bytes()); + stream.extend_from_slice(&fp); + let plain: Vec<u8> = (0..40u8).collect(); + stream.extend_from_slice(&0x0204u16.to_le_bytes()); + stream.extend_from_slice(&40u16.to_le_bytes()); + stream.extend_from_slice(&plain); + let mut enc = stream.clone(); + encrypt_stream("xor", &mut enc, 0); + assert_ne!(enc, stream); + let d = Decryptor::from_filepass(&fp, true, &[DEFAULT_PASSWORD]).unwrap(); + d.decrypt_stream(&mut enc, 0); + assert_eq!(enc, stream); + } + + #[test] + fn rc4_and_cryptoapi_round_trip() { + for m in ["rc4", "cryptoapi40", "cryptoapi128"] { + let fp = filepass(m, true); + let mut stream = vec![]; + stream.extend_from_slice(&0x002Fu16.to_le_bytes()); + stream.extend_from_slice(&(fp.len() as u16).to_le_bytes()); + stream.extend_from_slice(&fp); + for k in 0..300u16 { + stream.extend_from_slice(&0x0203u16.to_le_bytes()); + stream.extend_from_slice(&14u16.to_le_bytes()); + stream.extend_from_slice(&k.to_le_bytes()); + stream.extend_from_slice(&[7; 12]); + } + let mut enc = stream.clone(); + encrypt_stream(m, &mut enc, 0); + assert_ne!(enc, stream, "{m}"); + let d = Decryptor::from_filepass(&fp, true, &[DEFAULT_PASSWORD]) + .unwrap_or_else(|e| panic!("{m}: {e:?}")); + d.decrypt_stream(&mut enc, 0); + assert_eq!(enc, stream, "{m}"); + } + } + + fn hexv(s: &str) -> Vec<u8> { + (0..s.len() / 2) + .map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap()) + .collect() + } + + /// The verifier matches the MS-OFFCRYPTO reference vectors (the + /// `VelvetSweatshop` default stores `0x9a0a`). + #[test] + fn xor_verifier_matches_reference() { + assert_eq!(xor_verifier(b"VelvetSweatshop"), 0x9a0a); + assert_eq!(xor_verifier(b"password1"), 0xe1ae); + assert_eq!(xor_verifier(b"Secret1"), 0xc266); + assert_ne!(xor_verifier(b"wrong"), 0x9a0a); + } + + /// The obfuscation array matches the MIT msoffcrypto reference for several + /// passwords, byte for byte (which validates the tables and the key + /// derivation): the reference array is ours rotated left by 3, and ours + /// rotated back is the reference's. + #[test] + fn xor_array_matches_reference_vectors() { + for (pw, hex) in [ + ("VelvetSweatshop", "876b9ae21ee305621e699660986e9404"), + ("password1", "dbb75abe58b0da357bda1cf8bef81cdb"), + ("abc", "95999475da577857da7465a87a2f2577"), + ("Secret1", "7b3863b360b04a572d758f752d56928a"), + ] { + let ours = xor_array(pw.as_bytes(), xor_key(pw.as_bytes())); + let reference: Vec<u8> = ours.iter().map(|b| b.rotate_right(3)).collect(); + assert_eq!(reference, hexv(hex), "xor array mismatch for {pw:?}"); + } + } + + /// Passwords of 1 to 15 bytes are opened by the XOR scheme, others are not. + #[test] + fn xor_passwords_of_every_length_are_tried() { + for n in 0..=16 { + let pw = "a".repeat(n); + let key = if (1..=15).contains(&n) { + xor_key(pw.as_bytes()) + } else { + 0 + }; + let mut fp = vec![0u8, 0]; + fp.extend(key.to_le_bytes()); + fp.extend(xor_verifier(pw.as_bytes()).to_le_bytes()); + let found = Decryptor::from_filepass(&fp, true, &[&pw]).is_ok(); + assert_eq!(found, (1..=15).contains(&n), "length {n}"); + } + } + + // Independently generated vector (salt 00..0f, password "Secret1"). + #[test] + fn rc4_basic_verifier_and_key() { + let mut fp = vec![1u8, 0, 1, 0, 1, 0]; + fp.extend(0u8..16); + fp.extend(hexv("d11cfb57cde29a6eaf45018e01931b31")); + fp.extend(hexv("9687999033ca45aaade5f45386030a07")); + match Decryptor::from_filepass(&fp, true, &["wrong", DEFAULT_PASSWORD, "Secret1"]) { + Ok(Decryptor::Rc4 { h1 }) => assert_eq!(h1.to_vec(), hexv("cabe9bb94c")), + _ => panic!("Secret1 opens it"), + } + assert_eq!( + Decryptor::from_filepass(&fp, true, &["wrong", DEFAULT_PASSWORD]).err(), + Some(Refused::Password) + ); + } + + // Independently generated vector, cross-checked against the msoffcrypto + // reference (salt 10..1f, password "VelvetSweatshop", 128-bit). + #[test] + fn rc4_cryptoapi_verifier_vector() { + let mut header = vec![0u8; 32]; + header[16..20].copy_from_slice(&128u32.to_le_bytes()); + let mut fp = vec![1u8, 0, 3, 0, 3, 0]; + fp.extend(0x24u32.to_le_bytes()); + fp.extend((header.len() as u32).to_le_bytes()); + fp.extend(&header); + fp.extend(16u32.to_le_bytes()); + fp.extend(16u8..32); + fp.extend(hexv("66871900f3075fc3fa7e1d9b934d6b22")); + fp.extend(20u32.to_le_bytes()); + fp.extend(hexv("13146eb6c265735c52ea6eb1cc48739e80561620")); + assert!(Decryptor::from_filepass(&fp, true, &[DEFAULT_PASSWORD]).is_ok()); + assert_eq!( + Decryptor::from_filepass(&fp, true, &["nope"]).err(), + Some(Refused::Password) + ); + // A key size past what SHA-1 gives is a method not had, not a cut key. + fp[6 + 8 + 16..6 + 8 + 20].copy_from_slice(&2048u32.to_le_bytes()); + assert_eq!( + Decryptor::from_filepass(&fp, true, &[DEFAULT_PASSWORD]).err(), + Some(Refused::Method) + ); + } + + #[test] + fn rc4_is_symmetric() { + let k = b"key1234567890abc"; + let pt = b"eicar-ish plaintext content"; + let mut d = pt.to_vec(); + rc4(k, &mut d); + assert_ne!(&d[..], &pt[..]); + rc4(k, &mut d); + assert_eq!(&d[..], &pt[..]); + } + + /// A whole workbook stream (a BOF, a FILEPASS, records), whose FILEPASS is + /// found after the BOF. + #[test] + fn a_workbook_is_decrypted_from_its_filepass() { + for m in ["xor", "rc4", "cryptoapi128"] { + let fp = filepass(m, true); + let mut stream = vec![]; + // BOF, BIFF8, workbook globals. + stream.extend_from_slice(&0x0809u16.to_le_bytes()); + stream.extend_from_slice(&16u16.to_le_bytes()); + stream.extend_from_slice(&[0x00, 0x06, 0x05, 0x00]); + stream.extend_from_slice(&[0; 12]); + let filepass_pos = stream.len(); + stream.extend_from_slice(&0x002Fu16.to_le_bytes()); + stream.extend_from_slice(&(fp.len() as u16).to_le_bytes()); + stream.extend_from_slice(&fp); + for k in 0..50u16 { + stream.extend_from_slice(&0x0203u16.to_le_bytes()); + stream.extend_from_slice(&14u16.to_le_bytes()); + stream.extend_from_slice(&k.to_le_bytes()); + stream.extend_from_slice(&[9; 12]); + } + let mut enc = stream.clone(); + encrypt_stream(m, &mut enc, filepass_pos); + assert_eq!(filepass_at(&enc), Some(filepass_pos), "{m}"); + let out = decrypt_workbook(&enc, DEFAULT_PASSWORDS).unwrap(); + // The records are the plain ones, and FILEPASS is blanked. + let mut blanked = stream.clone(); + blanked[filepass_pos..filepass_pos + 2].fill(0); + blanked[filepass_pos + 4..filepass_pos + 4 + fp.len()].fill(0); + assert_eq!(out, blanked, "{m}"); + assert_eq!(filepass_at(&out), None, "{m}"); + assert_eq!( + decrypt_workbook(&enc, &["no"]).err(), + Some(Refused::Password), + "{m}" + ); + } + } +} diff --git a/crates/exav-unpack/src/formats/ole.rs b/crates/exav-unpack/src/formats/ole.rs index 134366f9..a3d31c24 100644 --- a/crates/exav-unpack/src/formats/ole.rs +++ b/crates/exav-unpack/src/formats/ole.rs @@ -1,67 +1,37 @@ #![allow(unused_imports)] +use super::cfb::{self, Compound}; use crate::*; use std::io::{BufReader, Cursor, Read, Seek, Write}; /// OLE2 / Compound File Binary (legacy Office, MSI): emit every stream so the /// engine can match on macro/object/shellcode content. -pub(crate) fn extract_ole(data: &[u8], budget: &mut Budget) -> Result<Vec<Entry>, LimitHit> { - match cfb::CompoundFile::open(Cursor::new(data)) { - Ok(comp) => collect_ole_entries(comp, data.len() as u64, budget), - // A strict CFB reader rejects compound files that violate the spec — most - // commonly a broken red-black-tree sibling ordering in the directory (e.g. - // `_VBA_PROJECT` sorted before `dir`), which many real Office documents and - // malware carry. The streams are still readable; fall back to a lenient flat - // directory walk (ClamAV-style) so we scan the content instead of returning - // "not fully scanned". Only surface the error if it is not a compound file. - Err(e) => match lenient_cfb_streams(data, budget.limits.max_buffer_bytes) { - Some(streams) => assemble_ole_entries(streams, data.len() as u64, budget), - None => Err(LimitHit::new(format!("ole: {e}"))), - }, - } -} - -/// Open a CFB compound file over a **seekable source** (the reader-based -/// streaming path): the whole `.doc`/`.xls`/`.msi` file is never buffered — cfb -/// walks the FAT chains with seeks, and each stream is read on demand. Returns -/// the same members as [`extract_ole`]. /// -/// Staged: the unpack-layer capability is ready, but core routing is gated on the -/// streamed member-scan path replicating OLE-specific handling (VBA-macro -/// heuristics + textual-type forcing) — see the module notes — so it is not yet -/// called. Wiring it without that would risk false positives on benign macros. -#[allow(dead_code)] -pub(crate) fn stream_ole_entries<R: Read + Seek>( - mut source: R, - budget: &mut Budget, -) -> Result<Vec<Entry>, LimitHit> { - let total = source - .seek(std::io::SeekFrom::End(0)) - .map_err(|e| LimitHit::corrupt(format!("ole: {e}")))?; - source - .seek(std::io::SeekFrom::Start(0)) - .map_err(|e| LimitHit::corrupt(format!("ole: {e}")))?; - let comp = cfb::CompoundFile::open(source).map_err(|e| LimitHit::new(format!("ole: {e}")))?; - collect_ole_entries(comp, total, budget) +/// The reader is lenient: a compound file whose directory tree is out of order +/// (e.g. `_VBA_PROJECT` sorted before `dir`), which many real Office documents +/// and malware carry, is read as well as a clean one, so its content is scanned +/// instead of the file being reported as not fully scanned. +pub(crate) fn extract_ole(data: &[u8], budget: &mut Budget) -> Result<Vec<Entry>, LimitHit> { + let comp = Compound::open(data) + .ok_or_else(|| LimitHit::new("ole: not a compound file".to_string()))?; + collect_ole_entries(&comp, data.len() as u64, budget) } /// Walk every stream of a compound file and emit it as an [`Entry`], handling MSI /// name decompression, `.doc`/`.xls`/OOXML encryption detection, and VBA-macro -/// artifact synthesis. Shared by the buffered ([`extract_ole`]) and seekable -/// ([`stream_ole_entries`]) entry points. `total_len` is the container size (used -/// only for the encrypted-member size field). -fn collect_ole_entries<R: Read + Seek>( - mut comp: cfb::CompoundFile<R>, +/// artifact synthesis. `total_len` is the container size (used only for the +/// encrypted-member size field). +fn collect_ole_entries( + comp: &Compound<'_>, total_len: u64, budget: &mut Budget, ) -> Result<Vec<Entry>, LimitHit> { - let paths: Vec<std::path::PathBuf> = comp - .walk() - .filter(|e| e.is_stream()) - .map(|e| e.path().to_path_buf()) - .collect(); + // A stream entry with no name (a malformed directory) has its sectors + // inside this same file, uncompressed, where the raw scan of the caller + // covers them: skipping it loses the name and size, not the content. + let streams: Vec<cfb::StreamInfo<'_>> = comp.streams().filter(|s| !s.name.is_empty()).collect(); // Detect MSI database: decompress each stream name and check for `_Tables`. - let msi = is_msi_database(&paths); + let msi = is_msi_database(&streams); // Encrypted Office document. A password-protected OOXML file // (`.docx`/`.xlsx`/`.pptx`) is wrapped in an OLE2/CFB container holding the @@ -70,38 +40,22 @@ fn collect_ole_entries<R: Read + Seek>( // agile AES-CBC) with the default `VelvetSweatshop`/empty password + the caller // pool so the real content is scanned; if that fails (wrong password) we still // surface an encrypted member — never a silent clean. - let stream_named = |want: &str| -> Option<std::path::PathBuf> { - paths - .iter() - .find(|p| { - p.file_name() - .map(|s| s.to_string_lossy().eq_ignore_ascii_case(want)) - .unwrap_or(false) - }) - .cloned() + let stream_named = |want: &str| -> Option<&cfb::StreamInfo<'_>> { + streams.iter().find(|s| s.name.eq_ignore_ascii_case(want)) }; - // The stream paths are what the decryptor needs; without `decrypt` there is - // nothing to hand them to, and the member is reported unsupported below - // without either stream being read. + // Without `decrypt` there is nothing to hand the streams to, and the + // member is reported unsupported below without either being read. #[cfg_attr(not(feature = "decrypt"), allow(unused_variables))] - if let (Some(info_p), Some(pkg_p)) = ( + if let (Some(info_s), Some(pkg_s)) = ( stream_named("EncryptionInfo"), stream_named("EncryptedPackage"), ) { #[cfg(feature = "decrypt")] let decrypted = { - let mut read_full = |p: &std::path::Path| -> Option<Vec<u8>> { - let s = comp.open_stream(p).ok()?; - bounded_read(s, budget.limits.max_buffer_bytes) - .ok() - .map(|(b, _)| b) - }; - match (read_full(&info_p), read_full(&pkg_p)) { - (Some(info), Some(pkg)) => { - super::ole_crypto::try_decrypt_ooxml(&info, &pkg, &budget.passwords) - } - _ => None, - } + let cap = budget.limits.max_buffer_bytes; + let info = comp.read(info_s.index, cap); + let pkg = comp.read(pkg_s.index, cap); + super::ole_crypto::try_decrypt_ooxml(&info.data, &pkg.data, &budget.passwords) }; #[cfg(not(feature = "decrypt"))] let decrypted: Option<Vec<u8>> = None; @@ -122,23 +76,21 @@ fn collect_ole_entries<R: Read + Seek>( } let mut entries = Vec::new(); - for p in &paths { + for s in &streams { budget.count_entry()?; let cap = budget.reserve()?; - let stream = comp - .open_stream(p) - .map_err(|e| LimitHit::new(format!("ole stream: {e}")))?; - let (buf, truncated) = - bounded_read(stream, cap).map_err(|e| LimitHit::new(format!("ole read: {e}")))?; - if truncated { + let read = comp.read(s.index, cap); + // A prefix handed over as a whole stream would be a payload past the + // cap that nothing scanned and nothing reported. + if read.truncated { return Err(LimitHit::new("ole stream exceeds budget".to_string())); } - let (buf, was_decrypted) = match legacy_encryption(&p.to_string_lossy(), buf, budget) { + let (buf, was_decrypted) = match legacy_encryption(s.path, read.data.into_owned(), budget) { Legacy::Plain(b) => (b, false), Legacy::Decrypted(b) => (b, true), Legacy::Locked => { entries.push(Entry::unsupported( - p.to_string_lossy().into_owned(), + s.path.to_string(), total_len, true, "encrypted Office document", @@ -151,10 +103,9 @@ fn collect_ole_entries<R: Read + Seek>( // MSI compresses OLE2 stream names to fit longer names into the // 31-char OLE2 limit. Decompress if this is an MSI database. let name = if msi { - let full = p.to_string_lossy(); - decompress_msi_name(&full) + decompress_msi_name(s.path) } else { - p.to_string_lossy().into_owned() + s.path.to_string() }; // A stream we DECRYPTED carries its plaintext and stays marked encrypted. // Both facts are true: the content is available to scan, and the document @@ -166,10 +117,10 @@ fn collect_ole_entries<R: Read + Seek>( entries.push(entry); } - append_ole10native_payloads(&mut entries, budget); + append_ole10native_payloads(&mut entries, budget)?; // Before the macro pass: an embedded storage can itself hold the VBA // project, and the artifacts are synthesised from whatever is present. - append_ppt_embedded_storages(&mut entries, budget); + append_ppt_embedded_storages(&mut entries, budget)?; append_macro_artifacts(&mut entries, budget); Ok(entries) } @@ -187,7 +138,10 @@ fn collect_ole_entries<R: Read + Seek>( /// 13 corpus documents carried an encrypted OLE2 this way that exav never saw, /// and the detection was already correct once the payload was carved by hand. /// It is a routine malware-delivery shape, so the value is well beyond those 13. -fn append_ole10native_payloads(entries: &mut Vec<Entry>, budget: &mut Budget) { +fn append_ole10native_payloads( + entries: &mut Vec<Entry>, + budget: &mut Budget, +) -> Result<(), LimitHit> { let mut carved: Vec<Entry> = Vec::new(); for e in entries.iter() { // Stream names arrive as paths (`/\x01Ole10Native`), and the `\x01` @@ -205,19 +159,27 @@ fn append_ole10native_payloads(entries: &mut Vec<Entry>, budget: &mut Budget) { let Some(payload) = ole10native_payload(&e.data) else { continue; }; - if budget.count_entry().is_err() { - break; - } - let Ok(cap) = budget.reserve() else { break }; + budget.count_entry()?; + let cap = budget.reserve()?; if payload.len() as u64 > cap { + carved.push(Entry::unsupported( + format!("{leaf}-payload"), + payload.len() as u64, + false, + "embedded object exceeds the per-member budget", + )); continue; } budget.commit(payload.len() as u64); carved.push(Entry::new(format!("{leaf}-payload"), payload.to_vec())); } entries.extend(carved); + Ok(()) } +/// An embedded storage's bytes, and why they are not all of it when they are not. +type Storage = (Vec<u8>, Option<&'static str>); + /// Inflate every object PowerPoint 97 embedded in its own record stream and /// append it. /// @@ -234,20 +196,21 @@ fn append_ole10native_payloads(entries: &mut Vec<Entry>, budget: &mut Budget) { /// 10,752-byte compound file deflated inside one of these records, and exav's /// own OLE reader decompresses the module source from it once it is handed over. /// The value is wider than macros — *any* embedded object was unreachable. -fn append_ppt_embedded_storages(entries: &mut Vec<Entry>, budget: &mut Budget) { +fn append_ppt_embedded_storages( + entries: &mut Vec<Entry>, + budget: &mut Budget, +) -> Result<(), LimitHit> { let mut carved: Vec<Entry> = Vec::new(); for e in entries.iter() { let leaf = e.name.rsplit(['/', '\\']).next().unwrap_or(&e.name); if !leaf.eq_ignore_ascii_case("PowerPoint Document") { continue; } - for (i, (blob, why)) in ppt_embedded_storages(&e.data, budget) + for (i, (blob, why)) in ppt_embedded_storages(&e.data, budget)? .into_iter() .enumerate() { - if budget.count_entry().is_err() { - break; - } + budget.count_entry()?; budget.commit(blob.len() as u64); let mut entry = Entry::new(format!("ppt-embedded-{i}"), blob); entry.unsupported = why; @@ -255,6 +218,7 @@ fn append_ppt_embedded_storages(entries: &mut Vec<Entry>, budget: &mut Budget) { } } entries.extend(carved); + Ok(()) } /// Walk a `PowerPoint Document` record tree and return the payload of every @@ -266,22 +230,14 @@ fn append_ppt_embedded_storages(entries: &mut Vec<Entry>, budget: &mut Budget) { /// how a record nested three levels down is reached at all. The instance (the /// high twelve bits) selects the storage form: `0` is stored, `1` is /// `[uncompressedSize:u32]` followed by a zlib stream. -fn ppt_embedded_storages( - stream: &[u8], - budget: &mut Budget, -) -> Vec<(Vec<u8>, Option<&'static str>)> { +fn ppt_embedded_storages(stream: &[u8], budget: &mut Budget) -> Result<Vec<Storage>, LimitHit> { /// `RT_ExternalOleObjectStg`. const EXT_OLE_OBJ_STG: u16 = 0x1011; - /// A record tree deep enough to need more steps than this is malformed, and - /// bounding the walk is cheaper than reasoning about whether it can loop. - const MAX_RECORDS: usize = 100_000; - let mut out = Vec::new(); let mut off = 0usize; - for _ in 0..MAX_RECORDS { - let Some(hdr) = crate::bytes::at(stream, off, 8) else { - break; - }; + // Every step moves `off` forward by a header at least, so the walk ends + // with the stream. It has no count of its own to stop it with records left. + while let Some(hdr) = crate::bytes::at(stream, off, 8) { let ver_instance = u16::from_le_bytes([hdr[0], hdr[1]]); let rec_type = u16::from_le_bytes([hdr[2], hdr[3]]); let len = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]) as usize; @@ -290,11 +246,18 @@ fn ppt_embedded_storages( off += 8; continue; } - if rec_type == EXT_OLE_OBJ_STG { - if let Some(body) = crate::bytes::at(stream, off + 8, len) { - if let Some(blob) = ppt_storage_payload(ver_instance >> 4, body, budget) { - out.push(blob); - } + if rec_type == EXT_OLE_OBJ_STG + && let Some(rest) = stream.get(off.saturating_add(8)..) + { + // A record that runs past the end of the stream is read as far as + // the stream goes, and says so. + let cut = len > rest.len(); + let body = &rest[..len.min(rest.len())]; + if let Some((blob, why)) = ppt_storage_payload(ver_instance >> 4, body, budget)? { + let why = why + .or(cut + .then_some("embedded PowerPoint storage runs past the end of its stream")); + out.push((blob, why)); } } // `len` is attacker-controlled; a wrapping step would walk the stream @@ -304,7 +267,7 @@ fn ppt_embedded_storages( _ => break, } } - out + Ok(out) } /// One storage record's bytes: stored as-is, or inflated, bounded by the @@ -313,38 +276,39 @@ fn ppt_storage_payload( instance: u16, body: &[u8], budget: &mut Budget, -) -> Option<(Vec<u8>, Option<&'static str>)> { +) -> Result<Option<Storage>, LimitHit> { const OVER: &str = "embedded PowerPoint storage exceeds the per-member budget"; const BROKEN: &str = "embedded PowerPoint storage could not be decompressed"; const PART_WAY: &str = "embedded PowerPoint storage failed to decompress part way; \ the bytes before the failure were scanned"; - let cap = budget.reserve().ok()?; + let cap = budget.reserve()?; if instance == 0 { // Stored. The record is the compound file. - return Some(if body.len() as u64 <= cap { + return Ok(Some(if body.len() as u64 <= cap { (body.to_vec(), None) } else { (Vec::new(), Some(OVER)) - }); + })); } // Compressed: a declared size then a zlib stream. The declared size is a // hint from the file, so it is not trusted for allocation — the read is // bounded by the budget and salvages a truncated tail, which is how a // deliberately-truncated object still gets scanned rather than dropped. - let deflated = body.get(4..)?; - let Some(zlib) = crate::inflate::zlib_body(deflated) else { - return Some((Vec::new(), Some(BROKEN))); + let Some(zlib) = body.get(4..).and_then(crate::inflate::zlib_body) else { + return Ok(Some((Vec::new(), Some(BROKEN)))); + }; + let Ok(s) = bounded_read_salvage(zlib, cap, true) else { + return Ok(Some((Vec::new(), Some(BROKEN)))); }; - let s = bounded_read_salvage(zlib, cap, true).ok()?; let why = match (s.over_cap, s.undecoded, s.data.is_empty()) { (true, _, _) => Some(OVER), (_, true, true) => Some(BROKEN), (_, true, false) => Some(PART_WAY), // Nothing decoded and nothing left undecoded: an empty storage. - (_, false, true) => return None, + (_, false, true) => return Ok(None), (_, false, false) => None, }; - Some((s.data, why)) + Ok(Some((s.data, why))) } /// The payload bytes inside an `Ole10Native` stream, or `None` when the header @@ -459,403 +423,6 @@ fn push_artifact(entries: &mut Vec<Entry>, budget: &mut Budget, name: &str, art: entries.push(Entry::new(name.to_string(), art)); } -// --------------------------------------------------------------------------- -// Lenient (fault-tolerant) Compound File Binary reader -// --------------------------------------------------------------------------- -// -// Clean-room implementation of the [MS-CFB] container just far enough to -// recover stream *contents* from a compound file whose directory red-black tree -// is malformed (bad sibling ordering, adjacent red nodes, etc.). A strict reader -// aborts on such files; here we ignore the tree structure entirely and walk the -// directory sectors as a flat array of 128-byte entries, following the FAT and -// mini-FAT sector chains to reassemble each stream. Written purely from the -// public [MS-CFB] specification. - -const CFB_SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; -/// Highest value that names a real sector; everything above is a sentinel -/// (`FREESECT`/`ENDOFCHAIN`/`FATSECT`/`DIFSECT`/`NOSTREAM`). -const CFB_MAXREGSECT: u32 = 0xFFFF_FFFA; - -fn le16(b: &[u8], o: usize) -> u16 { - u16::from_le_bytes([b[o], b[o + 1]]) -} -fn le32(b: &[u8], o: usize) -> u32 { - u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) -} -fn le64(b: &[u8], o: usize) -> u64 { - let mut v = [0u8; 8]; - v.copy_from_slice(&b[o..o + 8]); - u64::from_le_bytes(v) -} - -/// Parse a Compound File Binary tolerantly, returning `(stream leaf name, bytes)` -/// for every directory entry marked as a stream. Returns `None` if `data` is not -/// a compound file (bad signature / too small) so the caller can report the -/// original strict-parse error instead of masking a genuine non-OLE input. -/// -/// `cap` bounds the bytes read for any single stream (the peak-buffer limit). -/// Each stream comes back as `(name, bytes, truncated)`. The flag matters: the -/// readers below stop at `cap`, and a prefix delivered as a whole stream would -/// hide whatever sits past it. -fn lenient_cfb_streams(data: &[u8], cap: u64) -> Option<Vec<(String, Vec<u8>, bool)>> { - if data.len() < 512 || data[..8] != CFB_SIGNATURE { - return None; - } - let sector_shift = le16(data, 30); - if !(7..=24).contains(§or_shift) { - return None; - } - let sector_size = 1usize << sector_shift; // v3 = 512, v4 = 4096 - let mini_shift = le16(data, 32); - if !(3..=12).contains(&mini_shift) { - return None; - } - let mini_sector_size = 1usize << mini_shift; // normally 64 - let v4 = le16(data, 26) >= 4; - let first_dir = le32(data, 48); - let mini_cutoff = le32(data, 56) as u64; // normally 4096 - let first_minifat = le32(data, 60); - let first_difat = le32(data, 68); - - let entries_per_sector = sector_size / 4; - // Upper bound on how many sectors any chain can traverse (loop guard). - let max_sectors = data.len() / sector_size + 2; - - let read_sector = |s: u32| -> Option<&[u8]> { - // Sector N begins at byte offset (N + 1) * sector_size (the header - // occupies the first sector regardless of version). - let off = (s as usize).checked_add(1)?.checked_mul(sector_size)?; - data.get(off..off.checked_add(sector_size)?) - }; - - // --- Assemble the DIFAT (list of FAT sector numbers) --------------------- - let mut fat_sectors: Vec<u32> = Vec::new(); - for i in 0..109 { - let v = le32(data, 76 + i * 4); - if v <= CFB_MAXREGSECT { - fat_sectors.push(v); - } - } - let mut difat = first_difat; - let mut guard = 0; - while difat <= CFB_MAXREGSECT && guard < max_sectors { - guard += 1; - let Some(sec) = read_sector(difat) else { break }; - for i in 0..entries_per_sector - 1 { - let v = le32(sec, i * 4); - if v <= CFB_MAXREGSECT { - fat_sectors.push(v); - } - } - difat = le32(sec, (entries_per_sector - 1) * 4); - if fat_sectors.len() > max_sectors { - break; - } - } - - // --- Build the FAT ------------------------------------------------------- - let mut fat: Vec<u32> = Vec::with_capacity(crate::cap_prealloc( - fat_sectors.len().saturating_mul(entries_per_sector), - )); - for &fs in &fat_sectors { - match read_sector(fs) { - Some(sec) => { - for i in 0..entries_per_sector { - fat.push(le32(sec, i * 4)); - } - } - None => break, - } - } - if fat.is_empty() { - return None; - } - - // Follow a FAT sector chain, bounded to avoid cycles on corrupt tables. - let follow = |start: u32| -> Vec<u32> { - let mut out = Vec::new(); - let mut cur = start; - while cur <= CFB_MAXREGSECT && (cur as usize) < fat.len() && out.len() < max_sectors { - out.push(cur); - cur = fat[cur as usize]; - } - out - }; - let read_fat_stream = |start: u32, size: u64| -> Vec<u8> { - let need = size.min(cap) as usize; - let mut out = Vec::with_capacity(need.min(1 << 20)); - for s in follow(start) { - if out.len() >= need { - break; - } - if let Some(sec) = read_sector(s) { - let take = sec.len().min(need - out.len()); - out.extend_from_slice(&sec[..take]); - } - } - out.truncate(need); - out - }; - - // --- Directory sectors (flat array of 128-byte entries) ------------------ - let mut dir = Vec::new(); - for s in follow(first_dir) { - if let Some(sec) = read_sector(s) { - dir.extend_from_slice(sec); - } - } - let n_entries = dir.len() / 128; - if n_entries == 0 { - return None; - } - - // Root entry (object type 5) anchors the mini stream. - let mut root = None; - let mut root_start = 0u32; - let mut root_size = 0u64; - for i in 0..n_entries { - let e = &dir[i * 128..i * 128 + 128]; - if e[66] == 5 { - root = Some(i); - root_start = le32(e, 116); - root_size = if v4 { - le64(e, 120) - } else { - le32(e, 120) as u64 - }; - break; - } - } - - // Each entry's path, from the tree: a storage's child and that child's - // siblings are its members. The sibling order the strict reader refuses - // does not matter here, and the VBA project is found by its `VBA` storage. - // An entry the walk does not reach keeps its bare name. - let entry_name = |i: usize| { - let e = &dir[i * 128..i * 128 + 128]; - cfb_entry_name(&e[..64], le16(e, 64) as usize) - }; - let mut paths: Vec<Option<String>> = vec![None; n_entries]; - let mut seen = vec![false; n_entries]; - let mut storages = Vec::new(); - if let Some(r) = root { - seen[r] = true; - storages.push((r, String::new())); - } - while let Some((storage, base)) = storages.pop() { - let mut members = vec![le32(&dir[storage * 128..storage * 128 + 128], 76)]; - while let Some(m) = members.pop() { - let m = m as usize; - if m >= n_entries || seen[m] { - continue; - } - seen[m] = true; - let e = &dir[m * 128..m * 128 + 128]; - members.extend([le32(e, 68), le32(e, 72)]); - let path = format!("{base}/{}", entry_name(m)); - if e[66] == 1 { - storages.push((m, path.clone())); - } - paths[m] = Some(path); - } - } - let mini_stream = read_fat_stream(root_start, root_size); - - // --- Mini-FAT ------------------------------------------------------------ - let mut minifat: Vec<u32> = Vec::new(); - for s in follow(first_minifat) { - if let Some(sec) = read_sector(s) { - for i in 0..entries_per_sector { - minifat.push(le32(sec, i * 4)); - } - } - if minifat.len() > max_sectors * entries_per_sector { - break; - } - } - let read_mini_stream = |start: u32, size: u64| -> Vec<u8> { - let need = size.min(cap) as usize; - let mut out = Vec::with_capacity(need.min(1 << 16)); - let mut cur = start; - let mut steps = 0; - while cur <= CFB_MAXREGSECT && (cur as usize) < minifat.len() && out.len() < need { - steps += 1; - if steps > minifat.len() + 1 { - break; // cycle guard - } - let off = (cur as usize) * mini_sector_size; - if let Some(chunk) = crate::bytes::at(&mini_stream, off, mini_sector_size) { - let take = chunk.len().min(need - out.len()); - out.extend_from_slice(&chunk[..take]); - } - cur = minifat[cur as usize]; - } - out.truncate(need); - out - }; - - // --- Collect every stream entry ------------------------------------------ - let mut streams = Vec::new(); - for i in 0..n_entries { - let e = &dir[i * 128..i * 128 + 128]; - if e[66] != 2 { - continue; // only streams (2); skip storage (1)/root (5)/unallocated (0) - } - let name_len = le16(e, 64) as usize; - let name = cfb_entry_name(&e[..64], name_len); - if name.is_empty() { - // A nameless stream entry (malformed directory). Its sectors are - // stored UNCOMPRESSED inside this same file, so the caller's raw - // pattern scan already covers those bytes — skipping the member here - // loses the name/size metadata for `.cdb` matching, not the content, - // so nothing goes unscanned by skipping it here. - continue; - } - let start = le32(e, 116); - let size = if v4 { - le64(e, 120) - } else { - le32(e, 120) as u64 - }; - let bytes = if size < mini_cutoff { - read_mini_stream(start, size) - } else { - read_fat_stream(start, size) - }; - // Both readers stop at `cap`. A stream the directory declares larger - // than that was cut by the budget, and the caller has to be told, - // because a prefix handed over as a whole stream is a payload past the - // cap that nothing ever scanned and nothing ever reported. One that - // comes back short of a size within the cap has a sector chain that - // ends early: there is nothing more of it in the file to read. - let truncated = size > cap && (bytes.len() as u64) < size; - streams.push((paths[i].take().unwrap_or(name), bytes, truncated)); - if streams.len() >= 4096 { - break; - } - } - if streams.is_empty() { - return None; - } - Some(streams) -} - -/// Decode a directory entry name: `name_len` bytes of UTF-16LE from a 64-byte -/// field, including the terminating NUL (so the character count is -/// `name_len / 2 - 1`). -fn cfb_entry_name(raw: &[u8], name_len: usize) -> String { - let chars = (name_len / 2).saturating_sub(1).min(32); - let mut u16s = Vec::with_capacity(chars); - for i in 0..chars { - u16s.push(le16(raw, i * 2)); - } - String::from_utf16_lossy(&u16s) -} - -/// Build the OLE [`Entry`] list from an already-extracted stream set (each a leaf -/// name with its raw bytes). Mirrors the tail of [`collect_ole_entries`] but -/// operates purely in memory, so it serves the lenient fallback path. Handles MSI -/// name decompression, OOXML/legacy encryption detection + decryption, and VBA/XLM -/// macro artifacts. -fn assemble_ole_entries( - streams: Vec<(String, Vec<u8>, bool)>, - total_len: u64, - budget: &mut Budget, -) -> Result<Vec<Entry>, LimitHit> { - let leaf = |n: &str| -> String { n.rsplit(['/', '\\']).next().unwrap_or(n).to_string() }; - let find = |want: &str| -> Option<&Vec<u8>> { - streams - .iter() - .find(|(n, _, _)| leaf(n).eq_ignore_ascii_case(want)) - .map(|(_, d, _)| d) - }; - - let msi = streams.iter().any(|(n, _, _)| { - let d = decompress_msi_name(&leaf(n)); - d == "_Tables" || d == "!_Tables" - }); - - // OOXML encrypted container: decrypt the standard scheme or surface an - // encrypted member — never a silent clean. - #[cfg_attr(not(feature = "decrypt"), allow(unused_variables))] - if let (Some(info), Some(pkg)) = (find("EncryptionInfo"), find("EncryptedPackage")) { - #[cfg(feature = "decrypt")] - let decrypted = super::ole_crypto::try_decrypt_ooxml( - info.as_slice(), - pkg.as_slice(), - &budget.passwords, - ); - #[cfg(not(feature = "decrypt"))] - let decrypted: Option<Vec<u8>> = None; - budget.count_entry()?; - return match decrypted { - Some(zip) => { - budget.commit(zip.len() as u64); - Ok(vec![Entry::new("EncryptedPackage.zip".to_string(), zip)]) - } - None => Ok(vec![Entry::unsupported( - "EncryptedPackage".to_string(), - total_len, - true, - "encrypted Office document", - )]), - }; - } - - let mut entries = Vec::new(); - for (name, data, truncated) in streams { - budget.count_entry()?; - let (buf, was_decrypted) = match legacy_encryption(&name, data, budget) { - Legacy::Plain(b) => (b, false), - Legacy::Decrypted(b) => (b, true), - Legacy::Locked => { - entries.push(Entry::unsupported( - name, - total_len, - true, - "encrypted Office document", - )); - continue; - } - }; - let cap = budget.reserve()?; - if buf.len() as u64 > cap { - return Err(LimitHit::new("ole stream exceeds budget".to_string())); - } - budget.commit(buf.len() as u64); - let out_name = if msi { - decompress_msi_name(&leaf(&name)) - } else { - name.clone() - }; - // A stream the reader had to cut short is reported alongside the part - // that fits, the same way an over-budget safetensors header is. Handing - // back only the prefix would let a payload past the cap go unscanned - // with nothing said about it. - if truncated { - entries.push(Entry::unsupported( - out_name.clone(), - buf.len() as u64, - false, - "stream exceeds the per-member size budget; only its head was read", - )); - } - // Decrypted streams stay marked encrypted — same rule as the strict path - // above, applied here too because the lenient fallback is the one a - // malformed compound file actually takes. - let mut entry = Entry::new(out_name, buf); - entry.encrypted = was_decrypted; - entries.push(entry); - } - - append_ole10native_payloads(&mut entries, budget); - // Before the macro pass: an embedded storage can itself hold the VBA - // project, and the artifacts are synthesised from whatever is present. - append_ppt_embedded_storages(&mut entries, budget); - append_macro_artifacts(&mut entries, budget); - Ok(entries) -} - /// A stream after the legacy `.doc`/`.xls` encryption check. enum Legacy { Plain(Vec<u8>), @@ -886,10 +453,9 @@ fn legacy_encryption(path: &str, data: Vec<u8>, budget: &Budget) -> Legacy { return Legacy::Plain(data); } #[cfg(feature = "decrypt")] - if is_excel { - if let Some(dec) = super::ole_crypto::try_decrypt_workbook(&data, &budget.passwords) { - return Legacy::Decrypted(dec); - } + if is_excel && let Some(dec) = super::ole_crypto::try_decrypt_workbook(&data, &budget.passwords) + { + return Legacy::Decrypted(dec); } #[cfg(not(feature = "decrypt"))] let _ = budget; @@ -922,14 +488,10 @@ fn xls_has_filepass(head: &[u8]) -> bool { /// Check if an OLE2 compound file is an MSI database by testing whether any /// stream name decompresses to `_Tables` (WiX uses `!_Tables` prefix). -fn is_msi_database(paths: &[std::path::PathBuf]) -> bool { - paths.iter().any(|p| { - p.file_name() - .map(|s| { - let d = decompress_msi_name(&s.to_string_lossy()); - d == "_Tables" || d == "!_Tables" - }) - .unwrap_or(false) +fn is_msi_database(streams: &[cfb::StreamInfo<'_>]) -> bool { + streams.iter().any(|s| { + let d = decompress_msi_name(s.name); + d == "_Tables" || d == "!_Tables" }) } @@ -1063,13 +625,13 @@ mod macro_artifact_tests { let mut b = Budget::new(Limits::default()); let intact = compressed(b"storage"); - let got = ppt_storage_payload(1, &intact, &mut b).unwrap(); + let got = ppt_storage_payload(1, &intact, &mut b).unwrap().unwrap(); assert_eq!(got, (b"storage".to_vec(), None)); let mut bad_adler = intact.clone(); let n = bad_adler.len(); bad_adler[n - 1] ^= 1; - let got = ppt_storage_payload(1, &bad_adler, &mut b).unwrap(); + let got = ppt_storage_payload(1, &bad_adler, &mut b).unwrap().unwrap(); assert_eq!(got, (b"storage".to_vec(), None)); // Damaged after a long run of content: that run, and a reason. @@ -1080,18 +642,22 @@ mod macro_artifact_tests { let mut damaged = vec![0, 0, 0, 0, 0x78, 0x9c]; damaged.extend_from_slice(e.get_ref()); damaged.extend_from_slice(&[0x06, 0x5a, 0x5a]); - let (data, why) = ppt_storage_payload(1, &damaged, &mut b).unwrap(); + let (data, why) = ppt_storage_payload(1, &damaged, &mut b).unwrap().unwrap(); assert_eq!(data, content); assert!(why.is_some_and(|w| w.contains("part way")), "{why:?}"); - let (data, why) = ppt_storage_payload(1, b"\0\0\0\0not zlib", &mut b).unwrap(); + let (data, why) = ppt_storage_payload(1, b"\0\0\0\0not zlib", &mut b) + .unwrap() + .unwrap(); assert!(data.is_empty() && why.is_some()); let mut small = Budget::new(Limits { max_buffer_bytes: 4, ..Limits::default() }); - let (data, why) = ppt_storage_payload(0, b"stored storage", &mut small).unwrap(); + let (data, why) = ppt_storage_payload(0, b"stored storage", &mut small) + .unwrap() + .unwrap(); assert!(data.is_empty() && why.is_some()); } diff --git a/crates/exav-unpack/src/formats/ole_crypto.rs b/crates/exav-unpack/src/formats/ole_crypto.rs index 0fbdc03d..c33eaa9d 100644 --- a/crates/exav-unpack/src/formats/ole_crypto.rs +++ b/crates/exav-unpack/src/formats/ole_crypto.rs @@ -19,19 +19,9 @@ //! `EncryptedPackage` compound file here and scans the recovered `.zip`; a //! document that still won't open is reported password-protected, never clean. -use md5::{Digest, Md5}; -use sha1::Sha1; +use sha1::{Digest, Sha1}; -/// Passwords exav tries automatically on an encrypted Office document before -/// giving up and reporting it password-protected: the Excel default (opens with -/// no prompt, the common malware trick) and the empty password. -pub(crate) const DEFAULT_OFFICE_PASSWORDS: &[&str] = &["VelvetSweatshop", ""]; - -fn md5(data: &[u8]) -> [u8; 16] { - let mut h = Md5::new(); - h.update(data); - h.finalize().into() -} +use super::offcrypto::DEFAULT_PASSWORDS as DEFAULT_OFFICE_PASSWORDS; fn sha1(data: &[u8]) -> [u8; 20] { let mut h = Sha1::new(); @@ -43,453 +33,18 @@ fn utf16le(s: &str) -> Vec<u8> { s.encode_utf16().flat_map(u16::to_le_bytes).collect() } -/// RC4 stream cipher (symmetric: the same routine decrypts and encrypts). -fn rc4(key: &[u8], data: &[u8]) -> Vec<u8> { - let mut s: [u8; 256] = std::array::from_fn(|i| i as u8); - let mut j = 0usize; - for i in 0..256 { - j = (j + s[i] as usize + key[i % key.len()] as usize) & 0xff; - s.swap(i, j); - } - let (mut i, mut j) = (0usize, 0usize); - let mut out = Vec::with_capacity(data.len()); - for &b in data { - i = (i + 1) & 0xff; - j = (j + s[i] as usize) & 0xff; - s.swap(i, j); - let k = s[(s[i] as usize + s[j] as usize) & 0xff]; - out.push(b ^ k); - } - out -} - -/// The RC4 key schedule for a password, in one of the two legacy schemes. -enum KeyDeriver { - /// RC4 basic ([MS-OFFCRYPTO] §2.3.6.2): MD5-based, 5-byte intermediate. - Basic([u8; 5]), - /// RC4-CryptoAPI ([MS-OFFCRYPTO] §2.3.5.2): SHA-1-based, `key_bytes`-long key. - CryptoApi { h0: [u8; 20], key_bytes: usize }, -} - -impl KeyDeriver { - fn basic(salt: &[u8], pw: &str) -> Self { - let h0 = md5(&utf16le(pw)); - let mut buf = Vec::with_capacity(21 * 16); - for _ in 0..16 { - buf.extend_from_slice(&h0[..5]); - buf.extend_from_slice(salt); - } - let h1 = md5(&buf); - let mut inter = [0u8; 5]; - inter.copy_from_slice(&h1[..5]); - KeyDeriver::Basic(inter) - } - - fn cryptoapi(salt: &[u8], pw: &str, key_bits: u32) -> Self { - let mut m = Vec::with_capacity(salt.len() + pw.len() * 2); - m.extend_from_slice(salt); - m.extend_from_slice(&utf16le(pw)); - // A zero KeySize means the 40-bit default. - let key_bytes = if key_bits == 0 { - 5 - } else { - (key_bits / 8) as usize - }; - KeyDeriver::CryptoApi { - h0: sha1(&m), - key_bytes, - } - } - - /// Per-block RC4 key; the stream is re-keyed every 1024 bytes. - fn block_key(&self, block: u32) -> Vec<u8> { - match self { - KeyDeriver::Basic(inter) => { - let mut kd = [0u8; 9]; - kd[..5].copy_from_slice(inter); - kd[5..].copy_from_slice(&block.to_le_bytes()); - md5(&kd).to_vec() - } - KeyDeriver::CryptoApi { h0, key_bytes } => { - let mut kd = Vec::with_capacity(24); - kd.extend_from_slice(h0); - kd.extend_from_slice(&block.to_le_bytes()); - let hf = sha1(&kd); - if *key_bytes <= 5 { - // 40-bit: 5 real key bytes zero-padded to a 16-byte RC4 key. - let mut k = vec![0u8; 16]; - k[..5].copy_from_slice(&hf[..5]); - k - } else { - hf[..(*key_bytes).min(hf.len())].to_vec() - } - } - } - } - - /// The verifier hash: MD5 for basic, SHA-1 for CryptoAPI. - fn hash(&self, data: &[u8]) -> Vec<u8> { - match self { - KeyDeriver::Basic(_) => md5(data).to_vec(), - KeyDeriver::CryptoApi { .. } => sha1(data).to_vec(), - } - } -} - -/// The parameters parsed from a `FilePass` record. -struct FilePass { - salt: Vec<u8>, - enc_verifier: Vec<u8>, - enc_vhash: Vec<u8>, - /// `None` = RC4 basic; `Some(key_bits)` = RC4-CryptoAPI. - cryptoapi_key_bits: Option<u32>, -} - -impl FilePass { - fn deriver(&self, pw: &str) -> KeyDeriver { - match self.cryptoapi_key_bits { - None => KeyDeriver::basic(&self.salt, pw), - Some(bits) => KeyDeriver::cryptoapi(&self.salt, pw, bits), - } - } - - /// [MS-OFFCRYPTO] §2.3.6.3 / §2.3.5.6: the block-0 keystream decrypts the - /// 16-byte verifier followed continuously by its hash; the password is - /// correct when `H(verifier) == verifierHash`. - fn password_ok(&self, pw: &str) -> bool { - if self.enc_verifier.len() != 16 || self.enc_vhash.is_empty() { - return false; - } - let d = self.deriver(pw); - let mut blob = Vec::with_capacity(self.enc_verifier.len() + self.enc_vhash.len()); - blob.extend_from_slice(&self.enc_verifier); - blob.extend_from_slice(&self.enc_vhash); - let dec = rc4(&d.block_key(0), &blob); - d.hash(&dec[..16]) == dec[16..] - } -} - -/// Parse the `FilePass` record (BIFF record `0x2F`) that must follow `BOF` at the -/// start of an encrypted `Workbook`/`Book` stream. Handles RC4 basic (version -/// major 1) and RC4-CryptoAPI (major ≥ 2); XOR obfuscation (`wEncryptionType 0`) -/// is left for the detect-only path. -fn parse_filepass(stream: &[u8]) -> Option<FilePass> { - if stream.len() < 4 { - return None; - } - let bof_size = u16::from_le_bytes([stream[2], stream[3]]) as usize; - let fp = 4 + bof_size; - let body = fp + 4; - if body > stream.len() || u16::from_le_bytes([stream[fp], stream[fp + 1]]) != 0x2f { - return None; - } - let size = u16::from_le_bytes([stream[fp + 2], stream[fp + 3]]) as usize; - let rec = crate::bytes::at(stream, body, size)?; - if rec.len() < 6 || u16::from_le_bytes([rec[0], rec[1]]) != 1 { - return None; // XOR obfuscation or none - } - let major = u16::from_le_bytes([rec[2], rec[3]]); - if major == 1 { - // RC4 basic: salt(16) ‖ encVerifier(16) ‖ encVerifierHash(16). - let v = rec.get(6..54)?; - Some(FilePass { - salt: v[0..16].to_vec(), - enc_verifier: v[16..32].to_vec(), - enc_vhash: v[32..48].to_vec(), - cryptoapi_key_bits: None, - }) - } else { - // RC4-CryptoAPI: Flags(4) HeaderSize(4) EncryptionHeader{…KeySize@16…} - // then EncryptionVerifier{SaltSize(4) Salt EncVerifier(16) HashSize(4) Hash}. - let hdr_meta = rec.get(6..14)?; - let header_size = u32::from_le_bytes(hdr_meta[4..8].try_into().ok()?) as usize; - let header = crate::bytes::at(rec, 14, header_size)?; - let key_bits = u32::from_le_bytes(header.get(16..20)?.try_into().ok()?); - let v = rec.get(14usize.checked_add(header_size)?..)?; - let salt_size = u32::from_le_bytes(v.get(0..4)?.try_into().ok()?) as usize; - let salt = crate::bytes::at(v, 4, salt_size)?.to_vec(); - let ev_off = 4usize.checked_add(salt_size)?; - let enc_verifier = crate::bytes::at(v, ev_off, 16)?.to_vec(); - let vh = ev_off.checked_add(16)?; - let vhash_size = u32::from_le_bytes(crate::bytes::at(v, vh, 4)?.try_into().ok()?) as usize; - let enc_vhash = crate::bytes::at(v, vh.checked_add(4)?, vhash_size)?.to_vec(); - Some(FilePass { - salt, - enc_verifier, - enc_vhash, - cryptoapi_key_bits: Some(key_bits), - }) - } -} - -// Record IDs whose *data* stays in cleartext ([MS-OFFCRYPTO] §2.3.6.1): BOF, -// FilePass, UsrExcl, FileLock, InterfaceHdr, RRDInfo, RRDHead. -const EXEMPT: &[u16] = &[0x0809, 0x002f, 0x0194, 0x0195, 0x00e1, 0x0196, 0x0138]; -// BoundSheet8: its 4-byte `lbPlyPos` (right after the header) also stays cleartext. -const BOUNDSHEET8: u16 = 0x0085; -const BLOCK: usize = 1024; - -/// Decrypt a whole `Workbook`/`Book` stream in place following the BIFF record -/// structure: record headers stay cleartext, exempt records' data stays -/// cleartext, and every other record's data is RC4-decrypted, while the cipher -/// position advances continuously over the cleartext regions (re-keyed each -/// 1024-byte block). See [MS-OFFCRYPTO] §2.3.6.1. -fn decrypt_workbook(stream: &[u8], deriver: &KeyDeriver) -> Vec<u8> { - // `cipher_in` is the same length as the stream: cleartext positions are zero - // (so the cipher still advances over them) and encrypted positions carry the - // real bytes. `plain[pos] = Some(byte)` overlays the cleartext afterwards. - let mut cipher_in = vec![0u8; stream.len()]; - let mut plain: Vec<Option<u8>> = vec![None; stream.len()]; - let mut p = 0usize; - while p + 4 <= stream.len() { - let num = u16::from_le_bytes([stream[p], stream[p + 1]]); - let size = u16::from_le_bytes([stream[p + 2], stream[p + 3]]) as usize; - let dstart = p + 4; - let dend = (dstart + size).min(stream.len()); - // Record header is always cleartext. - for k in 0..4.min(stream.len() - p) { - plain[p + k] = Some(stream[p + k]); - } - if num == 0x2f { - // FilePass: neutralise the type to 0 but keep the size field; zero data. - plain[p] = Some(0); - plain[p + 1] = Some(0); - for pos in plain.iter_mut().take(dend).skip(dstart) { - *pos = Some(0); - } - } else if EXEMPT.contains(&num) { - for k in dstart..dend { - plain[k] = Some(stream[k]); - } - } else if num == BOUNDSHEET8 { - for k in dstart..(dstart + 4).min(dend) { - plain[k] = Some(stream[k]); // lbPlyPos stays cleartext - } - let s = (dstart + 4).min(dend); - cipher_in[s..dend].copy_from_slice(&stream[s..dend]); - } else { - cipher_in[dstart..dend].copy_from_slice(&stream[dstart..dend]); - } - p = dend; - } - // RC4 the buffer, re-keying every 1024 bytes. - let mut out = Vec::with_capacity(stream.len()); - for (b, chunk) in cipher_in.chunks(BLOCK).enumerate() { - out.extend_from_slice(&rc4(&deriver.block_key(b as u32), chunk)); - } - // Overlay the cleartext skeleton. - for (pos, &pb) in plain.iter().enumerate() { - if let Some(byte) = pb { - out[pos] = byte; - } - } - out -} - -/// Try to decrypt an encrypted BIFF8 `Workbook`/`Book` stream with the caller's -/// password pool plus exav's [`DEFAULT_OFFICE_PASSWORDS`]. Handles the RC4 schemes -/// (basic / CryptoAPI) and the legacy XOR obfuscation (`wEncryptionType == 0`). -/// Returns the decrypted stream on the first password that verifies, or `None` -/// if the scheme is unhandled or no password matched (the caller then reports -/// password-protected, never a silent clean). +/// An encrypted BIFF `Workbook`/`Book` stream decrypted with the caller's +/// password pool plus the default passwords ([`DEFAULT_OFFICE_PASSWORDS`]), by +/// [`offcrypto`](super::offcrypto): `None` if the scheme is not handled or no +/// password matched (the caller then reports password-protected, never a silent +/// clean). pub(crate) fn try_decrypt_workbook(stream: &[u8], passwords: &[String]) -> Option<Vec<u8>> { let candidates: Vec<&str> = passwords .iter() .map(String::as_str) .chain(DEFAULT_OFFICE_PASSWORDS.iter().copied()) .collect(); - // RC4 basic / CryptoAPI (wEncryptionType == 1). - if let Some(fp) = parse_filepass(stream) { - for pw in &candidates { - if fp.password_ok(pw) { - return Some(decrypt_workbook(stream, &fp.deriver(pw))); - } - } - return None; - } - // Legacy XOR obfuscation (wEncryptionType == 0). - if let Some(verifier) = parse_filepass_xor(stream) { - for pw in &candidates { - if xor_verify_password(pw, verifier) { - return Some(decrypt_workbook_xor(stream, pw)); - } - } - } - None -} - -// ─────────────────── BIFF XOR obfuscation ([MS-OFFCRYPTO] §2.3.7) ─────────────── -// -// The legacy `wEncryptionType == 0` scheme obfuscates each record's data with a -// 16-byte array derived from the password (`ROR(byte ^ pad, 5)` per byte), with -// the same cleartext-exempt records as the RC4 path. Clean-room from the public -// [MS-OFFCRYPTO] §2.3.7 tables/algorithms, cross-checked against the MIT -// msoffcrypto reference vectors. - -#[rustfmt::skip] -const XOR_PAD: [u8; 15] = [ - 0xBB, 0xFF, 0xFF, 0xBA, 0xFF, 0xFF, 0xB9, 0x80, - 0x00, 0xBE, 0x0F, 0x00, 0xBF, 0x0F, 0x00, -]; -#[rustfmt::skip] -const XOR_INITIAL: [u16; 15] = [ - 0xE1F0, 0x1D0F, 0xCC9C, 0x84C0, 0x110C, 0x0E10, 0xF1CE, 0x313E, - 0x1872, 0xE139, 0xD40F, 0x84F9, 0x280C, 0xA96A, 0x4EC3, -]; -#[rustfmt::skip] -const XOR_MATRIX: [u16; 105] = [ - 0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09, 0x7B61, 0xF6C2, 0xFDA5, - 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF, 0x4563, 0x8AC6, 0x05AD, 0x0B5A, 0x16B4, 0x2D68, - 0x5AD0, 0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40, 0xD849, 0xA0B3, - 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5, 0x6F45, 0xDE8A, 0xAD35, 0x4A4B, 0x9496, - 0x390D, 0x721A, 0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9, 0x47D3, - 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4, 0x7B68, 0xF6D0, 0xB861, 0x60E3, 0xC1C6, 0x93AD, - 0x377B, 0x6EF6, 0xDDEC, 0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10, - 0xAA51, 0x4483, 0x8906, 0x022D, 0x045A, 0x08B4, 0x1168, 0x76B4, 0xED68, 0xCAF1, - 0x85C3, 0x1BA7, 0x374E, 0x6E9C, 0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6, - 0x1DAD, 0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC, 0x1021, 0x2042, - 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4, -]; - -/// `ROR(b1 ^ b2, 1)` over 8 bits: the per-element mixing of the XOR array. -fn xor_ror(b1: u8, b2: u8) -> u8 { - (b1 ^ b2).rotate_right(1) -} - -/// [MS-OFFCRYPTO] §2.3.7.1 password verifier derivation. `true` if `pw` matches -/// the stored `verification_bytes`. -fn xor_verify_password(pw: &str, verification_bytes: u16) -> bool { - let bytes = pw.as_bytes(); - if bytes.is_empty() || bytes.len() > 15 { - return false; - } - let mut arr: Vec<u16> = Vec::with_capacity(bytes.len() + 1); - arr.push(bytes.len() as u16); - arr.extend(bytes.iter().map(|&b| b as u16)); - arr.reverse(); - let mut verifier: u16 = 0; - for b in arr { - let i1 = if verifier & 0x4000 == 0 { 0 } else { 1 }; - let i2 = verifier.wrapping_mul(2) & 0x7FFF; - verifier = (i1 ^ i2) ^ b; - } - (verifier ^ 0xCE4B) == verification_bytes -} - -/// [MS-OFFCRYPTO] §2.3.7.2 `CreateXorKey_Method1`. -fn xor_create_key(pw: &[u8]) -> u16 { - let mut xor_key = XOR_INITIAL[pw.len() - 1]; - let mut current_element: i32 = 0x68; - for &ch in pw.iter().rev() { - let mut c = ch; - for _ in 0..7 { - if c & 0x40 != 0 { - xor_key ^= XOR_MATRIX[current_element as usize]; - } - c <<= 1; - current_element -= 1; - } - } - xor_key -} - -/// [MS-OFFCRYPTO] §2.3.7.2 `CreateXorArray_Method1`: the 16-byte obfuscation -/// array. `None` if the password length isn't 1..=15 (the scheme's range). -fn xor_create_array(pw: &str) -> Option<[u8; 16]> { - let pw = pw.as_bytes(); - if pw.is_empty() || pw.len() > 15 { - return None; - } - let xor_key = xor_create_key(pw); - let hi = (xor_key >> 8) as u8; - let lo = (xor_key & 0xFF) as u8; - let mut obf = [0u8; 16]; - let mut index = pw.len(); - - if index % 2 == 1 { - obf[index] = xor_ror(XOR_PAD[0], hi); - index -= 1; - obf[index] = xor_ror(pw[pw.len() - 1], lo); - } - while index > 0 { - index -= 1; - obf[index] = xor_ror(pw[index], hi); - index -= 1; - obf[index] = xor_ror(pw[index], lo); - } - - let mut index = 15usize; - let mut pad_index = 15 - pw.len(); - while pad_index > 0 { - obf[index] = xor_ror(XOR_PAD[pad_index], hi); - index -= 1; - pad_index -= 1; - obf[index] = xor_ror(XOR_PAD[pad_index], lo); - // An odd count of pad bytes ends here, one step short of two. - index = index.saturating_sub(1); - pad_index = pad_index.saturating_sub(1); - } - Some(obf) -} - -/// Locate the `FilePass` record and, if it is the XOR scheme -/// (`wEncryptionType == 0`), return its 16-bit `verificationBytes`. -fn parse_filepass_xor(stream: &[u8]) -> Option<u16> { - if stream.len() < 4 { - return None; - } - let bof_size = u16::from_le_bytes([stream[2], stream[3]]) as usize; - let fp = 4 + bof_size; - let body = fp + 4; - if body > stream.len() || u16::from_le_bytes([stream[fp], stream[fp + 1]]) != 0x2f { - return None; - } - let size = u16::from_le_bytes([stream[fp + 2], stream[fp + 3]]) as usize; - let rec = crate::bytes::at(stream, body, size)?; - // XORObfuscation: wEncryptionType(2)=0, Key(2), VerificationBytes(2). - if rec.len() < 6 || u16::from_le_bytes([rec[0], rec[1]]) != 0 { - return None; - } - Some(u16::from_le_bytes([rec[4], rec[5]])) -} - -/// Decrypt a BIFF `Workbook`/`Book` stream obfuscated with the XOR scheme -/// ([MS-OFFCRYPTO] §2.3.7.3 `DecryptData_Method1`). Record headers and the -/// cleartext-exempt records (BOF/FilePass/… and BoundSheet8's `lbPlyPos`) stay -/// verbatim; every other record's data is de-obfuscated `ROR(byte ^ pad, 5)`, -/// with the array index seeded from the record's end offset in the stream. -fn decrypt_workbook_xor(stream: &[u8], password: &str) -> Vec<u8> { - let Some(arr) = xor_create_array(password) else { - return stream.to_vec(); - }; - let mut out = stream.to_vec(); - // De-obfuscate one encrypted run: `idx0` seeds the array position. - let deobf = |out: &mut [u8], start: usize, end: usize, idx0: usize| { - let mut idx = idx0 % 16; - for k in start..end { - out[k] = (stream[k] ^ arr[idx]).rotate_right(5); - idx = (idx + 1) % 16; - } - }; - let mut p = 0usize; - while p + 4 <= stream.len() { - let num = u16::from_le_bytes([stream[p], stream[p + 1]]); - let size = u16::from_le_bytes([stream[p + 2], stream[p + 3]]) as usize; - let dstart = p + 4; - let dend = (dstart + size).min(stream.len()); - if EXEMPT.contains(&num) { - // header + data stay cleartext (already copied) - } else if num == BOUNDSHEET8 { - // lbPlyPos (first 4 data bytes) stays cleartext; the rest is a run - // whose seed carries the +4 offset the spec applies to it. - let run = (dstart + 4).min(dend); - deobf(&mut out, run, dend, dend + 4); - } else { - deobf(&mut out, dstart, dend, dend); - } - p = dend; - } - out + super::offcrypto::decrypt_workbook(stream, &candidates).ok() } // ─────────────────────────── OOXML (.docx/.xlsx) AES ─────────────────────────── @@ -930,24 +485,6 @@ mod tests { ); } - #[test] - fn xor_arrays_for_every_password_length_are_built() { - for n in 0..=16 { - let pw = "a".repeat(n); - assert_eq!( - xor_create_array(&pw).is_some(), - (1..=15).contains(&n), - "length {n}" - ); - } - } - - #[test] - fn a_cryptoapi_key_size_past_the_hash_is_cut_to_the_hash() { - let k = KeyDeriver::cryptoapi(&[0u8; 16], "pw", 2048); - assert!(k.block_key(0).len() <= 20); - } - #[test] fn a_short_standard_encryption_info_is_not_parsed_not_a_panic() { for len in 0..=11 { @@ -973,20 +510,22 @@ mod tests { v.extend_from_slice(data); } - /// Build a `Workbook` stream (BOF, `FilePass`, an EICAR record, EOF) and - /// encrypt the EICAR record's data with the block-0 keystream (the whole - /// stream fits one 1024-byte block). - fn encrypted_workbook(d: &KeyDeriver, filepass_body: &[u8]) -> Vec<u8> { + /// A `Workbook` stream (BOF, `FilePass`, an EICAR record, EOF) encrypted + /// with the default password by `method`. + fn encrypted_workbook(method: &str) -> Vec<u8> { + use super::super::offcrypto::encrypt::{encrypt_stream, filepass}; let mut wb = Vec::new(); - push_rec(&mut wb, 0x0809, &[0u8; 16]); // BOF - push_rec(&mut wb, 0x002f, filepass_body); // FilePass - let off = wb.len() + 4; + // BOF of BIFF8. + push_rec( + &mut wb, + 0x0809, + &[0x00, 0x06, 0x05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ); + let at = wb.len(); + push_rec(&mut wb, 0x002f, &filepass(method, true)); // FilePass push_rec(&mut wb, 0x00ff, eicar()); // non-exempt → encrypted push_rec(&mut wb, 0x000a, &[]); // EOF - let ks = rc4(&d.block_key(0), &vec![0u8; wb.len()]); - for i in 0..eicar().len() { - wb[off + i] ^= ks[off + i]; - } + encrypt_stream(method, &mut wb, at); assert!( !wb.windows(eicar().len()).any(|w| w == eicar()), "EICAR encrypted" @@ -994,70 +533,21 @@ mod tests { wb } - /// Encrypted verifier pair for a password/scheme: `verifier ‖ H(verifier)` - /// under the block-0 keystream. - fn verifier_pair(d: &KeyDeriver, verifier: &[u8]) -> Vec<u8> { - let mut vblob = verifier.to_vec(); - vblob.extend_from_slice(&d.hash(verifier)); - rc4(&d.block_key(0), &vblob) - } - - // Independently-generated vector (salt 00..0f, password "Secret1"). + /// End-to-end: the default password recovers the cleartext EICAR of a + /// workbook with no caller password, whichever the scheme. #[test] - fn rc4_basic_verifier_and_key() { - let salt = (0u8..16).collect::<Vec<_>>(); - let fp = FilePass { - salt: salt.clone(), - enc_verifier: hex("d11cfb57cde29a6eaf45018e01931b31").to_vec(), - enc_vhash: hex("9687999033ca45aaade5f45386030a07").to_vec(), - cryptoapi_key_bits: None, - }; - assert!(fp.password_ok("Secret1")); - assert!(!fp.password_ok("wrong")); - assert!(!fp.password_ok("VelvetSweatshop")); - match KeyDeriver::basic(&salt, "Secret1") { - KeyDeriver::Basic(inter) => assert_eq!(inter, hex5("cabe9bb94c")), - _ => panic!(), + fn velvetsweatshop_recovers_eicar() { + for method in ["rc4", "cryptoapi40", "cryptoapi128", "xor"] { + let ct = encrypted_workbook(method); + let recovered = try_decrypt_workbook(&ct, &[]) + .unwrap_or_else(|| panic!("{method}: VelvetSweatshop must decrypt")); + assert!( + recovered.windows(eicar().len()).any(|w| w == eicar()), + "{method}" + ); } } - // Independently-generated vector, cross-checked against the msoffcrypto - // reference `verifypw` (salt 10..1f, password "VelvetSweatshop", 128-bit). - #[test] - fn rc4_cryptoapi_verifier_vector() { - let salt = (16u8..32).collect::<Vec<_>>(); - let fp = FilePass { - salt, - enc_verifier: hex("66871900f3075fc3fa7e1d9b934d6b22").to_vec(), - enc_vhash: hex20("13146eb6c265735c52ea6eb1cc48739e80561620").to_vec(), - cryptoapi_key_bits: Some(128), - }; - assert!(fp.password_ok("VelvetSweatshop")); - assert!(!fp.password_ok("nope")); - } - - #[test] - fn rc4_is_symmetric() { - let k = b"key1234567890abc"; - let pt = b"eicar-ish plaintext content"; - assert_eq!(rc4(k, &rc4(k, pt)), pt); - } - - /// End-to-end (RC4 basic): VelvetSweatshop default recovers cleartext EICAR - /// with no caller password. - #[test] - fn velvetsweatshop_basic_recovers_eicar() { - let salt = [0x5au8; 16]; - let d = KeyDeriver::basic(&salt, "VelvetSweatshop"); - let enc = verifier_pair(&d, &[0x24u8; 16]); - let mut fp = vec![1u8, 0, 1, 0, 1, 0]; // encType=1, ver 1.1 - fp.extend_from_slice(&salt); - fp.extend_from_slice(&enc[..32]); - let ct = encrypted_workbook(&d, &fp); - let recovered = try_decrypt_workbook(&ct, &[]).expect("VelvetSweatshop must decrypt"); - assert!(recovered.windows(eicar().len()).any(|w| w == eicar())); - } - /// A workbook embedded in another, as Excel stores an inserted workbook /// object, is decrypted too: only the first workbook stream was checked, /// and a plain one at the top hid an encrypted one below it. Excel names @@ -1066,13 +556,7 @@ mod tests { #[test] fn an_embedded_encrypted_workbook_is_decrypted() { use std::io::Write; - let salt = [0x5au8; 16]; - let d = KeyDeriver::basic(&salt, "VelvetSweatshop"); - let enc = verifier_pair(&d, &[0x24u8; 16]); - let mut fp = vec![1u8, 0, 1, 0, 1, 0]; - fp.extend_from_slice(&salt); - fp.extend_from_slice(&enc[..32]); - let ct = encrypted_workbook(&d, &fp); + let ct = encrypted_workbook("rc4"); for storage in ["/MBD0001", "/MBD0012AB34"] { let mut cf = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap(); @@ -1102,34 +586,6 @@ mod tests { } } - /// End-to-end (RC4-CryptoAPI, 128-bit): VelvetSweatshop default recovers EICAR. - #[test] - fn velvetsweatshop_cryptoapi_recovers_eicar() { - let salt = [0x3cu8; 16]; - let d = KeyDeriver::cryptoapi(&salt, "VelvetSweatshop", 128); - let enc = verifier_pair(&d, &[0x24u8; 16]); // 16 + 20 = 36 bytes - // FilePass: encType(1) vMaj(3) vMin(3) Flags(4) HeaderSize(4) Header Verifier - let mut header = vec![0u8; 32]; - header[16..20].copy_from_slice(&128u32.to_le_bytes()); // KeySize - let mut vblk = Vec::new(); - vblk.extend_from_slice(&16u32.to_le_bytes()); - vblk.extend_from_slice(&salt); - vblk.extend_from_slice(&enc[..16]); - vblk.extend_from_slice(&20u32.to_le_bytes()); - vblk.extend_from_slice(&enc[16..36]); - let mut fp = Vec::new(); - fp.extend_from_slice(&1u16.to_le_bytes()); - fp.extend_from_slice(&3u16.to_le_bytes()); - fp.extend_from_slice(&3u16.to_le_bytes()); - fp.extend_from_slice(&0x24u32.to_le_bytes()); - fp.extend_from_slice(&(header.len() as u32).to_le_bytes()); - fp.extend_from_slice(&header); - fp.extend_from_slice(&vblk); - let ct = encrypted_workbook(&d, &fp); - let recovered = try_decrypt_workbook(&ct, &[]).expect("CryptoAPI VelvetSweatshop decrypt"); - assert!(recovered.windows(eicar().len()).any(|w| w == eicar())); - } - fn aes_ecb_encrypt(key: &[u8], data: &[u8]) -> Vec<u8> { use aes::cipher::{BlockCipherEncrypt, KeyInit}; let cipher = aes::Aes128::new_from_slice(key).unwrap(); @@ -1258,7 +714,7 @@ mod tests { } fn aes256_cbc_encrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Vec<u8> { - use aes::cipher::{block_padding::NoPadding, BlockModeEncrypt, KeyIvInit}; + use aes::cipher::{BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}; let mut out = data.to_vec(); let n = out.len(); let enc = <cbc::Encryptor<aes::Aes256>>::new_from_slices(key, &iv[..16]).unwrap(); @@ -1373,75 +829,6 @@ encryptedVerifierHashInput=\"{}\" encryptedVerifierHashValue=\"{}\" encryptedKey ); } - /// Password verifier matches the MS-OFFCRYPTO reference vector (the - /// `VelvetSweatshop` default stores `0x9a0a`). - #[test] - fn xor_verifier_matches_reference() { - assert!(xor_verify_password("VelvetSweatshop", 0x9a0a)); - assert!(xor_verify_password("password1", 0xe1ae)); - assert!(xor_verify_password("Secret1", 0xc266)); - assert!(!xor_verify_password("VelvetSweatshop", 0x0000)); - assert!(!xor_verify_password("wrong", 0x9a0a)); - } - - /// The 16-byte obfuscation array matches the MIT msoffcrypto reference for - /// several passwords (validates the tables + key derivation byte-for-byte). - #[test] - fn xor_array_matches_reference_vectors() { - for (pw, hex) in [ - ("VelvetSweatshop", "876b9ae21ee305621e699660986e9404"), - ("password1", "dbb75abe58b0da357bda1cf8bef81cdb"), - ("abc", "95999475da577857da7465a87a2f2577"), - ("Secret1", "7b3863b360b04a572d758f752d56928a"), - ] { - assert_eq!( - xor_create_array(pw).unwrap().to_vec(), - hexv(hex), - "xor array mismatch for {pw:?}" - ); - } - } - - /// End-to-end: obfuscate a BIFF `Workbook` (BOF, XOR `FilePass`, an EICAR - /// record, EOF) with the XOR scheme, then confirm the default-password path - /// recovers the cleartext EICAR, exercising the per-record index math. - #[test] - fn xor_obfuscation_recovers_eicar() { - let pw = "VelvetSweatshop"; - let arr = xor_create_array(pw).unwrap(); - - let mut wb = Vec::new(); - push_rec(&mut wb, 0x0809, &[0u8; 16]); // BOF (exempt) - // FilePass XOR: wEncryptionType=0, key, verifier(0x9a0a) - let mut fp = Vec::new(); - fp.extend_from_slice(&0u16.to_le_bytes()); - fp.extend_from_slice(&0x1234u16.to_le_bytes()); // key (unused for verify) - fp.extend_from_slice(&0x9a0au16.to_le_bytes()); // verificationBytes - push_rec(&mut wb, 0x002f, &fp); - let rec_off = wb.len(); // stream offset of the EICAR record header - push_rec(&mut wb, 0x00ff, eicar()); // non-exempt → obfuscated - push_rec(&mut wb, 0x000a, &[]); // EOF - - // Obfuscate the EICAR record's data in place: cipher = ROL(plain,5) ^ pad, - // with the same end-offset seed the decryptor uses. - let dstart = rec_off + 4; - let dend = dstart + eicar().len(); - let idx0 = dend % 16; - for (j, k) in (dstart..dend).enumerate() { - wb[k] = wb[k].rotate_left(5) ^ arr[(idx0 + j) % 16]; - } - assert!( - !wb.windows(eicar().len()).any(|w| w == eicar()), - "EICAR must be obfuscated in the input" - ); - - let recovered = try_decrypt_workbook(&wb, &[]).expect("XOR VelvetSweatshop must decrypt"); - assert!( - recovered.windows(eicar().len()).any(|w| w == eicar()), - "EICAR must be recovered from the de-obfuscated workbook" - ); - } - fn hexv(s: &str) -> Vec<u8> { (0..s.len() / 2) .map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap()) @@ -1488,26 +875,4 @@ encryptedVerifierHashInput=\"{}\" encryptedVerifierHashValue=\"{}\" encryptedKey assert!(check("Password1234_"), "correct password must verify"); assert!(!check("wrong-password"), "wrong password must not verify"); } - - fn hex(s: &str) -> [u8; 16] { - let mut o = [0u8; 16]; - for (i, b) in o.iter_mut().enumerate() { - *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap(); - } - o - } - fn hex20(s: &str) -> [u8; 20] { - let mut o = [0u8; 20]; - for (i, b) in o.iter_mut().enumerate() { - *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap(); - } - o - } - fn hex5(s: &str) -> [u8; 5] { - let mut o = [0u8; 5]; - for (i, b) in o.iter_mut().enumerate() { - *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap(); - } - o - } } diff --git a/crates/exav-unpack/src/formats/onenote.rs b/crates/exav-unpack/src/formats/onenote.rs index 9c3f6527..16633d1a 100644 --- a/crates/exav-unpack/src/formats/onenote.rs +++ b/crates/exav-unpack/src/formats/onenote.rs @@ -147,9 +147,11 @@ mod tests { blob.extend_from_slice(&FILE_DATA_STORE_GUID); blob.extend_from_slice(&[0u8; 3]); let mut budget = Budget::new(Limits::default()); - assert!(extract(Format::OneNote, &blob, &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::OneNote, &blob, &mut budget) + .unwrap() + .is_empty() + ); } #[test] @@ -158,8 +160,10 @@ mod tests { blob.extend_from_slice(&ONENOTE_HEADER_GUID); blob.extend_from_slice(b"a section with no embedded files"); let mut budget = Budget::new(Limits::default()); - assert!(extract(Format::OneNote, &blob, &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::OneNote, &blob, &mut budget) + .unwrap() + .is_empty() + ); } } diff --git a/crates/exav-unpack/src/formats/partition.rs b/crates/exav-unpack/src/formats/partition.rs index 6304563f..224e945f 100644 --- a/crates/exav-unpack/src/formats/partition.rs +++ b/crates/exav-unpack/src/formats/partition.rs @@ -129,7 +129,16 @@ pub fn intersection_alert(data: &dyn crate::source::ByteSource) -> Option<&'stat } } -/// Walk a disk image, each partition streamed from where it lies. +/// Whether the start of a disk `len` bytes long, `head` (at least two +/// sectors of it, or all of a shorter one), holds a partition table. +#[cfg(feature = "partition")] +pub(crate) fn is_table(head: &[u8], len: u64) -> bool { + is_gpt(head) || is_apm(head) || is_mbr_in(head, len) +} + +/// Walk a disk image, each partition streamed from where it lies. A partition +/// larger than the buffer limit that holds a filesystem exav reads is walked +/// in place instead, where the stream would be all of it. #[cfg(feature = "partition")] pub(crate) fn walk<T>( src: &dyn crate::source::ByteSource, @@ -137,8 +146,14 @@ pub(crate) fn walk<T>( visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { let mut source = crate::source::Reader::new(src); - let members = stream_offsets(&mut source)?; - crate::stream::stream_stored(&mut source, budget, visit, members) + let regions = stream_offsets(&mut source)?; + super::volumes::walk_regions( + &mut source, + regions, + super::volumes::Mode::Raw, + budget, + visit, + ) } /// Parse the GPT/APM/MBR table (tiny, near the start) via targeted reads and @@ -365,8 +380,8 @@ mod tests { b[3..11].copy_from_slice(b"MTOO4048"); b[11..13].copy_from_slice(&512u16.to_le_bytes()); // bytes per sector b[13] = 1; // sectors per cluster - // Boot code that parses as: bootable, type 0x01 (FAT12), LBA 0, 4096 - // sectors — i.e. a "partition" covering the entire image. + // Boot code that parses as: bootable, type 0x01 (FAT12), LBA 0, 4096 + // sectors — i.e. a "partition" covering the entire image. b[446] = 0x80; b[446 + 4] = 0x01; b[446 + 8..446 + 12].copy_from_slice(&0u32.to_le_bytes()); @@ -423,12 +438,12 @@ mod tests { img[h + 72..h + 80].copy_from_slice(&2u64.to_le_bytes()); // entries_lba = 2 img[h + 80..h + 84].copy_from_slice(&1u32.to_le_bytes()); // num_entries = 1 img[h + 84..h + 88].copy_from_slice(&128u32.to_le_bytes()); // entry_size = 128 - // Partition entry at sector 2. + // Partition entry at sector 2. let e = 2 * SECTOR; img[e] = 0xAB; // non-zero type GUID => used img[e + 32..e + 40].copy_from_slice(&3u64.to_le_bytes()); // first_lba = 3 img[e + 40..e + 48].copy_from_slice(&3u64.to_le_bytes()); // last_lba = 3 - // Partition region at sector 3. + // Partition region at sector 3. img[3 * SECTOR..3 * SECTOR + MARKER.len()].copy_from_slice(MARKER); img } diff --git a/crates/exav-unpack/src/formats/pdf.rs b/crates/exav-unpack/src/formats/pdf.rs index c72d11c4..e27237a9 100644 --- a/crates/exav-unpack/src/formats/pdf.rs +++ b/crates/exav-unpack/src/formats/pdf.rs @@ -66,17 +66,17 @@ pub fn has_obfuscated_name_object(data: &[u8]) -> bool { let mut decoded: Vec<u8> = Vec::new(); let mut gratuitous = false; while j < data.len() && !TERM.contains(&data[j]) { - if data[j] == b'#' && j + 2 < data.len() { - if let Ok(b) = + if data[j] == b'#' + && j + 2 < data.len() + && let Ok(b) = u8::from_str_radix(std::str::from_utf8(&data[j + 1..j + 3]).unwrap_or("x"), 16) - { - if b.is_ascii_alphanumeric() { - gratuitous = true; - } - decoded.push(b); - j += 3; - continue; + { + if b.is_ascii_alphanumeric() { + gratuitous = true; } + decoded.push(b); + j += 3; + continue; } decoded.push(data[j]); j += 1; @@ -160,11 +160,51 @@ pub(crate) fn extract_pdf<R>( let mut js_acc: Vec<u8> = Vec::new(); let mut uri_acc: Vec<u8> = Vec::new(); let mut lex = Lexer::new(data); - while let Some((obj_id, gen, obj_start)) = lex.find_next_obj() { + // Where each object number is, found when a stream's `/Filter` or + // `/DecodeParms` is a reference to one. + let mut index: Option<HashMap<u32, usize>> = None; + // Bytes read again because an object ended before its parse did, or did not + // parse. Bounded, since every object of a crafted file can claim the rest of + // it, and the bound is said when it is reached. + let mut reread = 0u64; + let reread_cap = (data.len() as u64) + .saturating_mul(8) + .saturating_add(1 << 20); + let mut gave_up = false; + while let Some((obj_id, r#gen, obj_start)) = lex.find_next_obj() { lex.set_pos(obj_start); - let Some((obj, _end)) = parse_indirect_object_body(&mut lex) else { + let Some((obj, end)) = parse_indirect_object_body(&mut lex) else { + // An object that does not parse (an unterminated string in it runs + // to the end of the file) is not the end of the document: the scan + // goes on from its start, as a reader going by the cross-reference + // table reaches every object after it. + reread = reread.saturating_add(lex.pos().saturating_sub(obj_start) as u64); + if reread > reread_cap { + gave_up = true; + } else { + lex.set_pos(obj_start); + } continue; }; + // An object that does not end where it says (a string left open takes + // the rest of the file for itself, and the dictionary round it still + // parses) must not take the objects after it. Parsing resumes after the + // first `endobj`, as a reader that goes by the cross-reference table + // finds each of them. + if let Some(p) = memchr::memmem::find( + &data[obj_start.min(data.len())..end.min(data.len())], + b"endobj", + ) { + let stop = obj_start + p + b"endobj".len(); + if end > stop { + reread = reread.saturating_add((end - stop) as u64); + if reread > reread_cap { + gave_up = true; + } else { + lex.set_pos(stop); + } + } + } budget.count_entry()?; // Harvest JS / URI / launch strings from this object's dictionary(s). @@ -174,7 +214,7 @@ pub(crate) fn extract_pdf<R>( harvest_actions( &obj, decoder.as_ref(), - (obj_id, gen), + (obj_id, r#gen), &mut js_acc, &mut uri_acc, ); @@ -188,10 +228,11 @@ pub(crate) fn extract_pdf<R>( { #[cfg(feature = "decrypt")] if let Some(ref dec) = decoder { - dec.decrypt_stream(obj_id, gen, &mut stream_data); + dec.decrypt_stream(obj_id, r#gen, &mut stream_data); } let raw_len = stream_data.len() as u64; let cap = budget.reserve()?; + let info = resolve_filter_references(info, data, &mut index); let outcome = apply_filters(&stream_data, &info, cap)?; if outcome.truncated { return Err(LimitHit::new("pdf stream exceeds budget".to_string())); @@ -225,7 +266,12 @@ pub(crate) fn extract_pdf<R>( e } else { let mut e = Entry::new(format!("pdf-obj-{obj_id}-{gen}"), buf); - if outcome.part_way { + if outcome.unknown_filter { + e.unsupported = Some( + "PDF stream uses a filter that is not decoded; its bytes were \ + scanned as far as the filters before it decode them", + ); + } else if outcome.part_way { e.unsupported = Some( "PDF stream failed to decode part way; the bytes before the \ failure were scanned", @@ -239,6 +285,21 @@ pub(crate) fn extract_pdf<R>( } } + if gave_up { + budget.count_entry()?; + if let Some(r) = visit( + Entry::unsupported( + "pdf-overlapping-objects".to_string(), + data.len() as u64, + false, + "PDF objects run into one another more than can be read again; the rest of the file was not scanned object by object", + ), + budget, + ) { + return Ok(Some(r)); + } + } + // Emit the harvested active-content members (bounded by the budget like any // other member). Non-empty only. for (name, acc) in [("pdf-javascript", js_acc), ("pdf-uris", uri_acc)] { @@ -330,11 +391,85 @@ fn harvest_inner( } } +/// `info` with the references in `/Filter` and `/DecodeParms` replaced by the +/// objects they name, which a PDF may use for either. The scan is object by +/// object, so a filter written as `7 0 R` was no filter at all, and the stream +/// was scanned compressed. +fn resolve_filter_references( + mut info: HashMap<String, Primitive>, + data: &[u8], + index: &mut Option<HashMap<u32, usize>>, +) -> HashMap<String, Primitive> { + let mut resolve = |p: &Primitive| -> Option<Primitive> { + let mut at = p.clone(); + // A reference to a reference is followed, a loop of them not forever. + for _ in 0..8 { + let Primitive::Reference(id) = at else { + return Some(at); + }; + let index = index.get_or_insert_with(|| { + let mut found = HashMap::new(); + let mut lex = Lexer::new(data); + // The last definition of a number wins, as in an incremental + // update. + while let Some((n, _gen, start)) = lex.find_next_obj() { + found.insert(n, start); + } + found + }); + let start = *index.get(&id)?; + let mut lex = Lexer::new(data); + lex.set_pos(start); + at = match parse_indirect_object_body(&mut lex)?.0 { + Primitive::Stream { .. } => return None, + other => other, + }; + } + None + }; + for key in ["Filter", "DecodeParms", "DP"] { + let Some(value) = info.get(key) else { + continue; + }; + let resolved = match value { + Primitive::Reference(_) => resolve(value), + Primitive::Array(items) + if items.iter().any(|i| matches!(i, Primitive::Reference(_))) => + { + Some(Primitive::Array( + items + .iter() + .map(|i| resolve(i).unwrap_or(Primitive::Null)) + .collect(), + )) + } + _ => continue, + }; + match resolved { + Some(v) => { + info.insert(key.to_string(), v); + } + // A reference to nothing: the stream names a filter it does not + // give. Left as a name no decoder has, so the stream is reported. + None if key == "Filter" => { + info.insert( + key.to_string(), + Primitive::Name("<unresolved filter reference>".to_string()), + ); + } + None => { + info.remove(key); + } + } + } + info +} + /// What decoding a stream's `/Filter` chain produced. -struct FilterOutcome { - buf: Vec<u8>, +pub(crate) struct FilterOutcome { + pub(crate) buf: Vec<u8>, /// An output cap was hit: the caller should treat the stream as over budget. - truncated: bool, + pub(crate) truncated: bool, /// An implemented filter failed and nothing was recovered: the caller /// should report the stream undecodable rather than scan an empty buffer /// as though its bytes were examined. Distinct from "decoded to empty", @@ -344,6 +479,9 @@ struct FilterOutcome { /// A filter decoded part of its input and failed on the rest: `buf` holds /// what came before the failure. part_way: bool, + /// The chain names a filter that is neither decoded here nor an image + /// codec: what follows it was not decoded, and `buf` is not the content. + unknown_filter: bool, } /// A zlib stream decoded up to any damage, without its Adler-32 check. @@ -366,7 +504,7 @@ fn salvage_zlib(data: &[u8], cap: u64) -> Salvaged { /// filters use the pure decoders in [`filters`]. An unknown/unsupported filter /// (e.g. an image codec like DCTDecode) stops the chain and passes through /// whatever was decoded so far. -fn apply_filters( +pub(crate) fn apply_filters( stream_data: &[u8], info: &HashMap<String, Primitive>, cap: u64, @@ -380,11 +518,13 @@ fn apply_filters( // No decoding involved: raw bytes pass through, nothing to fail. decode_error: false, part_way: false, + unknown_filter: false, }); } let parms = decode_parms_list(info, names.len()); let mut buf = stream_data.to_vec(); let mut part_way = false; + let mut unknown_filter = false; // Whether an implemented filter failed with nothing recovered. Sticky // across the chain: only a successful decode producing content clears the // question, and the caller reports `unsupported` solely on @@ -407,10 +547,10 @@ fn apply_filters( Ok((out, truncated)) => (out, truncated, false), Err(_) => { let mut s = salvage_zlib(&buf, cap); - if s.data.is_empty() { - if let Some(z) = buf.iter().position(|&b| b == 0x78) { - s = salvage_zlib(&buf[z..], cap); - } + if s.data.is_empty() + && let Some(z) = buf.iter().position(|&b| b == 0x78) + { + s = salvage_zlib(&buf[z..], cap); } // Empty input decodes vacuously (nothing to fail on); // only non-empty input decoding to nothing is an error. @@ -448,7 +588,18 @@ fn apply_filters( // (an image codec over raw bytes reads as the raw bytes — same as // ClamAV, which matches signatures against the stream content // without decoding image codecs either). - _ => break, + // The identity crypt filter, which every reader passes over: + // what a `/Crypt` before `/FlateDecode` leaves is still to be + // inflated. (A named crypt filter is applied with the document's + // key, in the pass that decrypts streams.) + "Crypt" => continue, + // An image codec ends the chain. Any other name is a filter no + // decoder here has, and what follows it cannot be reached. + "DCTDecode" | "DCT" | "JPXDecode" | "CCITTFaxDecode" | "CCF" | "JBIG2Decode" => break, + _ => { + unknown_filter = true; + break; + } }; decode_error |= err; if truncated { @@ -457,6 +608,7 @@ fn apply_filters( truncated: true, decode_error, part_way, + unknown_filter, }); } buf = out; @@ -467,12 +619,13 @@ fn apply_filters( truncated, decode_error, part_way, + unknown_filter, }) } /// Collect the ordered list of filter names from `/Filter` (a single name or an /// array of names). -fn filter_names(info: &HashMap<String, Primitive>) -> Vec<String> { +pub(crate) fn filter_names(info: &HashMap<String, Primitive>) -> Vec<String> { match info.get("Filter") { Some(Primitive::Name(n)) => vec![n.clone()], Some(Primitive::Array(arr)) => arr @@ -564,26 +717,25 @@ fn detect_encryption(data: &[u8]) -> Option<CryptDict> { parse_indirect_object_body(&mut lex) { // Strategy 1: dict contains an /Encrypt key - if let Some(Primitive::Dictionary(enc)) = dict.get("Encrypt") { - if let Some(cd) = CryptDict::from_primitive(enc) { - return Some(cd); - } + if let Some(Primitive::Dictionary(enc)) = dict.get("Encrypt") + && let Some(cd) = CryptDict::from_primitive(enc) + { + return Some(cd); } // Strategy 2: this IS the encrypt dict (standalone) - if is_encrypt_dict(&dict) { - if let Some(cd) = CryptDict::from_primitive(&dict) { - return Some(cd); - } + if is_encrypt_dict(&dict) + && let Some(cd) = CryptDict::from_primitive(&dict) + { + return Some(cd); } } } // Strategy 3: check trailer - if let Some(trailer) = parse_trailer_dict(data) { - if let Some(Primitive::Dictionary(enc)) = trailer.get("Encrypt") { - if let Some(cd) = CryptDict::from_primitive(enc) { - return Some(cd); - } - } + if let Some(trailer) = parse_trailer_dict(data) + && let Some(Primitive::Dictionary(enc)) = trailer.get("Encrypt") + && let Some(cd) = CryptDict::from_primitive(enc) + { + return Some(cd); } None } @@ -597,21 +749,18 @@ fn find_doc_id(data: &[u8]) -> Option<Vec<u8>> { lex.set_pos(obj_start); if let Some((Primitive::Dictionary(dict) | Primitive::Stream { info: dict, .. }, _end)) = parse_indirect_object_body(&mut lex) + && let Some(Primitive::Array(arr)) = dict.get("ID") + && let Some(Primitive::String(s)) = arr.first() { - if let Some(Primitive::Array(arr)) = dict.get("ID") { - if let Some(Primitive::String(s)) = arr.first() { - return Some(s.clone()); - } - } + return Some(s.clone()); } } // Check the trailer - if let Some(trailer) = parse_trailer_dict(data) { - if let Some(Primitive::Array(arr)) = trailer.get("ID") { - if let Some(Primitive::String(s)) = arr.first() { - return Some(s.clone()); - } - } + if let Some(trailer) = parse_trailer_dict(data) + && let Some(Primitive::Array(arr)) = trailer.get("ID") + && let Some(Primitive::String(s)) = arr.first() + { + return Some(s.clone()); } None } @@ -627,11 +776,11 @@ mod tests { assert!(has_obfuscated_name_object(b"<</#4f#70enAction 2 0 R>>")); // /OpenAction assert!(has_obfuscated_name_object(b"/J#61vaScript")); // → JavaScript assert!(has_obfuscated_name_object(b"/La#75nch")); // → Launch - // An incidental escape in an *ordinary* name is benign and must NOT fire - // (this is the false positive that made it unsafe to run by default). + // An incidental escape in an *ordinary* name is benign and must NOT fire + // (this is the false positive that made it unsafe to run by default). assert!(!has_obfuscated_name_object(b"/Ty#70e")); // → Type (not sensitive) assert!(!has_obfuscated_name_object(b"/C#31")); // → C1 (a real benign-PDF FP) - // No escapes, or only legitimate ones → not flagged. + // No escapes, or only legitimate ones → not flagged. assert!(!has_obfuscated_name_object(b"/Type/Catalog/OpenAction")); assert!(!has_obfuscated_name_object(b"/JavaScript")); // unobfuscated → not flagged assert!(!has_obfuscated_name_object(b"/Weird#20Name")); // #20 = space, needs escaping @@ -917,8 +1066,10 @@ trailer\n<< /Root 1 0 R >>\n%%EOF"; b"%PDF-1.5\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF"; let mut budget = Budget::new(Limits::default()); let entries = extract(Format::Pdf, pdf, &mut budget).unwrap(); - assert!(entries - .iter() - .all(|e| e.name != "pdf-javascript" && e.name != "pdf-uris")); + assert!( + entries + .iter() + .all(|e| e.name != "pdf-javascript" && e.name != "pdf-uris") + ); } } diff --git a/crates/exav-unpack/src/formats/pdf_parse/crypt.rs b/crates/exav-unpack/src/formats/pdf_parse/crypt.rs index d4adfe9f..de32a8fb 100644 --- a/crates/exav-unpack/src/formats/pdf_parse/crypt.rs +++ b/crates/exav-unpack/src/formats/pdf_parse/crypt.rs @@ -160,17 +160,17 @@ impl Decoder { Err(PdfCryptError::UnsupportedRevision(level)) } - pub(crate) fn decrypt_stream(&self, obj_id: u32, gen: u32, data: &mut Vec<u8>) { + pub(crate) fn decrypt_stream(&self, obj_id: u32, r#gen: u32, data: &mut Vec<u8>) { if data.is_empty() || self.method == CryptMethod::None { return; } match self.method { CryptMethod::V2 => { - let key = self.per_object_key(obj_id, gen, None); + let key = self.per_object_key(obj_id, r#gen, None); rc4_apply(&key, data); } CryptMethod::AESV2 => { - let key = self.per_object_key(obj_id, gen, Some(b"sAlT")); + let key = self.per_object_key(obj_id, r#gen, Some(b"sAlT")); aes_cbc_decrypt(&key, data); } CryptMethod::AESV3 => { @@ -184,12 +184,12 @@ impl Decoder { /// ISO 32000-1 7.6.2, algorithm 1: MD5 of the file key, the object /// number's low 3 bytes and the generation's low 2, and for AES the salt; /// the first n + 5 bytes of it, at most 16, are the object's key. - fn per_object_key(&self, obj_id: u32, gen: u32, salt: Option<&[u8]>) -> Vec<u8> { + fn per_object_key(&self, obj_id: u32, r#gen: u32, salt: Option<&[u8]>) -> Vec<u8> { let n = self.key_size.min(16).min(self.key.len()); let mut data = Vec::with_capacity(n + 5 + 4); data.extend_from_slice(&self.key[..n]); data.extend_from_slice(&obj_id.to_le_bytes()[..3]); - data.extend_from_slice(&gen.to_le_bytes()[..2]); + data.extend_from_slice(&r#gen.to_le_bytes()[..2]); if let Some(s) = salt { data.extend_from_slice(s); } @@ -755,8 +755,14 @@ mod tests { method: CryptMethod::AESV2, }; let cases = [ - (&v3, "222222222222222222222222222222221667316af8df34c59a9cfbb282f3f15390b837b680e02362584c9426a765787eba74d5ee5529da8aad289df5c742541b"), - (&v2, "22222222222222222222222222222222c0f2686b1de9823bb0b732f174856be9b2bde49ef0768d91635c34a59383f9e6064367ffe1064032238b380d4224a448"), + ( + &v3, + "222222222222222222222222222222221667316af8df34c59a9cfbb282f3f15390b837b680e02362584c9426a765787eba74d5ee5529da8aad289df5c742541b", + ), + ( + &v2, + "22222222222222222222222222222222c0f2686b1de9823bb0b732f174856be9b2bde49ef0768d91635c34a59383f9e6064367ffe1064032238b380d4224a448", + ), ]; for (dec, hex) in cases { // As extracted, and with the end-of-line a stream body can carry. @@ -769,6 +775,33 @@ mod tests { } } + /// An AES-128 (R4) file whose `/O` holds a backspace, written `\b`, opens + /// with the empty password. The dictionary is the one of a real file that + /// was reported password-protected while qpdf opened it. + #[test] + fn an_owner_key_with_a_backspace_escape_still_opens_with_the_empty_password() { + use crate::formats::pdf_parse::{lex::Lexer, parse::parse_object}; + let mut src = b"<</Filter/Standard/O (".to_vec(); + src.extend_from_slice( + b"\xe4\xa5`\xed\xd7\\b\x9c\xd0\x85\x0fB\x8c\xba\\)\x18\\(\xdc\\\\\xd8[\x1d\\)\xefFm\xb4\x96\xe2O\xf3<\xb0", + ); + src.extend_from_slice(b")/U ("); + src.extend_from_slice(b"\xb7c\x87\xea/\xb6\\n\xa9\x0b\xe5O\x81\xc7\xcf\x80I"); + src.extend_from_slice(&[0; 16]); + src.extend_from_slice( + b")/P -300/R 4/EncryptMetadata false/V 4/Length 128/StrF/StdCF/StmF/StdCF\ + /CF<</StdCF<</Length 16/AuthEvent/DocOpen/CFM/AESV2>>>>>>", + ); + let Some(Primitive::Dictionary(dict)) = parse_object(&mut Lexer::new(&src)) else { + panic!("not a dictionary"); + }; + let dict = CryptDict::from_primitive(&dict).unwrap(); + assert_eq!(dict.o.len(), 32); + assert_eq!(dict.o[5], 0x08); + let id = unhex("4db41f9bda1583e5463f724dd715f4ee"); + assert!(Decoder::from_password(&dict, &id, b"").is_ok()); + } + /// RC4 with a 40-bit file key: the object key is 10 bytes (n + 5), and it /// covers the generation number. Vector from Python's hashlib and a plain /// RC4, object 9, generation 1. diff --git a/crates/exav-unpack/src/formats/pdf_parse/filters.rs b/crates/exav-unpack/src/formats/pdf_parse/filters.rs index f43c14b4..6336a5c0 100644 --- a/crates/exav-unpack/src/formats/pdf_parse/filters.rs +++ b/crates/exav-unpack/src/formats/pdf_parse/filters.rs @@ -248,12 +248,13 @@ pub(crate) fn lzw_decode(data: &[u8], early_change: bool, cap: u64) -> (Vec<u8>, } // Add prev + first-byte-of-entry as the next dictionary entry. - if let Some(p) = prev { - if (p as usize) < dict.len() && dict.len() < MAX_ENTRIES { - let mut new_entry = dict[p as usize].clone(); - new_entry.push(entry[0]); - dict.push(new_entry); - } + if let Some(p) = prev + && (p as usize) < dict.len() + && dict.len() < MAX_ENTRIES + { + let mut new_entry = dict[p as usize].clone(); + new_entry.push(entry[0]); + dict.push(new_entry); } prev = Some(code); diff --git a/crates/exav-unpack/src/formats/pdf_parse/lex.rs b/crates/exav-unpack/src/formats/pdf_parse/lex.rs index 33d66e4c..7337f1a4 100644 --- a/crates/exav-unpack/src/formats/pdf_parse/lex.rs +++ b/crates/exav-unpack/src/formats/pdf_parse/lex.rs @@ -45,10 +45,24 @@ impl<'a> Lexer<'a> { self.data } + /// White space only. A `%` here is the byte it is: a comment is no reason to + /// miss an `N G obj` after it on the same line, which a reader that goes by + /// the cross-reference table finds, and the scan for objects must too. + fn skip_blank(&mut self) { + while self.pos < self.data.len() + && matches!( + self.data[self.pos], + b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\x00' + ) + { + self.pos += 1; + } + } + fn skip_whitespace(&mut self) { while self.pos < self.data.len() { match self.data[self.pos] { - b' ' | b'\t' | b'\r' | b'\n' | b'\x00' => self.pos += 1, + b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\x00' => self.pos += 1, b'%' => { // PDF comment: skip to end of line self.pos += 1; @@ -189,10 +203,10 @@ impl<'a> Lexer<'a> { if let Ok(b) = u8::from_str_radix(std::str::from_utf8(pair).unwrap_or("0"), 16) { result.push(b); } - } else if pair.len() == 1 { - if let Ok(b) = u8::from_str_radix(&format!("{}0", char::from(pair[0])), 16) { - result.push(b); - } + } else if pair.len() == 1 + && let Ok(b) = u8::from_str_radix(&format!("{}0", char::from(pair[0])), 16) + { + result.push(b); } } result @@ -223,6 +237,8 @@ impl<'a> Lexer<'a> { b'n' => result.push(b'\n'), b'r' => result.push(b'\r'), b't' => result.push(b'\t'), + b'b' => result.push(0x08), + b'f' => result.push(0x0c), b'\\' => result.push(b'\\'), b'(' => result.push(b'('), b')' => result.push(b')'), @@ -239,18 +255,15 @@ impl<'a> Lexer<'a> { } d @ b'0'..=b'7' => { let mut octal = (d - b'0') as u16; + // Up to two more digits; a byte that is not one + // belongs to the string, so it is only peeked at. for _ in 0..2 { - self.pos += 1; - if self.pos < self.data.len() { - if let Some(o) = self.data[self.pos].checked_sub(b'0') { - if o <= 7 { - octal = octal * 8 + o as u16; - } else { - break; - } - } else { - break; + match self.data.get(self.pos + 1) { + Some(&n @ b'0'..=b'7') => { + octal = octal * 8 + (n - b'0') as u16; + self.pos += 1; } + _ => break, } } result.push(octal as u8); @@ -273,8 +286,8 @@ impl<'a> Lexer<'a> { let start = self.pos; while self.pos < self.data.len() { match self.data[self.pos] { - b' ' | b'\t' | b'\r' | b'\n' | b'(' | b')' | b'<' | b'>' | b'/' | b'[' | b']' - | b'{' | b'}' | b'%' | 0 => break, + b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'(' | b')' | b'<' | b'>' | b'/' + | b'[' | b']' | b'{' | b'}' | b'%' | 0 => break, _ => self.pos += 1, } } @@ -283,14 +296,14 @@ impl<'a> Lexer<'a> { let mut name = String::with_capacity(raw.len()); let mut i = 0; while i < raw.len() { - if raw[i] == b'#' && i + 2 < raw.len() { - if let Ok(b) = + if raw[i] == b'#' + && i + 2 < raw.len() + && let Ok(b) = u8::from_str_radix(std::str::from_utf8(&raw[i + 1..i + 3]).unwrap_or("0"), 16) - { - name.push(b as char); - i += 3; - continue; - } + { + name.push(b as char); + i += 3; + continue; } name.push(raw[i] as char); i += 1; @@ -330,30 +343,30 @@ impl<'a> Lexer<'a> { /// Returns (obj_number, gen_number, offset_of_first_byte_after_obj_keyword) or None. pub(crate) fn find_next_obj(&mut self) -> Option<(u32, u32, usize)> { while self.pos < self.data.len() { - self.skip_whitespace(); + self.skip_blank(); if self.pos >= self.data.len() { return None; } // Try to read: number number obj let saved = self.pos; - if let Token::Integer(n) = self.read_number() { - if n >= 0 { - self.skip_whitespace(); - if self.pos < self.data.len() && self.data[self.pos].is_ascii_digit() { - // This might be the generation number - let gen_start = self.pos; - while self.pos < self.data.len() && self.data[self.pos].is_ascii_digit() { - self.pos += 1; - } - let gen: u32 = std::str::from_utf8(&self.data[gen_start..self.pos]) - .unwrap_or("0") - .parse() - .unwrap_or(0); - self.skip_whitespace(); - if self.starts_with(b"obj") { - self.pos += 3; - return Some((n as u32, gen, self.pos)); - } + if let Token::Integer(n) = self.read_number() + && n >= 0 + { + self.skip_blank(); + if self.pos < self.data.len() && self.data[self.pos].is_ascii_digit() { + // This might be the generation number + let gen_start = self.pos; + while self.pos < self.data.len() && self.data[self.pos].is_ascii_digit() { + self.pos += 1; + } + let r#gen: u32 = std::str::from_utf8(&self.data[gen_start..self.pos]) + .unwrap_or("0") + .parse() + .unwrap_or(0); + self.skip_blank(); + if self.starts_with(b"obj") { + self.pos += 3; + return Some((n as u32, r#gen, self.pos)); } } } @@ -399,6 +412,34 @@ mod tests { assert!(none); } + fn string_of(src: &[u8]) -> Vec<u8> { + match Lexer::new(src).next_token() { + Token::String(s) => s, + other => panic!("not a string: {other:?}"), + } + } + + /// `\b` and `\f` are backspace and form feed. Read as the letters, an + /// `/O` entry that holds one is the wrong key, and an encrypted PDF that + /// opens with an empty password is reported password-protected. + #[test] + fn backspace_and_form_feed_escapes_are_decoded() { + assert_eq!(string_of(b"(a\\bb\\fc)"), b"a\x08b\x0cc"); + } + + /// A short octal escape ended where the next byte is not a digit, and + /// that byte was dropped: the `)` after `\0` closed nothing, and the + /// string ran on into the rest of the file. + #[test] + fn a_short_octal_escape_does_not_eat_the_next_byte() { + let mut lex = Lexer::new(b"(a\\0) /Next"); + assert_eq!(lex.next_token(), Token::String(b"a\0".to_vec())); + assert_eq!(lex.next_token(), Token::Name("Next".into())); + assert_eq!(string_of(b"(\\12x)"), b"\nx"); + assert_eq!(string_of(b"(\\1234)"), b"S4"); + assert_eq!(string_of(b"(\\8)"), b"8"); + } + #[test] fn an_object_after_a_long_digit_run_is_still_found() { let mut data = vec![b'9'; 5000]; diff --git a/crates/exav-unpack/src/formats/pdf_parse/parse.rs b/crates/exav-unpack/src/formats/pdf_parse/parse.rs index ae532590..fce57bb3 100644 --- a/crates/exav-unpack/src/formats/pdf_parse/parse.rs +++ b/crates/exav-unpack/src/formats/pdf_parse/parse.rs @@ -25,10 +25,10 @@ fn parse_object_depth(lex: &mut Lexer, depth: usize) -> Option<Primitive> { // encrypted-PDF key derivation). The reference target isn't resolved // here, so it surfaces as the leading integer. let saved = lex.pos(); - if let Token::Integer(_gen) = lex.next_token() { - if let Token::R = lex.next_token() { - return Some(Primitive::Reference(n as u32)); - } + if let Token::Integer(_gen) = lex.next_token() + && let Token::R = lex.next_token() + { + return Some(Primitive::Reference(n as u32)); } lex.set_pos(saved); Some(Primitive::Integer(n)) diff --git a/crates/exav-unpack/src/formats/pepack.rs b/crates/exav-unpack/src/formats/pepack.rs index c8278010..04700b6f 100644 --- a/crates/exav-unpack/src/formats/pepack.rs +++ b/crates/exav-unpack/src/formats/pepack.rs @@ -344,13 +344,13 @@ fn identify(pe: &Pe) -> Option<Packer> { // Entry-point signature for FSG (which strips section names). FSG 2.0 stubs // begin the decompressor with a distinctive load of the packed-source and // destination pointers. Match at the entry-point file offset only. - if let Some(off) = pe.rva_to_off(pe.entry_rva) { - if let Some(win) = crate::bytes::at(pe.data, off, 2) { - // `BE xx xx xx xx` (mov esi, imm32) is the canonical FSG 2.0 stub - // opener; require it to sit right at the entry point. - if win[0] == 0xBE && fsg_stub_plausible(pe, off) { - return Some(Packer::Fsg); - } + if let Some(off) = pe.rva_to_off(pe.entry_rva) + && let Some(win) = crate::bytes::at(pe.data, off, 2) + { + // `BE xx xx xx xx` (mov esi, imm32) is the canonical FSG 2.0 stub + // opener; require it to sit right at the entry point. + if win[0] == 0xBE && fsg_stub_plausible(pe, off) { + return Some(Packer::Fsg); } } None @@ -586,8 +586,8 @@ fn looks_random_name(name: &[u8; 8]) -> bool { return false; } let switches = n - .windows(2) - .filter(|w| w[0].is_ascii_lowercase() && w[1].is_ascii_uppercase()) + .array_windows() + .filter(|[a, b]| a.is_ascii_lowercase() && b.is_ascii_uppercase()) .count(); digits > 0 || switches >= 2 } @@ -798,15 +798,15 @@ fn aplib_unpack<R>( if stream.len() < 8 { continue; } - if let Some(out) = aplib::depack(stream, cap) { - if looks_like_pe(&out) { - budget.commit(out.len() as u64); - let name = format!("{}-unpacked", packer_name(packer)); - return Ok(match visit(Entry::new(name, out), budget) { - Some(r) => Recovered::Halt(r), - None => Recovered::Emitted, - }); - } + if let Some(out) = aplib::depack(stream, cap) + && looks_like_pe(&out) + { + budget.commit(out.len() as u64); + let name = format!("{}-unpacked", packer_name(packer)); + return Ok(match visit(Entry::new(name, out), budget) { + Some(r) => Recovered::Halt(r), + None => Recovered::Emitted, + }); } } Ok(Recovered::Nothing) @@ -999,7 +999,7 @@ fn looks_like_pe(out: &[u8]) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::{extract, Format, Limits}; + use crate::{Format, Limits, extract}; /// Build a minimal but structurally valid PE with the given section names /// and one section carrying `body` as raw data. Used both as the packed @@ -1197,9 +1197,11 @@ mod tests { fn non_pe_input_is_ignored() { let mut b = Budget::new(Limits::default()); assert!(!is_pepack(b"\x7fELF not a PE")); - assert!(extract(Format::PePacked, b"\x7fELF..", &mut b) - .unwrap() - .is_empty()); + assert!( + extract(Format::PePacked, b"\x7fELF..", &mut b) + .unwrap() + .is_empty() + ); } #[test] diff --git a/crates/exav-unpack/src/formats/ppmd7/model.rs b/crates/exav-unpack/src/formats/ppmd7/model.rs index c07d4055..c84348b6 100644 --- a/crates/exav-unpack/src/formats/ppmd7/model.rs +++ b/crates/exav-unpack/src/formats/ppmd7/model.rs @@ -162,10 +162,12 @@ impl<RC: RangeDec> Ppmd7<RC> { return None; } // Bounds-checked byte arena. Zero-initialised, matching `alloc_zeroed`. - // `try_reserve` keeps allocation failure non-panicking. - let mut arena: Vec<u8> = Vec::new(); - arena.try_reserve_exact(total_size).ok()?; - arena.resize(total_size, 0u8); + // The size is a header's to choose, up to the model's largest, so the + // arena must not be touched to be made: `vec![0; n]` takes zeroed pages + // from the system and a stream pays for the ones its model reaches. + // The `try_reserve` first keeps failure to allocate non-panicking. + Vec::<u8>::new().try_reserve_exact(total_size).ok()?; + let arena: Vec<u8> = vec![0u8; total_size]; let mut ppmd = Self { min_context: 0, @@ -1504,11 +1506,7 @@ impl<RC: RangeDec> Ppmd7<RC> { /// an arena-bounds violation was observed while updating the model. #[inline(always)] fn finish_symbol(&self, sym: i32) -> i32 { - if self.corrupt { - SYM_ERROR - } else { - sym - } + if self.corrupt { SYM_ERROR } else { sym } } } @@ -1516,7 +1514,7 @@ impl<RC: RangeDec> Ppmd7<RC> { mod tests { use super::*; use crate::formats::ppmd7::SevenZRangeDecoder; - use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::panic::{AssertUnwindSafe, catch_unwind}; type Model = Ppmd7<SevenZRangeDecoder>; diff --git a/crates/exav-unpack/src/formats/ppmd7/rangedec.rs b/crates/exav-unpack/src/formats/ppmd7/rangedec.rs index c08f7d16..3b787a0f 100644 --- a/crates/exav-unpack/src/formats/ppmd7/rangedec.rs +++ b/crates/exav-unpack/src/formats/ppmd7/rangedec.rs @@ -19,8 +19,8 @@ // Each coder is dead in a build without the format that uses it. #![cfg_attr(not(all(feature = "rar", feature = "sevenz")), allow(dead_code))] -use super::common::PPMD_BIN_SCALE; use super::RangeDec; +use super::common::PPMD_BIN_SCALE; const K_TOP_VALUE: u32 = 1 << 24; diff --git a/crates/exav-unpack/src/formats/ppmd8/mod.rs b/crates/exav-unpack/src/formats/ppmd8/mod.rs index d62843fb..0b35fdae 100644 --- a/crates/exav-unpack/src/formats/ppmd8/mod.rs +++ b/crates/exav-unpack/src/formats/ppmd8/mod.rs @@ -12,8 +12,8 @@ mod model; use std::io::{self, Read}; -use crate::formats::ppmd7::{RarRangeDecoder, SYM_END}; use crate::LimitHit; +use crate::formats::ppmd7::{RarRangeDecoder, SYM_END}; pub(crate) use model::Ppmd8; pub(crate) const PPMD8_MIN_ORDER: u32 = 2; @@ -203,6 +203,10 @@ mod tests { /// The reference `ppmd-rust` PPMd8 encoder, restart method only: its /// cut-off path swaps a state with itself through `mem::swap`, which a /// debug build aborts on. Cut-off streams come from fixtures instead. + /// The smallest model the reference encoder takes: ppmd-rust 1.5.2 refuses + /// the 2048 bytes the format allows (and the port reads). + const REF_MIN_MEM: u32 = 2064; + fn encode(data: &[u8], order: u32, mem: u32, end_marker: bool) -> Vec<u8> { let mut packed = Vec::new(); let mut enc = ppmd_rust::Ppmd8Encoder::new( @@ -329,7 +333,7 @@ mod tests { #[test] fn model_matches_reference_small() { - roundtrip(b"a", 2, 2048); + roundtrip(b"a", 2, REF_MIN_MEM); roundtrip(b"abcabcabcabc", 3, 4096); roundtrip(&[0u8; 1000], 6, 1 << 16); roundtrip(&(0..=255u8).collect::<Vec<_>>(), 16, 1 << 16); @@ -362,7 +366,7 @@ mod tests { lcg_bytes(100 * 1024, 43, 20), ]; for data in &inputs { - for (order, mem) in [(2, 2048), (6, 4096), (16, 16 * 1024), (8, 64 * 1024)] { + for (order, mem) in [(2, REF_MIN_MEM), (6, 4096), (16, 16 * 1024), (8, 64 * 1024)] { roundtrip(data, order, mem); let packed = encode(data, order, mem, false); let (restarts, cut_offs, _) = diff --git a/crates/exav-unpack/src/formats/ppmd8/model.rs b/crates/exav-unpack/src/formats/ppmd8/model.rs index abbf95e3..8323a423 100644 --- a/crates/exav-unpack/src/formats/ppmd8/model.rs +++ b/crates/exav-unpack/src/formats/ppmd8/model.rs @@ -17,7 +17,7 @@ use super::RestoreMethod; use crate::formats::ppmd7::common::*; -use crate::formats::ppmd7::{RangeDec, TaggedOffset, SYM_END, SYM_ERROR}; +use crate::formats::ppmd7::{RangeDec, SYM_END, SYM_ERROR, TaggedOffset}; // Context (12 bytes): // num_stats:u8 @0 (symbol count - 1) flags:u8 @1 @@ -151,9 +151,9 @@ impl<RC: RangeDec> Ppmd8<RC> { let align_offset = (4u32.wrapping_sub(mem_size)) & 3; let total_size = (align_offset as usize).checked_add(mem_size as usize)?; - let mut arena: Vec<u8> = Vec::new(); - arena.try_reserve_exact(total_size).ok()?; - arena.resize(total_size, 0u8); + // Not touched to be made: see the PPMd7 arena. + Vec::<u8>::new().try_reserve_exact(total_size).ok()?; + let arena: Vec<u8> = vec![0u8; total_size]; let mut ppmd = Self { min_context: 0, @@ -1817,10 +1817,6 @@ impl<RC: RangeDec> Ppmd8<RC> { /// `sym`, unless the model went out of bounds while updating. fn finish_symbol(&self, sym: u8) -> i32 { - if self.corrupt { - SYM_ERROR - } else { - sym as i32 - } + if self.corrupt { SYM_ERROR } else { sym as i32 } } } diff --git a/crates/exav-unpack/src/formats/qcow2.rs b/crates/exav-unpack/src/formats/qcow2.rs index 3f3d5564..5e0c7a96 100644 --- a/crates/exav-unpack/src/formats/qcow2.rs +++ b/crates/exav-unpack/src/formats/qcow2.rs @@ -1,10 +1,11 @@ //! QCOW2 — QEMU's copy-on-write disk image. //! //! 7-Zip opens these, every hypervisor reads them, and `qemu-img convert -c` -//! produces one whose clusters are **deflate-compressed**. That last part is -//! what makes the format matter to a scanner rather than merely to a -//! virtualisation stack: a payload inside a compressed image appears nowhere in -//! the file's bytes, so nothing short of real reconstruction reaches it. +//! produces one whose clusters are **compressed** (deflate, or zstd where the +//! image says so). That last part is what makes the format matter to a scanner +//! rather than merely to a virtualisation stack: a payload inside a compressed +//! image appears nowhere in the file's bytes, so nothing short of real +//! reconstruction reaches it. //! //! Implemented from the public QCOW2 specification. All header and table fields //! are **big-endian**. @@ -12,18 +13,23 @@ //! The disk is addressed through two levels of tables. A guest offset splits //! into an L1 index, an L2 index and an offset within the cluster; `L1[i]` gives //! the location of an L2 table, and `L2[j]` gives the location of the cluster -//! itself — or marks it unallocated (reads as zeroes) or compressed. +//! itself — or marks it unallocated (reads as zeroes) or compressed. The tables +//! are read from the source as clusters are reached, never held. //! -//! Reconstruction emits the guest disk as a single member, which the partition -//! and filesystem handlers then pick up in the normal way. +//! The disk is a [`VDisk`]: one member when it fits the buffer limit, else its +//! filesystems are walked in place (see `vdisk.rs`). // Every sum and product on a header's number is checked or saturating here; a // plain one fails the build, so the next edit cannot add the unchecked kind. #![deny(clippy::arithmetic_side_effects)] -use crate::{Budget, Entry, LimitHit, Sink}; +use super::vdisk::{self, Health, Layout, Map, Unpacked, VDisk, be64, bytes_at}; +use super::volumes::report_enc; +use crate::source::ByteSource; +use crate::stream::Visit; +use crate::{Budget, LimitHit}; -/// L2 entry bit 62: the cluster is deflate-compressed. +/// L2 entry bit 62: the cluster is compressed. const L2_COMPRESSED: u64 = 1 << 62; /// Mask selecting a cluster's host offset from an uncompressed L1/L2 entry. /// Bits 9..55; the low bits are flags and the top bit is `COPIED`. @@ -31,6 +37,17 @@ const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00; /// L2 entry bit 0 (qcow2 v3): the cluster reads as zeroes. const L2_ZERO: u64 = 1; +/// Incompatible-feature bits (version 3, header offset 72) that change how +/// the image is read. +const INCOMPAT_EXTERNAL_DATA: u64 = 1 << 2; +const INCOMPAT_COMPRESSION_TYPE: u64 = 1 << 3; +const INCOMPAT_EXTENDED_L2: u64 = 1 << 4; +/// Dirty, corrupt, external data file, compression type, extended L2: the +/// bits this reader knows. An image needing another is not read. +const INCOMPAT_KNOWN: u64 = 0b1_1111; + +const NAME: &str = "qcow2-disk"; + fn be_u32(d: &[u8], off: usize) -> u32 { crate::bytes::at(d, off, 4) .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])) @@ -53,193 +70,501 @@ pub(crate) fn is_qcow2(data: &[u8]) -> bool { super::sniff::is(data, crate::Format::Qcow2) } -pub(crate) fn extract_qcow2<R>( - data: &[u8], - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - if !is_qcow2(data) { - return Ok(None); - } - let cluster_bits = be_u32(data, 20); - let virtual_size = be_u64(data, 24); - let crypt_method = be_u32(data, 32); - let l1_size = be_u32(data, 36) as usize; - let l1_offset = crate::bytes::to_usize(be_u64(data, 40)); - let backing_file_size = be_u32(data, 16); +/// How a cluster is compressed. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Codec { + Deflate, + Zstd, +} + +struct Qcow2<'a> { + src: &'a dyn ByteSource, + l1: u64, + cluster: u64, + l2_entries: u64, + size: u64, + file_len: u64, + codec: Codec, + /// Compressed-cluster descriptor split, per the specification: the low + /// `csize_shift` bits are the host offset, the next ones the length in + /// 512-byte sectors minus one. + csize_shift: u32, + csize_mask: u64, +} + +impl Layout for Qcow2<'_> { + fn name(&self) -> &'static str { + NAME + } + + fn size(&self) -> u64 { + self.size + } + + fn unit(&self) -> u64 { + self.cluster + } + + fn map(&self, idx: u64) -> Map { + let i = idx.checked_div(self.l2_entries).unwrap_or(0); + let j = idx.checked_rem(self.l2_entries).unwrap_or(0); + let Some(l1e) = be64(self.src, self.l1.saturating_add(i.saturating_mul(8))) else { + return Map::Zero; + }; + let l2 = l1e & OFFSET_MASK; + if l2 == 0 { + return Map::Zero; // no L2 table: this whole range is unallocated (zeroes) + } + // An L2 table past the end is a truncated image. + if l2 + .checked_add(self.cluster) + .is_none_or(|end| end > self.file_len) + { + return Map::Zero; + } + let Some(e) = be64(self.src, l2.saturating_add(j.saturating_mul(8))) else { + return Map::Zero; + }; + let compressed = e & L2_COMPRESSED != 0; + // Bit 0 means "reads as zeroes" for a *plain* entry only. In a + // compressed descriptor it is part of the host offset, so testing it + // unconditionally drops every compressed cluster that happens to + // start at an odd byte — silently, as a hole of zeroes that scans + // clean. + if e == 0 || (!compressed && e & L2_ZERO != 0) { + return Map::Zero; // unallocated or explicitly zero + } + if compressed { + let at = e & low_mask(self.csize_shift); + let sectors = + (e.checked_shr(self.csize_shift).unwrap_or(0) & self.csize_mask).saturating_add(1); + // The run starts mid-sector, so the first partial sector counts + // against the total length. + let len = sectors.saturating_mul(512).saturating_sub(at & 511); + // Past the end: a truncated image, not a hidden cluster. + if at >= self.file_len { + return Map::Zero; + } + let avail = self.file_len.saturating_sub(at); + return Map::Packed { + at, + stored: len.min(avail), + cut: len > avail, + }; + } + // A table entry pointing past the end means the image is truncated: + // those bytes are absent rather than hidden, so the cluster stays zero. + // An offset of zero is no cluster at all, whatever the flag bits say. + let host = e & OFFSET_MASK; + let n = self + .cluster + .min(self.size.saturating_sub(idx.saturating_mul(self.cluster))); + if host == 0 || host.checked_add(n).is_none_or(|end| end > self.file_len) { + return Map::Zero; + } + Map::Plain(host) + } + + fn unpack(&self, _idx: u64, at: u64, stored: u64, cut: bool, out: usize) -> Unpacked { + let raw = usize::try_from(stored) + .ok() + .and_then(|n| bytes_at(self.src, at, n)); + let Some(raw) = raw else { + return Unpacked { + data: Vec::new(), + damaged: true, + }; + }; + let cap = out as u64; + let s = match self.codec { + // Clusters are raw DEFLATE, with no zlib wrapper. + Codec::Deflate => crate::salvage(crate::inflate::Inflate::new(&raw[..]), cap), + #[cfg(feature = "zstd")] + Codec::Zstd => { + use std::io::Read; + // The compressed run is padded to a sector: the cluster is + // what the frame holds, and what follows it is not a frame. + let frames = super::zstd::ZstdReader::new(std::io::Cursor::new(&raw[..])); + crate::salvage(frames.take(cap), cap) + } + #[cfg(not(feature = "zstd"))] + Codec::Zstd => { + return Unpacked { + data: Vec::new(), + damaged: true, + }; + } + }; + Unpacked { + damaged: s.part_way(cut), + data: s.data, + } + } + + fn reports(&self, health: &Health) -> Vec<(u64, &'static str)> { + // A cluster whose compressed bytes are *present* but will not decode is + // content exav failed to read — it must be reported. A cluster pointing + // past the end of the file is content that is not there at all, which + // is a truncated image rather than a hidden payload, and stays quiet. + // Reported alongside the disk, not instead of it: zero-filling a failed + // cluster and saying nothing would hand the scanner clean-looking space + // where real content should be. + match health.undecodable() { + 0 => Vec::new(), + n => vec![( + n.saturating_mul(self.cluster).min(self.size), + "QCOW2 compressed clusters that could not be decompressed", + )], + } + } +} + +/// What a QCOW2 header makes of an image. +enum Opened<'a> { + /// Not a QCOW2 image at all. + No, + /// A disk to read. + Disk(Qcow2<'a>), + /// An image whose disk is not read: the size it declares, whether it is + /// encrypted, and why. + Unread(u64, bool, &'static str), +} + +/// Read the header of `src`. +fn open(src: &dyn ByteSource) -> Opened<'_> { + let head = src.window(0, 112); + if !is_qcow2(&head) { + return Opened::No; + } + let version = be_u32(&head, 4); + let cluster_bits = be_u32(&head, 20); + let virtual_size = be_u64(&head, 24); + let crypt_method = be_u32(&head, 32); + let l1_size = u64::from(be_u32(&head, 36)); + let l1_offset = be_u64(&head, 40); + let backing_file_size = be_u32(&head, 16); + let unread = |why| Opened::Unread(virtual_size, false, why); // A backing file holds the clusters this image does not, and it is not in // this file — the same shape as a differencing VHD. if backing_file_size > 0 { - return report( + return unread( "QCOW2 with a backing file: the disk is a delta against an image not in this file", - virtual_size, - budget, - visit, ); } if crypt_method != 0 { - return report_enc("encrypted QCOW2 image", virtual_size, budget, visit); + return Opened::Unread(virtual_size, true, "encrypted QCOW2 image"); + } + let mut codec = Codec::Deflate; + if version == 3 { + let incompat = be_u64(&head, 72); + if incompat & INCOMPAT_EXTERNAL_DATA != 0 { + return unread( + "QCOW2 with an external data file: the disk is held in a file not in this one", + ); + } + if incompat & INCOMPAT_EXTENDED_L2 != 0 { + return unread("QCOW2 with extended L2 entries, which exav does not read"); + } + if incompat & !INCOMPAT_KNOWN != 0 { + return unread("QCOW2 needing a feature exav does not know"); + } + if incompat & INCOMPAT_COMPRESSION_TYPE != 0 { + // Past the header's declared length the field is not there. + let at = if be_u32(&head, 100) > 104 { + head.get(104).copied() + } else { + Some(0) + }; + codec = match at { + Some(0) => Codec::Deflate, + Some(1) => Codec::Zstd, + _ => return unread("QCOW2 with a compression type exav does not read"), + }; + } } // 9..=21 covers the 512-byte to 2 MiB cluster sizes the format allows; the // shift arithmetic below is meaningless outside it. if !(9..=21).contains(&cluster_bits) || l1_size == 0 { - return report("implausible QCOW2 geometry", virtual_size, budget, visit); + return unread("implausible QCOW2 geometry"); } - let cluster_size = 1usize << cluster_bits; - let l2_entries = cluster_size / 8; + let cluster = 1u64 << cluster_bits; + let l2_entries = cluster / 8; - let Some(l1) = crate::bytes::at(data, l1_offset, l1_size.saturating_mul(8)) else { - return report( - "QCOW2 L1 table lies outside the image", - virtual_size, - budget, - visit, - ); - }; - - budget.count_entry()?; - let cap = budget.reserve()?; - let want = virtual_size.min( - (l1_size as u64) - .saturating_mul(l2_entries as u64) - .saturating_mul(cluster_size as u64), - ); - if want > cap { - return Ok(visit( - Entry::unsupported( - "qcow2-disk".to_string(), - want, - false, - "QCOW2 disk exceeds the per-member size budget", - ), - budget, - )); + // The L1 table is read as clusters are reached, but it has to be in the + // file. + let file_len = src.len() as u64; + if !l1_offset + .checked_add(l1_size.saturating_mul(8)) + .is_some_and(|end| end <= file_len) + { + return unread("QCOW2 L1 table lies outside the image"); } + let size = virtual_size.min(l1_size.saturating_mul(l2_entries).saturating_mul(cluster)); + Opened::Disk(Qcow2 { + src, + l1: l1_offset, + cluster, + l2_entries, + size, + file_len, + codec, + csize_shift: 62u32.saturating_sub(cluster_bits.saturating_sub(8)), + csize_mask: low_mask(cluster_bits.saturating_sub(8)), + }) +} - // Compressed-cluster descriptor split, per the specification: the low - // `csize_shift` bits are the host offset, the next ones the length in - // 512-byte sectors minus one. - let csize_shift = 62u32.saturating_sub(cluster_bits.saturating_sub(8)); - let csize_mask = low_mask(cluster_bits.saturating_sub(8)); +/// Walk a QCOW2 image's disk. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + match open(src) { + Opened::No => Ok(None), + Opened::Unread(size, encrypted, why) => { + report_enc(NAME, size, encrypted, why, budget, visit) + } + Opened::Disk(layout) => { + let allowance = budget.remaining_scan(); + vdisk::walk(VDisk::new(src, layout, allowance), budget, visit) + } + } +} - let mut disk = vec![0u8; want as usize]; - // A cluster whose compressed bytes are *present* but will not inflate is - // content exav failed to read — it must be reported. A cluster pointing past - // the end of the file is content that is not there at all, which is a - // truncated image rather than a hidden payload, and stays quiet. - let mut undecodable_clusters = 0usize; +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::*; + use std::io::{Read, Seek, SeekFrom}; - for (i, l1_chunk) in l1.as_chunks::<8>().0.iter().enumerate() { - let l1e = u64::from_be_bytes(*l1_chunk); - let l2_off = crate::bytes::to_usize(l1e & OFFSET_MASK); - if l2_off == 0 { - continue; // no L2 table: this whole range is unallocated (zeroes) - } - let Some(l2) = crate::bytes::at(data, l2_off, l2_entries.saturating_mul(8)) else { - continue; // L2 table past the end: truncated image + /// An image of 64 clusters of 512 bytes: the header at 0, the L1 table at + /// 512 (one entry, to the L2 table at 1024), and `entry` as the L2 entry of + /// cluster 0. A data cluster holding `data` is at 1536. + fn image(version: u32, entry: u64, data: &[u8]) -> Vec<u8> { + let mut f = vec![0u8; 2048]; + f[..4].copy_from_slice(b"QFI\xfb"); + f[4..8].copy_from_slice(&version.to_be_bytes()); + f[20..24].copy_from_slice(&9u32.to_be_bytes()); + f[24..32].copy_from_slice(&(64u64 * 512).to_be_bytes()); + f[36..40].copy_from_slice(&1u32.to_be_bytes()); + f[40..48].copy_from_slice(&512u64.to_be_bytes()); + f[512..520].copy_from_slice(&((1u64 << 63) | 1024).to_be_bytes()); + f[1024..1032].copy_from_slice(&entry.to_be_bytes()); + f[1536..1536 + data.len()].copy_from_slice(data); + f + } + + /// A version 3 header with `incompat` set, `len` bytes long, and `ctype`. + fn v3(incompat: u64, len: u32, ctype: u8) -> Vec<u8> { + let mut f = image(3, 0, &[]); + f[72..80].copy_from_slice(&incompat.to_be_bytes()); + f[100..104].copy_from_slice(&len.to_be_bytes()); + f[104] = ctype; + f + } + + /// The first `n` bytes of the disk the image opens as, and what was found + /// of the compressed clusters in reading them. + fn read(img: &[u8], n: usize) -> (Vec<u8>, u64) { + let src = &img; + let Opened::Disk(layout) = open(src) else { + panic!("the image should open as a disk"); }; - for (j, l2_chunk) in l2.as_chunks::<8>().0.iter().enumerate() { - let e = u64::from_be_bytes(*l2_chunk); - let compressed = e & L2_COMPRESSED != 0; - // Bit 0 means "reads as zeroes" for a *plain* entry only. In a - // compressed descriptor it is part of the host offset, so testing it - // unconditionally drops every compressed cluster that happens to - // start at an odd byte — silently, as a hole of zeroes that scans - // clean. - if e == 0 || (!compressed && e & L2_ZERO != 0) { - continue; // unallocated or explicitly zero - } - let guest = i - .saturating_mul(l2_entries) - .saturating_add(j) - .saturating_mul(cluster_size); - if guest >= disk.len() { - continue; - } - let n = cluster_size.min(disk.len().saturating_sub(guest)); - - if compressed { - let coffset = crate::bytes::to_usize(e & low_mask(csize_shift)); - let nb_csectors = - crate::bytes::to_usize(e.checked_shr(csize_shift).unwrap_or(0) & csize_mask) - .saturating_add(1); - // The run starts mid-sector, so the first partial sector counts - // against the total length. - let csize = nb_csectors - .saturating_mul(512) - .saturating_sub(coffset & 511); - let Some(raw) = data.get(coffset..coffset.saturating_add(csize).min(data.len())) - else { - continue; // past the end: truncated image, not a hidden cluster - }; - // Cut by the end of the file: what is there is decoded, the - // rest is absent. - let rest_absent = coffset.saturating_add(csize) > data.len(); - // Clusters are raw DEFLATE, with no zlib wrapper. - let s = crate::salvage(crate::inflate::Inflate::new(raw), cluster_size as u64); - let k = n.min(s.data.len()); - disk[guest..guest.saturating_add(k)].copy_from_slice(&s.data[..k]); - if s.part_way(rest_absent) { - undecodable_clusters = undecodable_clusters.saturating_add(1); - } - continue; - } + let mut d = VDisk::new(src, layout, u64::MAX); + let mut out = vec![0u8; n]; + d.read_exact(&mut out).unwrap(); + (out, d.health().undecodable()) + } - // A table entry pointing past the end means the image is - // truncated: those bytes are absent rather than hidden, so the - // cluster stays zero. - let host = crate::bytes::to_usize(e & OFFSET_MASK); - if let Some(src) = crate::bytes::at(data, host, n) { - disk[guest..guest.saturating_add(n)].copy_from_slice(src); - } + fn unread(img: &[u8]) -> (bool, &'static str) { + match open(&img) { + Opened::Unread(_, enc, why) => (enc, why), + Opened::Disk(_) => panic!("the image should not open as a disk"), + Opened::No => panic!("the image should be one"), } } - if undecodable_clusters > 0 { - // Reported alongside the disk, not instead of it: what was reconstructed - // is still worth scanning, and the hole is still worth knowing about. - // Zero-filling a failed cluster and saying nothing would hand the - // scanner clean-looking space where real content should be. - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - "qcow2-disk".to_string(), - (undecodable_clusters as u64).saturating_mul(cluster_size as u64), - false, - "QCOW2 compressed clusters that could not be decompressed", - ), - budget, - ) { - return Ok(Some(r)); + const COPIED: u64 = 1 << 63; + + #[test] + fn a_plain_cluster_is_read_from_its_offset() { + let img = image(2, COPIED | 1536, b"payload"); + assert_eq!(&read(&img, 7).0, b"payload"); + } + + /// Bit 0 of a plain entry makes the cluster zeros, whatever it points at. + #[test] + fn the_zero_flag_of_a_plain_entry_reads_zeros() { + let img = image(3, COPIED | 1536 | 1, b"payload"); + assert_eq!(read(&img, 7).0, [0u8; 7]); + } + + /// An entry with flags and no offset names no cluster. Read as the offset + /// it is, zero, it would give the file's own header as the disk's first + /// sector, where the tool that mounts the image reads zeros. + #[test] + fn an_entry_with_flags_and_no_offset_is_not_the_file_header() { + let img = image(2, COPIED, b"payload"); + assert_eq!(read(&img, 4).0, [0u8; 4]); + } + + /// A cluster that is not wholly in the file is absent, not partly read. + #[test] + fn a_cluster_past_the_end_of_the_file_reads_as_zeros() { + let mut img = image(2, COPIED | 1536, b"payload"); + img.truncate(1536 + 100); + assert_eq!(read(&img, 7).0, [0u8; 7]); + } + + /// An L2 table that is not wholly in the file is absent. + #[test] + fn an_l2_table_past_the_end_of_the_file_reads_as_zeros() { + let mut img = image(2, COPIED | 1536, b"payload"); + img[512..520].copy_from_slice(&(COPIED | 1800).to_be_bytes()); + assert_eq!(read(&img, 7).0, [0u8; 7]); + } + + /// A compressed cluster that will not inflate is counted, and an image cut + /// inside one is not: what is there is decoded and the rest is absent. + /// Forty compressed clusters of 2 MiB are 80 MiB decoded, past what the + /// cache keeps. The first, read again after the rest, is decoded again and + /// is what it was; the cache never holds more than it may, and what was + /// decoded is counted. + #[test] + fn a_compressed_cluster_read_again_after_eviction_is_the_same() { + use std::io::Write; + let (n, cluster) = (40usize, 1usize << 21); + let pattern = + |i: usize| -> Vec<u8> { (0..cluster).map(|k| (k % 251) as u8 ^ i as u8).collect() }; + let mut f = vec![0u8; 4 << 20]; + f[..4].copy_from_slice(b"QFI\xfb"); + f[4..8].copy_from_slice(&2u32.to_be_bytes()); + f[20..24].copy_from_slice(&21u32.to_be_bytes()); + f[24..32].copy_from_slice(&((n * cluster) as u64).to_be_bytes()); + f[36..40].copy_from_slice(&1u32.to_be_bytes()); + f[40..48].copy_from_slice(&4096u64.to_be_bytes()); + // The one L2 table is at 2 MiB, wholly in the file. + f[4096..4104].copy_from_slice(&((1u64 << 63) | (2 << 20)).to_be_bytes()); + for i in 0..n { + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(&pattern(i)).unwrap(); + let z = z.finish().unwrap(); + assert!(z.len() < 100_000); + let at = f.len() as u64; + let sectors = (at % 512 + z.len() as u64).div_ceil(512); + let e = (1u64 << 62) | at | ((sectors - 1) << 49); + f[(2 << 20) + i * 8..(2 << 20) + i * 8 + 8].copy_from_slice(&e.to_be_bytes()); + f.extend_from_slice(&z); + } + let src = f.as_slice(); + let Opened::Disk(layout) = open(&src) else { + panic!("a disk") + }; + let mut d = VDisk::new(&src, layout, u64::MAX); + let at = |d: &mut VDisk<'_, Qcow2<'_>>, i: usize| { + d.seek(SeekFrom::Start((i * cluster + 1000) as u64)) + .unwrap(); + let mut b = vec![0u8; 4096]; + d.read_exact(&mut b).unwrap(); + b + }; + let first = at(&mut d, 0); + assert_eq!(first, pattern(0)[1000..5096]); + for i in 1..n { + assert_eq!(at(&mut d, i), pattern(i)[1000..5096], "cluster {i}"); } + assert_eq!(d.health().decoded(), (n * cluster) as u64); + assert_eq!(at(&mut d, 0), first, "cluster 0 after it was evicted"); + let h = d.health(); + assert_eq!(h.decoded(), ((n + 1) * cluster) as u64, "decoded again"); + assert_eq!(h.undecodable(), 0); + assert!(h.cache_peak() <= vdisk::CACHE_BYTES, "{}", h.cache_peak()); } - budget.commit(disk.len() as u64); - Ok(visit(Entry::new("qcow2-disk".to_string(), disk), budget)) -} -fn report<R>( - reason: &'static str, - size: u64, - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - budget.count_entry()?; - Ok(visit( - Entry::unsupported("qcow2-disk".to_string(), size, false, reason), - budget, - )) -} + #[test] + fn a_compressed_cluster_that_will_not_inflate_is_counted() { + use std::io::Write; + // One sector at 1536, compressed. + let entry = (1u64 << 62) | 1536; + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(&[b'a'; 512]).unwrap(); + let z = z.finish().unwrap(); + assert!(z.len() > 4 && z.len() < 100); + let whole = image(2, entry, &z); + assert_eq!(read(&whole, 512), (vec![b'a'; 512], 0)); + // Bytes that are not deflate. + let (d, bad) = read(&image(2, entry, &[0xFF; 512]), 512); + assert_eq!((d, bad), (vec![0u8; 512], 1)); + // The same stream with the file ending inside it: what is there is + // decoded, the rest is absent, and nothing is left undecoded. + let mut cut = whole.clone(); + cut.truncate(1536 + z.len() / 2); + let (d, bad) = read(&cut, 512); + assert_eq!(bad, 0); + assert!(d.iter().all(|&b| b == 0 || b == b'a')); + } -fn report_enc<R>( - reason: &'static str, - size: u64, - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - budget.count_entry()?; - Ok(visit( - Entry::unsupported("qcow2-disk".to_string(), size, true, reason), - budget, - )) + #[test] + fn the_features_that_change_how_the_image_is_read_are_not_ignored() { + assert!(unread(&v3(1 << 2, 104, 0)).1.contains("external data file")); + assert!(unread(&v3(1 << 4, 104, 0)).1.contains("extended L2")); + assert!( + unread(&v3(1 << 5, 104, 0)) + .1 + .contains("feature exav does not know") + ); + assert!(unread(&v3(1 << 3, 112, 9)).1.contains("compression type")); + // Dirty and corrupt do not change where anything is. + assert!(matches!( + open(&v3(0b11, 104, 0).as_slice()), + Opened::Disk(_) + )); + // Zlib named by the feature, or the field absent: deflate. + assert!(matches!( + open(&v3(1 << 3, 112, 0).as_slice()), + Opened::Disk(_) + )); + assert!(matches!( + open(&v3(1 << 3, 104, 1).as_slice()), + Opened::Disk(_) + )); + } + + #[test] + fn an_image_that_is_not_the_disk_is_reported_with_what_it_declares() { + let mut img = image(2, 0, &[]); + img[16..20].copy_from_slice(&5u32.to_be_bytes()); + assert!(unread(&img).1.contains("backing file")); + let mut img = image(2, 0, &[]); + img[32..36].copy_from_slice(&1u32.to_be_bytes()); + assert_eq!(unread(&img), (true, "encrypted QCOW2 image")); + for (at, v) in [(20usize, 8u32), (20, 22), (36, 0)] { + let mut img = image(2, 0, &[]); + img[at..at + 4].copy_from_slice(&v.to_be_bytes()); + assert!(unread(&img).1.contains("geometry"), "{at} {v}"); + } + } + + /// A table bigger than the file is refused up front, and one that fits is + /// not held: the size of the disk is what the tables cover. + #[test] + fn the_l1_table_has_to_be_in_the_file_and_bounds_the_disk() { + let mut img = image(2, 0, &[]); + img[36..40].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(unread(&img).1.contains("L1 table")); + // Offset near the top of the range: the sum must not wrap. + let mut img = image(2, 0, &[]); + img[40..48].copy_from_slice(&(u64::MAX - 3).to_be_bytes()); + assert!(unread(&img).1.contains("L1 table")); + // A size beyond what one L1 entry covers is cut to it. + let mut img = image(2, 0, &[]); + img[24..32].copy_from_slice(&u64::MAX.to_be_bytes()); + let src = img.as_slice(); + let Opened::Disk(l) = open(&src) else { + panic!() + }; + assert_eq!(l.size(), 64 * 512); + } } diff --git a/crates/exav-unpack/src/formats/rar.rs b/crates/exav-unpack/src/formats/rar.rs index a221ac90..5b6c3568 100644 --- a/crates/exav-unpack/src/formats/rar.rs +++ b/crates/exav-unpack/src/formats/rar.rs @@ -164,7 +164,7 @@ fn rar4_decrypt_member( None => rar3_crypt::candidates(&budget.passwords), }; for pw in tries { - let Some(keys) = crypt.keys.get(&pw, &salt) else { + let Some(keys) = crypt.keys.get(&pw, &salt, &budget.rar3_derivations) else { break; // no more derivations: the member stays encrypted }; let mut buf = raw.to_vec(); @@ -234,7 +234,7 @@ fn rar4_open_headers( let mut blocks = 0; while q < data.len() { let salt: [u8; 8] = crate::bytes::at(data, q, 8)?.try_into().ok()?; - let keys = crypt.keys.get(&pw, &salt)?; + let keys = crypt.keys.get(&pw, &salt, &budget.rar3_derivations)?; let mut first = data.get(q + 8..q + 24)?.to_vec(); rar3_crypt::decrypt(&keys, &mut first); let head_size = usize::from(u16::from_le_bytes([first[5], first[6]])); @@ -883,7 +883,7 @@ fn rar5_decrypt_member( if raw.len() as u64 > budget.limits.max_buffer_bytes { return None; } - let k = rar5_crypt::find_keys(&rec, &budget.passwords, keys)?; + let k = rar5_crypt::find_keys(&rec, &budget.passwords, keys, &budget.kdf_rounds)?; let mut buf = raw.to_vec(); rar5_crypt::decrypt(&k.key, &rec.iv, &mut buf); Some((buf, rec.tweaked.then_some(k.hash_key))) @@ -913,7 +913,7 @@ fn rar5_open_headers( budget: &Budget, keys: &mut KeyCache, ) -> Option<Vec<u8>> { - let k = rar5_crypt::find_keys(rec, &budget.passwords, keys)?; + let k = rar5_crypt::find_keys(rec, &budget.passwords, keys, &budget.kdf_rounds)?; let mut plain = data.get(..start)?.to_vec(); let mut headers = 0; while p < data.len() { @@ -1159,14 +1159,14 @@ fn join_rar4(volumes: &[&[u8]]) -> Result<Vec<u8>, String> { // it is given. if htype == 0x7B && flags & 0x0008 != 0 { let at = pos + 7 + if flags & 0x0002 != 0 { 4 } else { 0 }; - if let Some(n) = u16le(v, at).filter(|_| at + 2 <= pos + head_size) { - if usize::from(n) != k { - return Err(format!( - "part {} is volume {} of its set", - k + 1, - usize::from(n) + 1 - )); - } + if let Some(n) = u16le(v, at).filter(|_| at + 2 <= pos + head_size) + && usize::from(n) != k + { + return Err(format!( + "part {} is volume {} of its set", + k + 1, + usize::from(n) + 1 + )); } } match htype { @@ -1186,13 +1186,13 @@ fn join_rar4(volumes: &[&[u8]]) -> Result<Vec<u8>, String> { (false, None) => out.extend_from_slice(&v[pos..end]), (true, Some(p)) => p.data.extend_from_slice(data), (false, Some(_)) => { - return Err(format!("part {} drops a member mid-way", k + 1)) + return Err(format!("part {} drops a member mid-way", k + 1)); } (true, None) => { return Err(format!( "part {} goes on with a member whose start is missing", k + 1 - )) + )); } } if before && !after { @@ -1248,7 +1248,7 @@ fn rar4_whole(mut p: Pending, crc: u32) -> Result<Vec<u8>, String> { match (flags & 0x100 != 0, h.len() >= 40) { (true, true) => h[32..36].copy_from_slice(&((total >> 32) as u32).to_le_bytes()), _ if total > u64::from(u32::MAX) => { - return Err("a member past 4 GiB with no 64-bit size".to_string()) + return Err("a member past 4 GiB with no 64-bit size".to_string()); } _ => {} } @@ -1320,16 +1320,16 @@ fn join_rar5(volumes: &[&[u8]]) -> Result<Vec<u8>, String> { let Some(b) = rar5_block(v, pos) else { break }; // A volume past the first records its number: it must be where // it is given. - if b.htype == 1 && k > 0 { - if let Some(n) = rar5_volume_number(&v[b.hdr..b.data_off]) { - if n != k as u64 { - return Err(format!( - "part {} is volume {} of its set", - k + 1, - n.saturating_add(1) - )); - } - } + if b.htype == 1 + && k > 0 + && let Some(n) = rar5_volume_number(&v[b.hdr..b.data_off]) + && n != k as u64 + { + return Err(format!( + "part {} is volume {} of its set", + k + 1, + n.saturating_add(1) + )); } match b.htype { 1 if k == 0 => out.extend_from_slice(&v[pos..b.end]), @@ -1354,13 +1354,13 @@ fn join_rar5(volumes: &[&[u8]]) -> Result<Vec<u8>, String> { deferred.extend_from_slice(&v[pos..b.end]) } (false, Some(_)) => { - return Err(format!("part {} drops a member mid-way", k + 1)) + return Err(format!("part {} drops a member mid-way", k + 1)); } (true, None) => { return Err(format!( "part {} goes on with a member whose start is missing", k + 1 - )) + )); } } if before && !after { @@ -1565,7 +1565,7 @@ mod tests { v.extend_from_slice(&0u16.to_le_bytes()); // flags v.extend_from_slice(&13u16.to_le_bytes()); // head_size v.extend_from_slice(&[0; 6]); // reserved1(2)+reserved2(4) = 6 -> total 13 - // file header (type 0x74), flags 0x8000 (ADD_SIZE present) + // file header (type 0x74), flags 0x8000 (ADD_SIZE present) let mut fh = Vec::new(); fh.extend_from_slice(&(payload.len() as u32).to_le_bytes()); // PACK_SIZE fh.extend_from_slice(&(payload.len() as u32).to_le_bytes()); // UNP_SIZE @@ -1577,8 +1577,8 @@ mod tests { fh.extend_from_slice(&(name.len() as u16).to_le_bytes()); // NAME_SIZE fh.extend_from_slice(&0u32.to_le_bytes()); // ATTR fh.extend_from_slice(name); // NAME - // PACK_SIZE (first field of `fh`) doubles as the block's ADD_SIZE; there - // is no separate ADD_SIZE field. Header = generic(7) + fields. + // PACK_SIZE (first field of `fh`) doubles as the block's ADD_SIZE; there + // is no separate ADD_SIZE field. Header = generic(7) + fields. let head_size = 7 + fh.len(); v.extend_from_slice(&[0, 0]); // crc v.push(0x74); @@ -1786,8 +1786,8 @@ mod tests { let arcs = [ rar4_stored(b"hi.txt", &[b'z'; 1000]), rar5_stored(b"a/b.bin", &[b'y'; 1000]), - std::fs::read(format!("{dir}/solid_rar4.rar")).unwrap(), - std::fs::read(format!("{dir}/solid_rar5.rar")).unwrap(), + crate::read_fixture(&format!("{dir}/solid_rar4.rar")).unwrap(), + crate::read_fixture(&format!("{dir}/solid_rar5.rar")).unwrap(), ]; let names = |e: &[Entry]| { e.iter() diff --git a/crates/exav-unpack/src/formats/rar3_crypt.rs b/crates/exav-unpack/src/formats/rar3_crypt.rs index d09bee5b..22300d66 100644 --- a/crates/exav-unpack/src/formats/rar3_crypt.rs +++ b/crates/exav-unpack/src/formats/rar3_crypt.rs @@ -8,7 +8,9 @@ //! The format has no password check: a wrong password is found out by the //! member's checksum. -use aes::cipher::{block_padding::NoPadding, BlockModeDecrypt, KeyIvInit}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use aes::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}; use sha1::{Digest, Sha1}; type Aes128CbcDec = cbc::Decryptor<aes::Aes128>; @@ -64,7 +66,7 @@ pub(crate) struct KeyCache { limit: usize, } -/// The derivations one archive may make: about half a minute of SHA-1. +/// The derivations one scan may make, whatever the number of archives in it. const MAX_DERIVATIONS: usize = 256; impl Default for KeyCache { @@ -78,8 +80,14 @@ impl Default for KeyCache { impl KeyCache { /// The keys of `password` over `salt`; `None` when they are not cached and - /// the limit of derivations is reached. - pub(crate) fn get(&mut self, password: &str, salt: &[u8; 8]) -> Option<Keys> { + /// the limit of derivations is reached. `made` counts the derivations of + /// the whole scan. + pub(crate) fn get( + &mut self, + password: &str, + salt: &[u8; 8], + made: &AtomicUsize, + ) -> Option<Keys> { if let Some((.., k)) = self .keys .iter() @@ -87,7 +95,7 @@ impl KeyCache { { return Some(k.clone()); } - if self.keys.len() >= self.limit { + if made.fetch_add(1, Ordering::Relaxed) >= self.limit { return None; } let k = derive(password, salt); @@ -121,11 +129,33 @@ mod tests { keys: Vec::new(), limit: 3, }; + let made = AtomicUsize::new(0); for salt in 0..10u8 { - let got = cache.get("pw", &[salt; 8]); + let got = cache.get("pw", &[salt; 8], &made); assert_eq!(got.is_some(), salt < 3, "salt {salt}"); } - assert!(cache.get("pw", &[1; 8]).is_some(), "a cached one is served"); + assert!( + cache.get("pw", &[1; 8], &made).is_some(), + "a cached one is served" + ); assert_eq!(cache.keys.len(), 3); } + + /// Archives inside archives each start a cache of their own: the limit is + /// the scan's, counted in `made`, not each archive's. + #[test] + fn archives_of_one_scan_share_the_derivation_limit() { + let made = AtomicUsize::new(0); + let mut got = 0; + for archive in 0..10u8 { + let mut cache = KeyCache { + keys: Vec::new(), + limit: 3, + }; + for salt in 0..5u8 { + got += usize::from(cache.get("pw", &[archive * 5 + salt; 8], &made).is_some()); + } + } + assert_eq!(got, 3); + } } diff --git a/crates/exav-unpack/src/formats/rar3_unpack.rs b/crates/exav-unpack/src/formats/rar3_unpack.rs index aa2c888e..a7b09286 100644 --- a/crates/exav-unpack/src/formats/rar3_unpack.rs +++ b/crates/exav-unpack/src/formats/rar3_unpack.rs @@ -15,8 +15,8 @@ //! Everything is bounds-checked and never panics on malformed input. use super::ppmd7::{ - Ppmd7, RarRangeDecoder, PPMD7_MAX_MEM_SIZE, PPMD7_MAX_ORDER, PPMD7_MIN_MEM_SIZE, - PPMD7_MIN_ORDER, SYM_END, + PPMD7_MAX_MEM_SIZE, PPMD7_MAX_ORDER, PPMD7_MIN_MEM_SIZE, PPMD7_MIN_ORDER, Ppmd7, + RarRangeDecoder, SYM_END, }; use crate::{Budget, LimitHit}; use std::collections::VecDeque; @@ -1191,10 +1191,11 @@ impl DecodeReader { None => Ok(0), }; } - } else if let Some(f) = self.filters.front() { - if f.offset == 0 && f.length > self.win.buffered() { - self.fill(); - } + } else if let Some(f) = self.filters.front() + && f.offset == 0 + && f.length > self.win.buffered() + { + self.fill(); } if !self.filters.is_empty() { self.process_filters()?; @@ -1566,11 +1567,7 @@ fn filter_itanium(mut buf: Vec<u8>, offset: i64) -> Vec<u8> { } fn abs_i(n: i32) -> i32 { - if n < 0 { - -n - } else { - n - } + if n < 0 { -n } else { n } } fn filter_rgb(r0: u32, r1: u32, buf: &[u8]) -> Vec<u8> { diff --git a/crates/exav-unpack/src/formats/rar5_crypt.rs b/crates/exav-unpack/src/formats/rar5_crypt.rs index 89650ba8..d39168d3 100644 --- a/crates/exav-unpack/src/formats/rar5_crypt.rs +++ b/crates/exav-unpack/src/formats/rar5_crypt.rs @@ -6,6 +6,8 @@ //! key that tweaks the member's checksum, and the password check, the last //! folded to 8 bytes by XOR. +use std::sync::atomic::AtomicU64; + use aes::cipher::{BlockModeDecrypt, KeyIvInit}; use hmac::{Hmac, KeyInit, Mac}; use sha2::{Digest, Sha256}; @@ -134,19 +136,28 @@ pub(crate) fn find_keys( record: &CryptRecord, passwords: &[String], cache: &mut Vec<([u8; 16], u8, Vec<u8>, Keys)>, + spent: &AtomicU64, ) -> Option<Keys> { - find_keys_within(record, passwords, cache, MAX_KDF_ROUNDS) + find_keys_within(record, passwords, cache, spent, MAX_KDF_ROUNDS) } -/// The PBKDF2 iterations all the derivations of one archive may add up to: -/// every member may carry a salt of its own, and every password is tried over -/// each. About half a minute of HMACs. -const MAX_KDF_ROUNDS: u64 = 1 << 25; +/// The PBKDF2 iterations all the derivations of one scan may add up to, however +/// many archives it opens: every member may carry a salt of its own, every +/// password is tried over each, and an archive may hold another. About 0.7 s of +/// HMACs: 128 derivations at the count RAR writes (15), or one at 22. A +/// header asking for 2^24 took 2.6 s a scan, and a member with a larger count +/// than this is reported as encrypted, like one no password opens. +const MAX_KDF_ROUNDS: u64 = crate::kdf::PBKDF2_MAX_ROUNDS; + +use crate::kdf::charge; +/// `spent` counts the iterations derived so far in the scan, `cache` only the +/// ones of this archive. fn find_keys_within( record: &CryptRecord, passwords: &[String], cache: &mut Vec<([u8; 16], u8, Vec<u8>, Keys)>, + spent: &AtomicU64, max_rounds: u64, ) -> Option<Keys> { let candidates = passwords @@ -154,7 +165,6 @@ fn find_keys_within( .map(String::as_str) .chain(super::DEFAULT_ARCHIVE_PASSWORDS.iter().copied()); let rounds_of = |count: u8| 1u64 << count.min(MAX_KDF_COUNT); - let mut spent: u64 = cache.iter().map(|(_, c, ..)| rounds_of(*c)).sum(); for pw in candidates { let pw = pw.as_bytes(); let cached = cache @@ -164,8 +174,8 @@ fn find_keys_within( let keys = match cached { Some(k) => k, None => { - spent = spent.saturating_add(rounds_of(record.kdf_count)); - if spent > max_rounds { + let rounds = rounds_of(record.kdf_count); + if !charge(spent, rounds, max_rounds) { return None; } let k = derive(pw, &record.salt, record.kdf_count)?; @@ -228,15 +238,60 @@ mod tests { } } + /// Archives inside archives each start a cache of their own, so a bound + /// kept in the cache is a bound per archive: a chain of them paid it each + /// time (21 s under a sanitizer for one 25 KB file). The bound is the + /// scan's. + #[test] + fn archives_of_one_scan_share_the_derivation_bound() { + let passwords: Vec<String> = (0..50).map(|i| format!("password {i}")).collect(); + let spent = AtomicU64::new(0); + // 2^10 iterations a derivation, 2^13 allowed in all: eight of them, + // however many archives they are spread over. + let mut derived = 0; + for archive in 0..10 { + let mut cache = Vec::new(); + let _ = find_keys_within( + &record(archive, 10), + &passwords, + &mut cache, + &spent, + 1 << 13, + ); + derived += cache.len(); + } + assert_eq!(derived, 8); + } + + /// A header asking for 2^24 iterations (the largest count RAR writes) cost + /// 2.6 s to refuse every password, 20 s under a sanitizer; it is not derived. + #[test] + fn a_count_whose_derivation_exceeds_the_scan_bound_is_not_derived() { + let passwords = vec!["secret".to_string()]; + let mut cache = Vec::new(); + let spent = AtomicU64::new(0); + let start = std::time::Instant::now(); + assert!(find_keys(&record(1, 24), &passwords, &mut cache, &spent).is_none()); + assert!(cache.is_empty()); + assert!(start.elapsed() < std::time::Duration::from_millis(500)); + // Counts RAR writes by default are still derived. + let _ = find_keys(&record(1, 15), &passwords, &mut cache, &spent); + assert!(!cache.is_empty()); + } + /// Salts of a member each, and a password list: the derivations stop at /// the archive's total, not at the end of the members. #[test] fn key_derivations_over_many_salts_stop_at_the_total() { let passwords: Vec<String> = (0..50).map(|i| format!("password {i}")).collect(); let mut cache = Vec::new(); + let spent = AtomicU64::new(0); // 2^10 iterations a derivation, 2^13 allowed in all: eight of them. for salt in 0..40 { - assert!(find_keys_within(&record(salt, 10), &passwords, &mut cache, 1 << 13).is_none()); + assert!( + find_keys_within(&record(salt, 10), &passwords, &mut cache, &spent, 1 << 13) + .is_none() + ); } assert_eq!(cache.len(), 8); } diff --git a/crates/exav-unpack/src/formats/rar5_unpack.rs b/crates/exav-unpack/src/formats/rar5_unpack.rs index be988157..e33e1afc 100644 --- a/crates/exav-unpack/src/formats/rar5_unpack.rs +++ b/crates/exav-unpack/src/formats/rar5_unpack.rs @@ -730,18 +730,19 @@ fn run_filter(u: &mut Unpacker, flt: &FilterInfo) -> Result<(), LimitHit> { /// apply_filters: emit pending output, running the next filter if its range is /// fully unpacked. Returns true if it made progress (should be retried). fn apply_filters(u: &mut Unpacker) -> Result<bool, LimitHit> { - if let Some(flt) = u.filters.front() { - if u.write_ptr > flt.block_start && u.write_ptr >= flt.block_start + flt.block_length { - if u.last_write_ptr == flt.block_start { - let flt = u.filters.pop_front().unwrap(); + let Some((bs, len)) = u.filters.front().map(|f| (f.block_start, f.block_length)) else { + return Ok(false); + }; + if u.write_ptr > bs && u.write_ptr >= bs + len { + if u.last_write_ptr == bs { + if let Some(flt) = u.filters.pop_front() { run_filter(u, &flt)?; - } else { - let bs = flt.block_start; - push_window_data(u, u.last_write_ptr, bs)?; - u.last_write_ptr = bs; } - return Ok(true); + } else { + push_window_data(u, u.last_write_ptr, bs)?; + u.last_write_ptr = bs; } + return Ok(true); } Ok(false) } diff --git a/crates/exav-unpack/src/formats/reported.rs b/crates/exav-unpack/src/formats/reported.rs index b5513e0f..3a059585 100644 --- a/crates/exav-unpack/src/formats/reported.rs +++ b/crates/exav-unpack/src/formats/reported.rs @@ -12,8 +12,8 @@ //! //! # Why these are still here //! -//! Two of them are blocked on licensing rather than on effort, and that is -//! worth writing down so nobody re-derives the search: +//! One is blocked on licensing, the others on effort or on a way to check a +//! decoder; that is worth writing down so nobody re-derives the search: //! //! * **lrzip** — the rzip long-range match stream has no published //! specification. It is defined only by lrzip's own GPL source, which exav @@ -21,12 +21,14 @@ //! language. Running the tool as an *oracle* is allowed, but inferring an //! exact bitstream from black-box behaviour is not something to attempt and //! then claim. -//! * **InstallShield `ISc(` cabinets** — likewise undocumented; the only -//! implementation is the LGPL `unshield` **C project**. Not to be confused -//! with the MIT `unshield` *crate*, which reads the older InstallShield `.z` -//! archive — a different format, and one exav does open (`formats/ishield_z`). +//! * **InstallShield `ISc(` cabinets** — undocumented; the implementation is +//! the `unshield` **C project**, under the MIT licence, so its source may be +//! read and ported with a NOTICE entry. Not to be confused with the MIT +//! `unshield` *crate*, which reads the older InstallShield `.z` archive — a +//! different format, and one exav does open (`formats/ishield_z`, ported +//! from that crate). //! -//! Both therefore stay recognised-and-reported, which is the honest answer: +//! They stay recognised-and-reported, which is the honest answer: //! `UNSCANNABLE` tells an operator there is a gap, where a decoder that guessed //! would produce plausible bytes and a confident `OK`. diff --git a/crates/exav-unpack/src/formats/romfs.rs b/crates/exav-unpack/src/formats/romfs.rs new file mode 100644 index 00000000..d744d744 --- /dev/null +++ b/crates/exav-unpack/src/formats/romfs.rs @@ -0,0 +1,443 @@ +//! romfs: the files of an image, read where they lie. +//! +//! The smallest read-only filesystem Linux has, of boot floppies and the +//! firmware of small devices: nothing is compressed, so a file is the bytes +//! after its header. The image is a `Read + Seek`, read where the walk goes. +//! Only a file is ever held, bounded by the buffer limit. +//! +//! ```text +//! "-rom1fs-" size checksum volume name padded to 16 bytes +//! file header (16 bytes) next header and type, a directory's first entry +//! (or a link's target), size, checksum +//! name (null-ended, padded to 16), then the data (padded to 16) +//! ``` +//! +//! All numbers are big-endian. A directory is the list of headers that its +//! first entry begins and each one's next header goes on with. A hard link is +//! read as the file it names. A symbolic link or a device node holds no file's +//! bytes and is not emitted. The checksums are not checked. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] + +use std::collections::HashSet; +use std::io::{Read, Seek}; + +use super::vol::{Attrs, Emit, Got, List, Piece, Pieces, Vol, be32, give, read_file}; +use crate::source::{ByteSource, Reader}; +use crate::stream::Visit; +use crate::{Budget, LimitHit}; + +/// The most directories a path passes through. +const MAX_DEPTH: usize = 256; + +/// The longest a name is, bytes. +const MAX_NAME: usize = 1024; + +/// How many hard links are followed to a file. +const MAX_LINKS: usize = 8; + +/// The kinds of header. +const HARD_LINK: u32 = 0; +const DIRECTORY: u32 = 1; +const FILE: u32 = 2; + +/// Whether `data`, the start of an image, holds the name romfs begins with. +pub(crate) fn is_romfs(data: &[u8]) -> bool { + super::sniff::is(data, crate::Format::Romfs) +} + +/// A file header. +struct Header { + kind: u32, + executable: bool, + next: u64, + spec: u64, + size: u64, + name: String, + /// Where the data begins. + data: u64, +} + +/// The next multiple of 16. +fn align(n: u64) -> u64 { + n.saturating_add(15) & !15 +} + +/// The header at `at`. +fn header(v: &mut Vol, at: u64) -> Option<Header> { + let h = v.at(at, 16)?; + let word = be32(&h, 0); + let mut name = Vec::new(); + let mut pos = at.checked_add(16)?; + loop { + let chunk = v.at(pos, 16)?; + match chunk.iter().position(|&b| b == 0) { + Some(end) => { + name.extend_from_slice(&chunk[..end]); + break; + } + None => name.extend_from_slice(&chunk), + } + if name.len() > MAX_NAME { + return None; + } + pos = pos.checked_add(16)?; + } + // The name and its null end, padded to 16 bytes. + let data = at + .checked_add(16)? + .checked_add(align((name.len() as u64).saturating_add(1)))?; + Some(Header { + kind: word & 7, + executable: word & 8 != 0, + next: u64::from(word & !15), + spec: u64::from(be32(&h, 4)), + size: u64::from(be32(&h, 8)), + name: String::from_utf8_lossy(&name).into_owned(), + data, + }) +} + +/// The entries of the directory whose first header is at `first`: header and +/// place. The flag is false where the list is damaged. `left` counts the +/// headers the walk may still read. +fn entries(v: &mut Vol, first: u64, left: &mut u64, limit: usize) -> (Vec<(u64, Header)>, bool) { + let mut out = Vec::new(); + let mut at = first; + let mut seen = HashSet::new(); + while at != 0 { + if *left == 0 || !seen.insert(at) { + return (out, false); + } + *left = left.saturating_sub(1); + let Some(h) = header(v, at) else { + return (out, false); + }; + let next = h.next; + if h.name != "." && h.name != ".." { + if out.len() >= limit { + return (out, true); + } + out.push((at, h)); + } + at = next; + } + (out, true) +} + +/// The bytes of a file whose header is `h`: held when they are at most `cap`, +/// else read as they go. +fn file<'a>(v: &'a mut Vol<'_>, h: &Header, cap: u64) -> Got<'a> { + let plan = List { + pieces: vec![Piece::Disk(h.data, h.size)].into_iter(), + intact: true, + }; + read_file(v, Pieces::new(plan, h.size), h.size, cap) +} + +/// Walk a romfs image given whole as a source. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + super::volumes::entries(budget, visit, "", |budget, sink| { + walk_volume(Reader::new(src), src.len() as u64, budget, sink) + }) +} + +/// Walk the romfs image `disk`, which is `len` bytes long, handing each file +/// to `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + len: u64, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + let max = crate::bytes::to_usize(budget.limits().max_buffer_bytes); + let mut v = Vol { + disk: &mut disk, + len, + max, + }; + let head = v + .at(0, crate::bytes::to_usize(len.min(64))) + .unwrap_or_default(); + if !is_romfs(&head) { + return Ok(None); + } + // Nothing is read from past the size the image says it has. + v.len = v.len.min(u64::from(be32(&head, 8))); + // The volume name, padded to 16 bytes, and then the header of the root. + let name_end = head + .get(16..) + .and_then(|n| n.iter().position(|&b| b == 0)) + .unwrap_or(0); + let root_at = 16u64.saturating_add(align((name_end as u64).saturating_add(1))); + let Some(root) = header(&mut v, root_at).filter(|r| r.kind == DIRECTORY) else { + return Err(LimitHit::corrupt( + "romfs open: the root directory is unreadable".into(), + )); + }; + + // One more than the member limit, so the entry past it is counted and the + // walk stops there rather than ending short. + let limit = crate::bytes::to_usize(budget.limits().max_members).saturating_add(1); + // The headers of an image are disjoint, 16 bytes at the least each. + let mut left = v.len / 16; + let mut damaged = false; + let mut seen: HashSet<u64> = HashSet::from([root.spec]); + let mut stack = vec![(root.spec, String::new(), 0usize)]; + while let Some((first, prefix, depth)) = stack.pop() { + let (list, intact) = entries(&mut v, first, &mut left, limit); + damaged |= !intact; + let mut below = Vec::new(); + for (_, h) in list { + budget.count_entry()?; + let path = if prefix.is_empty() { + h.name.clone() + } else { + format!("{prefix}/{}", h.name) + }; + // A hard link is the file it names. + let mut target = h; + let mut hops = 0usize; + while target.kind == HARD_LINK && hops < MAX_LINKS { + hops = hops.saturating_add(1); + match header(&mut v, target.spec) { + Some(t) => { + target = Header { + name: target.name, + ..t + }; + } + None => { + damaged = true; + break; + } + } + } + // Links that lead only to links lead nowhere. + if target.kind == HARD_LINK { + damaged = true; + continue; + } + match target.kind { + DIRECTORY => { + if depth >= MAX_DEPTH { + damaged = true; + } else if seen.insert(target.spec) { + below.push((target.spec, path, depth.saturating_add(1))); + } + } + FILE => { + let attrs = Attrs { + mode: Some(if target.executable { + 0o100755 + } else { + 0o100644 + }), + mtime: None, + }; + if let Some(r) = give(budget, &mut *visit, path, attrs, |cap| { + file(&mut v, &target, cap) + })? { + return Ok(Some(r)); + } + } + // A link or a node holds no file's bytes. + _ => {} + } + } + stack.extend(below.into_iter().rev()); + } + if damaged { + return Err(LimitHit::corrupt( + "romfs walk: the image is damaged; the files before the damage were scanned".into(), + )); + } + Ok(None) +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::super::vol::collected; + use super::*; + use crate::Limits; + use std::io::Cursor; + + /// An image being written: headers at the places it is told. + struct Img(Vec<u8>); + + impl Img { + fn new() -> Img { + let mut d = vec![0u8; 32]; + d[..8].copy_from_slice(b"-rom1fs-"); + d[16] = b'T'; + Img(d) + } + + /// A header at `at` (which must be a multiple of 16), its data after + /// the name; the place after it. + fn put( + &mut self, + at: usize, + next: u32, + kind: u32, + spec: u32, + name: &str, + data: &[u8], + ) -> usize { + let end = at + 16 + (name.len() + 1).div_ceil(16) * 16 + data.len().div_ceil(16) * 16; + if self.0.len() < end.max(at + 16) { + self.0.resize(end.max(at + 16), 0); + } + self.0[at..at + 4].copy_from_slice(&(next | kind).to_be_bytes()); + self.0[at + 4..at + 8].copy_from_slice(&spec.to_be_bytes()); + self.0[at + 8..at + 12].copy_from_slice(&(data.len() as u32).to_be_bytes()); + self.0[at + 16..at + 16 + name.len()].copy_from_slice(name.as_bytes()); + let d = at + 16 + (name.len() + 1).div_ceil(16) * 16; + self.0[d..d + data.len()].copy_from_slice(data); + end + } + + fn finish(mut self) -> Vec<u8> { + let n = self.0.len() as u32; + self.0[8..12].copy_from_slice(&n.to_be_bytes()); + self.0 + } + } + + type Member = (String, Vec<u8>, Option<&'static str>); + + fn walked(d: &[u8], limits: Limits) -> (Vec<Member>, Result<(), LimitHit>) { + let mut out = Vec::new(); + let mut budget = Budget::new(limits); + let len = d.len() as u64; + let end = walk_volume(Cursor::new(d), len, &mut budget, &mut |e, _| { + let e = e.whole(); + out.push((e.name, e.data, e.unsupported)); + None::<()> + }); + (out, end.map(|_| ())) + } + + /// A root (`.` at 32, `..` at 64, and the entries from 96) with a file `a` + /// ("hi"), a folder `d` holding a file `b`, and a link `l` to `a`. + fn tree() -> Img { + let mut img = Img::new(); + img.put(32, 64, DIRECTORY, 32, ".", b""); + img.put(64, 96, DIRECTORY, 32, "..", b""); + // a at 96 (data after the name: 96 + 16 + 16 + 16 = 144), d at 144. + img.put(96, 144, FILE, 0, "a", b"hi"); + img.put(144, 224, DIRECTORY, 176, "d", b""); + // d's entries from 176: `.` is not listed, `b` is. + img.put(176, 0, FILE, 0, "b", b"in d"); + // The link follows d in the root's list, after d's entries. + img.put(224, 0, HARD_LINK, 96, "l", b""); + img + } + + #[test] + fn the_files_of_a_tree_come_out_and_a_hard_link_is_the_file_it_names() { + let (e, end) = walked(&tree().finish(), Limits::default()); + end.unwrap(); + let mut got: Vec<_> = e.iter().map(|m| (m.0.as_str(), m.1.as_slice())).collect(); + got.sort(); + assert_eq!( + got, + [("a", &b"hi"[..]), ("d/b", &b"in d"[..]), ("l", &b"hi"[..])] + ); + } + + #[test] + fn a_list_that_comes_back_to_a_header_ends_with_the_files_before_it() { + let mut img = tree(); + // a's next is a. + img.0[96..100].copy_from_slice(&(96u32 | FILE).to_be_bytes()); + let (e, end) = walked(&img.finish(), Limits::default()); + assert!(end.unwrap_err().is_corrupt()); + assert_eq!(e.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(), ["a"]); + } + + #[test] + fn a_folder_that_lists_the_folder_it_is_in_is_walked_once() { + let mut img = tree(); + // d's first entry is the root's first: its list is the root's. + img.0[148..152].copy_from_slice(&96u32.to_be_bytes()); + let (e, end) = walked(&img.finish(), Limits::default()); + end.unwrap(); + let names: Vec<_> = e.iter().map(|m| m.0.as_str()).collect(); + assert_eq!( + names.iter().filter(|n| n.ends_with('a')).count(), + 2, + "{names:?}" + ); + assert!(names.len() < 8, "{names:?}"); + } + + #[test] + fn links_that_lead_only_to_links_end_the_walk_as_damaged() { + let mut img = Img::new(); + img.put(32, 64, DIRECTORY, 32, ".", b""); + img.put(64, 96, DIRECTORY, 32, "..", b""); + img.put(96, 128, HARD_LINK, 128, "x", b""); + img.put(128, 0, HARD_LINK, 96, "y", b""); + let (e, end) = walked(&img.finish(), Limits::default()); + assert!(e.is_empty()); + assert!(end.unwrap_err().is_corrupt()); + } + + #[test] + fn a_file_longer_than_the_image_is_reported_with_what_there_is() { + let mut img = tree(); + img.0[96 + 8..96 + 12].copy_from_slice(&5000u32.to_be_bytes()); + let (e, end) = walked(&img.finish(), Limits::default()); + end.unwrap(); + let a = e.iter().find(|m| m.0 == "a").unwrap(); + assert_eq!(a.2, Some("file could not be read from the volume")); + } + + #[test] + fn the_entry_past_the_member_limit_is_counted_and_stops_the_walk() { + let run = |max: u64| { + let limits = Limits { + max_members: max, + ..Limits::default() + }; + walked(&tree().finish(), limits) + }; + // a, d, b and l. + let (e, end) = run(4); + end.unwrap(); + assert_eq!(e.len(), 3); + let (_, end) = run(3); + assert!(!end.unwrap_err().is_corrupt()); + } + + #[test] + fn a_file_over_the_buffer_limit_is_read_as_it_goes_and_the_walk_goes_on() { + let limits = Limits { + max_buffer_bytes: 3, + ..Limits::default() + }; + let (held, _) = walked(&tree().finish(), Limits::default()); + let img = tree().finish(); + let len = img.len() as u64; + let mut budget = Budget::new(limits); + let (e, end) = collected(|emit| walk_volume(Cursor::new(&img[..]), len, &mut budget, emit)); + end.unwrap(); + // The same files, the same bytes, the long ones not held. + assert_eq!(e.len(), held.len()); + for ((entry, streamed), (name, data, _)) in e.iter().zip(&held) { + assert_eq!((&entry.name, &entry.data), (name, data)); + assert_eq!(*streamed, data.len() > 3, "{name}"); + assert_eq!(entry.unsupported, None, "{name}"); + } + assert!(e.iter().any(|(_, streamed)| *streamed)); + } +} diff --git a/crates/exav-unpack/src/formats/rpm.rs b/crates/exav-unpack/src/formats/rpm.rs new file mode 100644 index 00000000..6cc2bc83 --- /dev/null +++ b/crates/exav-unpack/src/formats/rpm.rs @@ -0,0 +1,229 @@ +//! RPM packages: the install scripts and the payload. +//! +//! An RPM is a 96-byte lead, a signature header, the package's header (both +//! "header structures": a magic, an entry count, a size of data, the entries, +//! then the data) and the payload, a compressed cpio archive. The payload comes +//! out as one member, named for its format and compressor, which the scanner +//! opens in turn (gzip, xz, zstd, bzip2 or lzma, then cpio). The scripts that +//! run as root when a package is installed, removed or updated sit in the +//! header's data; each comes out as a member of its own, so signatures see them +//! as text. Integers are big-endian. +//! +//! Every count and offset is checked against the file before it is used; the +//! header data is read whole, up to a fixed bound. + +use crate::*; +use std::io::{Read, Seek, SeekFrom}; + +const LEAD: u64 = 96; +const HEADER_MAGIC: [u8; 4] = [0x8E, 0xAD, 0xE8, 0x01]; +/// Signature type of a lead that is followed by a signature header. +const HEADER_SIGNATURE: u16 = 5; +/// The most entries a header may have (rpm's own bound). +const MAX_ENTRIES: u32 = 0xFFFF; +/// The most data a header is read with. Real packages have a few megabytes at +/// most (a kernel's file list). +const MAX_DATA: u32 = 64 << 20; +/// The most strings of one script array taken (triggers). +const MAX_STRINGS: u32 = 4096; +/// The most script members of a package. +const MAX_SCRIPTS: usize = 4096; + +const TAG_PAYLOAD_FORMAT: u32 = 1124; +const TAG_PAYLOAD_COMPRESSOR: u32 = 1125; +const STRING: u32 = 6; +const STRING_ARRAY: u32 = 8; + +/// Header tags whose value is a script, and the member each is given. +const SCRIPTS: &[(u32, &str)] = &[ + (1023, "scripts/prein"), + (1024, "scripts/postin"), + (1025, "scripts/preun"), + (1026, "scripts/postun"), + (1079, "scripts/verify"), + (1151, "scripts/pretrans"), + (1152, "scripts/posttrans"), + (5020, "scripts/preuntrans"), + (5021, "scripts/postuntrans"), + (1065, "scripts/trigger"), + (5016, "scripts/filetrigger"), + (5026, "scripts/transfiletrigger"), +]; + +struct Entry { + tag: u32, + kind: u32, + offset: u32, + count: u32, +} + +/// A header structure: its entries, its data and where it ends. +struct Header { + entries: Vec<Entry>, + data: Vec<u8>, + /// Where the data starts in the file. + data_at: u64, + end: u64, +} + +fn be32(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) +} + +fn read_at<R: Read + Seek>(r: &mut R, at: u64, n: usize) -> Result<Vec<u8>, LimitHit> { + let mut out = vec![0u8; n]; + r.seek(SeekFrom::Start(at)) + .and_then(|_| r.read_exact(&mut out)) + .map_err(|e| LimitHit::corrupt(format!("rpm: {e}")))?; + Ok(out) +} + +fn read_header<R: Read + Seek>(r: &mut R, at: u64, total: u64) -> Result<Header, LimitHit> { + let h = read_at(r, at, 16)?; + if h[..4] != HEADER_MAGIC { + return Err(LimitHit::corrupt( + "rpm: no header structure where one belongs".to_string(), + )); + } + let (count, size) = (be32(&h, 8), be32(&h, 12)); + if count > MAX_ENTRIES || size > MAX_DATA { + return Err(LimitHit::corrupt( + "rpm: header counts past the limits".to_string(), + )); + } + // Nothing is allocated for a count the file is too short to hold. + let data_at = at + 16 + u64::from(count) * 16; + if data_at + u64::from(size) > total { + return Err(LimitHit::corrupt("rpm: header cut short".to_string())); + } + let table = read_at(r, at + 16, count as usize * 16)?; + let entries = table + .as_chunks::<16>() + .0 + .iter() + .map(|e| Entry { + tag: be32(e, 0), + kind: be32(e, 4), + offset: be32(e, 8), + count: be32(e, 12), + }) + .collect(); + Ok(Header { + entries, + data: read_at(r, data_at, size as usize)?, + data_at, + end: data_at + u64::from(size), + }) +} + +impl Header { + /// The NUL-terminated string at `offset` of the data, `(offset, length)`. + fn string_at(&self, offset: usize) -> Option<(usize, usize)> { + let rest = self.data.get(offset..)?; + Some((offset, rest.iter().position(|&b| b == 0)?)) + } + + fn string_of(&self, tag: u32) -> Option<&str> { + let e = self + .entries + .iter() + .find(|e| e.tag == tag && e.kind == STRING)?; + let (at, n) = self.string_at(e.offset as usize)?; + std::str::from_utf8(&self.data[at..at + n]).ok() + } +} + +/// The name of the payload: what it is (`cpio`) and how it is compressed. +fn payload_name(h: &Header) -> String { + let format = match h.string_of(TAG_PAYLOAD_FORMAT) { + Some("cpio") | None => ".cpio", + Some(_) => "", + }; + let compressor = match h.string_of(TAG_PAYLOAD_COMPRESSOR) { + Some("gzip") => ".gz", + Some("bzip2") => ".bz2", + Some("xz") => ".xz", + Some("lzma") => ".lzma", + Some("zstd") => ".zst", + _ => "", + }; + format!("payload{format}{compressor}") +} + +/// Walk a package: its scripts, then its payload, each read from where it lies. +pub(crate) fn walk<T>( + src: &dyn crate::source::ByteSource, + budget: &mut Budget, + visit: crate::stream::Visit<T>, +) -> Result<Option<T>, LimitHit> { + let mut source = crate::source::Reader::new(src); + let members = stream_offsets(&mut source)?; + crate::stream::stream_stored(&mut source, budget, visit, members) +} + +pub(crate) fn stream_offsets<R: Read + Seek>( + source: &mut R, +) -> Result<Vec<crate::stream::Region>, LimitHit> { + use crate::stream::Region; + let total = source + .seek(SeekFrom::End(0)) + .map_err(|e| LimitHit::corrupt(format!("rpm: {e}")))?; + let lead = read_at(source, 0, LEAD as usize)?; + if u16::from_be_bytes([lead[78], lead[79]]) != HEADER_SIGNATURE { + // RPM 2's signature is a bare block that is not read here. + return Ok(vec![Region::Unwalked( + "rpm".into(), + "an RPM of the old kind, with no signature header", + )]); + } + let signature = read_header(source, LEAD, total)?; + // The package header follows the signature header, from the next multiple of 8. + let header = read_header(source, signature.end.next_multiple_of(8), total)?; + + let mut regions = Vec::new(); + 'scripts: for &(tag, name) in SCRIPTS { + for e in header.entries.iter().filter(|e| e.tag == tag) { + let strings = match e.kind { + STRING => 1, + STRING_ARRAY => e.count.min(MAX_STRINGS), + _ => continue, + }; + let mut at = e.offset as usize; + for i in 0..strings { + // Entries may share their strings: the members are bounded + // here, before anything is built for each. + if regions.len() >= MAX_SCRIPTS { + regions.push(Region::Unwalked( + "scripts".into(), + "an RPM with more scripts than exav lists", + )); + break 'scripts; + } + let Some((start, n)) = header.string_at(at) else { + break; + }; + if n > 0 { + let name = if e.kind == STRING { + name.to_string() + } else { + format!("{name}/{i}") + }; + regions.push(Region::Member( + name, + header.data_at + start as u64, + n as u64, + )); + } + at = start + n + 1; + } + } + } + if header.end < total { + regions.push(Region::Member( + payload_name(&header), + header.end, + total - header.end, + )); + } + Ok(regions) +} diff --git a/crates/exav-unpack/src/formats/rtf.rs b/crates/exav-unpack/src/formats/rtf.rs index 76a1761b..85a5e829 100644 --- a/crates/exav-unpack/src/formats/rtf.rs +++ b/crates/exav-unpack/src/formats/rtf.rs @@ -1,99 +1,202 @@ -//! RTF embedded-object extractor. +//! RTF: the tokenizer of the format, and the extractor of the objects it +//! embeds. //! //! Implemented from the public **Microsoft Rich Text Format (RTF) Specification** //! (v1.9.1). An RTF file is a tree of brace-delimited groups containing control //! words (`\word` + optional signed number + one optional delimiting space) and -//! control symbols (`\` + a single non-letter). An embedded OLE object is carried -//! as ASCII hex inside an `\objdata` destination (RTF §"Objects"); malware hides -//! droppers there (an OLE2/VBA document or a raw PE). We collect the hex bytes of -//! every `\objdata`/`\datastore` destination and emit the decoded object so the +//! control symbols (`\` + a single non-letter). The [`Lexer`] reads it, for the +//! scanner and for exav-render's converter alike: what one sees as a control +//! word, a group or text, so does the other. +//! +//! An embedded OLE object is carried as ASCII hex inside an `\objdata` +//! destination (RTF §"Objects"); malware hides droppers there (an OLE2/VBA +//! document or a raw PE). The extractor collects the hex bytes of every +//! `\objdata`/`\datastore` destination and emits the decoded object so the //! engine can recurse into it. //! //! Hex digits inside the destination may be split across lines and interleaved -//! with control words; we decode only `[0-9a-fA-F]`, skip control sequences (so -//! their letters aren't misread as hex), and stop at the `}` closing the group. -//! Every read is bounds-checked; truncated/hostile RTF cannot panic (an -//! unterminated group runs to EOF, an odd trailing nibble is dropped). +//! with control words; only `[0-9a-fA-F]` of the text between them is decoded +//! (so the letters of a control word are not misread as hex), up to the `}` +//! that closes the destination. Every read is bounds-checked; truncated or +//! hostile RTF cannot panic (an unterminated group runs to EOF, an odd +//! trailing nibble is dropped). use crate::*; -/// Numeric value of an ASCII hex digit, else `None`. -fn hex_nibble(b: u8) -> Option<u8> { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - b'A'..=b'F' => Some(b - b'A' + 10), - _ => None, - } +/// One token of an RTF file. +#[derive(Debug, PartialEq, Eq)] +pub enum Tok<'a> { + Open, + Close, + /// A control word and its parameter. + Word(&'a str, Option<i32>), + /// `\'hh`. + Hex(u8), + /// A control symbol other than `\'`: `\~`, `\-`, `\_`, `\*`, `\\`, ... + Sym(u8), + /// Plain bytes. + Text(&'a [u8]), +} + +/// The tokens of an RTF file, one at a time. Line breaks between tokens are +/// not tokens; a backslash before one is a paragraph end. +pub struct Lexer<'a> { + pub d: &'a [u8], + pub p: usize, } -/// Consume the control word/symbol at `at` (a `\`). Returns its letter name -/// (empty for a control symbol) and the offset just past the token — including a -/// signed numeric parameter and one trailing delimiting space. Always advances. -fn control_token(data: &[u8], at: usize) -> (&[u8], usize) { - let n = data.len(); - let mut i = at + 1; - if i >= n { - return (&[], n); +impl<'a> Lexer<'a> { + pub fn new(d: &'a [u8]) -> Self { + Lexer { d, p: 0 } } - if !data[i].is_ascii_alphabetic() { - return (&data[i..i], i + 1); // control symbol: backslash + one char + + pub fn next_tok(&mut self) -> Option<Tok<'a>> { + loop { + let c = *self.d.get(self.p)?; + match c { + b'{' => { + self.p += 1; + return Some(Tok::Open); + } + b'}' => { + self.p += 1; + return Some(Tok::Close); + } + b'\r' | b'\n' => { + self.p += 1; + continue; + } + b'\\' => return Some(self.control()), + _ => { + let s = self.p; + while let Some(&c) = self.d.get(self.p) { + if matches!(c, b'{' | b'}' | b'\\' | b'\r' | b'\n') { + break; + } + self.p += 1; + } + return Some(Tok::Text(&self.d[s..self.p])); + } + } + } } - let name_start = i; - while i < n && data[i].is_ascii_alphabetic() { - i += 1; + + fn control(&mut self) -> Tok<'a> { + self.p += 1; + let Some(&c) = self.d.get(self.p) else { + return Tok::Text(&[]); + }; + if c.is_ascii_alphabetic() { + let s = self.p; + while self.d.get(self.p).is_some_and(|c| c.is_ascii_alphabetic()) && self.p - s < 32 { + self.p += 1; + } + let name = std::str::from_utf8(&self.d[s..self.p]).unwrap_or(""); + let mut param = None; + let neg = self.d.get(self.p) == Some(&b'-'); + let ds = if neg { self.p + 1 } else { self.p }; + let mut e = ds; + while self.d.get(e).is_some_and(|c| c.is_ascii_digit()) && e - ds < 11 { + e += 1; + } + if e > ds { + let v: i64 = std::str::from_utf8(&self.d[ds..e]) + .ok() + .and_then(|t| t.parse().ok()) + .unwrap_or(0); + let v = if neg { -v } else { v }; + // Parameters are small in any real document; the bound keeps + // the arithmetic on them from overflowing. + param = Some(v.clamp(-100_000_000, 100_000_000) as i32); + self.p = e; + } + // One space after a control word belongs to it. + if self.d.get(self.p) == Some(&b' ') { + self.p += 1; + } + return Tok::Word(name, param); + } + self.p += 1; + if c == b'\'' { + let h = |b: Option<&u8>| b.and_then(|b| (*b as char).to_digit(16)); + let hi = h(self.d.get(self.p)); + let lo = h(self.d.get(self.p + 1)); + return match (hi, lo) { + (Some(a), Some(b)) => { + self.p += 2; + Tok::Hex((a * 16 + b) as u8) + } + _ => Tok::Hex(0), + }; + } + if c == b'\r' || c == b'\n' { + // A backslash before a line break is a paragraph end. + return Tok::Word("par", None); + } + Tok::Sym(c) } - let name = &data[name_start..i]; - if i < n && data[i] == b'-' { - i += 1; + + /// Skips the bytes standing for the `n` characters after `\uN`. + pub fn skip_fallback(&mut self, n: u32) { + for _ in 0..n { + match self.d.get(self.p) { + Some(b'\\') if self.d.get(self.p + 1) == Some(&b'\'') => self.p += 4, + Some(b'\\' | b'{' | b'}' | b'\r' | b'\n') | None => break, + Some(_) => self.p += 1, + } + } + self.p = self.p.min(self.d.len()); } - while i < n && data[i].is_ascii_digit() { - i += 1; + + /// The `n` raw bytes after `\binN`. + pub fn take_bin(&mut self, n: usize) -> &'a [u8] { + let e = self.p.saturating_add(n).min(self.d.len()); + let s = &self.d[self.p..e]; + self.p = e; + s } - if i < n && data[i] == b' ' { - i += 1; // the single delimiting space belongs to the control word +} + +/// Numeric value of an ASCII hex digit, else `None`. +fn hex_nibble(b: u8) -> Option<u8> { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, } - (name, i) } -/// Decode the hex payload of a destination whose data begins at `from`, tracking -/// group depth (starting at 1) and stopping at the closing `}`. Returns the -/// decoded bytes and the offset just past that `}` (or EOF). -fn decode_object(data: &[u8], from: usize) -> (Vec<u8>, usize) { - let n = data.len(); - let mut i = from; +/// Decode the hex payload of the destination whose data begins where `lex` +/// stands, up to the `}` that closes it. Returns the decoded bytes and the +/// offset of that `}` (or EOF). +fn decode_object(lex: &mut Lexer<'_>) -> (Vec<u8>, usize) { let mut depth = 1i32; let mut hi: Option<u8> = None; let mut out = Vec::new(); - while i < n { - match data[i] { - b'{' => { - depth += 1; - i += 1; - } - b'}' => { + loop { + let at = lex.p; + match lex.next_tok() { + None => return (out, lex.d.len()), + Some(Tok::Open) => depth += 1, + Some(Tok::Close) => { depth -= 1; - i += 1; if depth == 0 { - break; + return (out, at); } } - b'\\' => { - let (_, next) = control_token(data, i); - i = next; - } - other => { - if let Some(v) = hex_nibble(other) { + Some(Tok::Text(text)) => { + for v in text.iter().filter_map(|&b| hex_nibble(b)) { match hi.take() { None => hi = Some(v), Some(h) => out.push((h << 4) | v), } } - i += 1; } + // Control words and symbols hold no digits of the object. + Some(_) => {} } } - (out, i) } pub(crate) fn extract_rtf<R>( @@ -104,71 +207,49 @@ pub(crate) fn extract_rtf<R>( if !data.starts_with(b"{\\rtf") { return Ok(None); } - let n = data.len(); - let mut i = 0usize; + let mut lex = Lexer::new(data); let mut index = 0usize; - while i < n { - if data[i] != b'\\' { - i += 1; + while let Some(tok) = lex.next_tok() { + // `\binN` is not honored: its bytes are read as the groups and words + // they could be, so an object in them is found whichever way a reader + // takes them. + if !matches!(tok, Tok::Word("objdata" | "datastore", _)) { continue; } - let (name, after) = control_token(data, i); - if name == b"objdata" || name == b"datastore" { - let (bytes, next) = decode_object(data, after); - // `next` sits just past the `}` that closed the destination, so drop - // that byte: the artifact should be the destination's content and - // nothing else. - let hex_end = match next.checked_sub(1) { - Some(e) if e >= after && data.get(e) == Some(&b'}') => e, - _ => next, - }; - let hex = data.get(after..hex_end).unwrap_or(&[]); - i = next; - if bytes.is_empty() { - continue; - } - // Hex digits are case-insensitive by definition — `4D` and `4d` name - // the same byte — so a signature written against this destination's - // hex TEXT should match whichever case the writer chose. Emitting a - // lowercased copy makes that true for exactly the region where the - // format says case carries no meaning. - // - // The alternative in place today is a blunt instrument: exav - // lowercases any pure-ASCII file it types as text, which turns EVERY - // case-sensitive signature case-insensitive on a whole class of files - // to fix this one case, and flips behaviour on a single NUL byte. - // This rule can be stated in one sentence and defended; that one - // cannot. - // - // Only when it would differ: an already-lowercase destination is - // identical to bytes the raw scan has covered, so copying it would - // cost memory for nothing. - index += 1; - if hex.iter().any(|b| b.is_ascii_uppercase()) { - budget.count_entry()?; - let cap = budget.reserve()?; - if hex.len() as u64 <= cap { - budget.commit(hex.len() as u64); - let folded = hex.to_ascii_lowercase(); - if let Some(r) = visit( - Entry::new(format!("rtf-objdata-hex-{index}"), folded), - budget, - ) { - return Ok(Some(r)); - } - } - } + let start = lex.p; + let (bytes, end) = decode_object(&mut lex); + let hex = data.get(start..end).unwrap_or(&[]); + if bytes.is_empty() { + continue; + } + // Hex digits are case-insensitive by definition, so a signature + // written against this destination's hex TEXT should match whichever + // case the writer chose: an uppercase destination is also offered + // lowercased. Only then: a lowercase one is identical to bytes the + // raw scan has covered, so copying it would cost memory for nothing. + index += 1; + if hex.iter().any(|b| b.is_ascii_uppercase()) { budget.count_entry()?; let cap = budget.reserve()?; - if bytes.len() as u64 > cap { - return Err(LimitHit::new("rtf object exceeds budget".to_string())); - } - budget.commit(bytes.len() as u64); - if let Some(r) = visit(Entry::new(format!("rtf-object-{index}"), bytes), budget) { - return Ok(Some(r)); + if hex.len() as u64 <= cap { + budget.commit(hex.len() as u64); + let folded = hex.to_ascii_lowercase(); + if let Some(r) = visit( + Entry::new(format!("rtf-objdata-hex-{index}"), folded), + budget, + ) { + return Ok(Some(r)); + } } - } else { - i = after; + } + budget.count_entry()?; + let cap = budget.reserve()?; + if bytes.len() as u64 > cap { + return Err(LimitHit::new("rtf object exceeds budget".to_string())); + } + budget.commit(bytes.len() as u64); + if let Some(r) = visit(Entry::new(format!("rtf-object-{index}"), bytes), budget) { + return Ok(Some(r)); } } Ok(None) @@ -182,6 +263,50 @@ mod tests { bytes.iter().map(|b| format!("{b:02x}")).collect() } + fn all(s: &[u8]) -> Vec<Tok<'_>> { + let mut l = Lexer::new(s); + let mut v = vec![]; + while let Some(t) = l.next_tok() { + v.push(t); + } + v + } + + #[test] + fn words_parameters_and_text() { + let t = all(b"{\\rtf1\\ansi\\fs-24 Hi\\'e9\\~x\\u-3913 ?}"); + assert_eq!( + t, + vec![ + Tok::Open, + Tok::Word("rtf", Some(1)), + Tok::Word("ansi", None), + Tok::Word("fs", Some(-24)), + Tok::Text(b"Hi"), + Tok::Hex(0xE9), + Tok::Sym(b'~'), + Tok::Text(b"x"), + Tok::Word("u", Some(-3913)), + Tok::Text(b"?"), + Tok::Close, + ] + ); + } + + #[test] + fn line_breaks_are_ignored_and_a_slash_before_one_is_a_paragraph() { + assert_eq!(all(b"a\r\nb"), vec![Tok::Text(b"a"), Tok::Text(b"b")]); + assert_eq!(all(b"\\\nx"), vec![Tok::Word("par", None), Tok::Text(b"x")]); + } + + #[test] + fn fallback_characters_are_skipped() { + let mut l = Lexer::new(b"\\u233\\'e9z"); + assert_eq!(l.next_tok(), Some(Tok::Word("u", Some(233)))); + l.skip_fallback(1); + assert_eq!(l.next_tok(), Some(Tok::Text(b"z"))); + } + #[test] fn extracts_single_object() { let payload = b"MALWARETEST-inside-rtf-object"; @@ -241,9 +366,11 @@ mod tests { let rtf = format!("{{\\rtf1{{\\object{{\\objdata {}}}}}}}", hex(payload)); let mut budget = Budget::new(Limits::default()); let entries = extract(Format::Rtf, &rtf.as_bytes(), &mut budget).unwrap(); - assert!(entries - .iter() - .all(|e| !e.name.starts_with("rtf-objdata-hex"))); + assert!( + entries + .iter() + .all(|e| !e.name.starts_with("rtf-objdata-hex")) + ); } #[test] @@ -285,8 +412,26 @@ mod tests { .unwrap() .is_empty() ); - assert!(extract(Format::Rtf, b"not rtf", &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Rtf, b"not rtf", &mut budget) + .unwrap() + .is_empty() + ); + } + + /// The destination ends at its own closing brace, whatever groups open + /// inside it, and an object a `\bin` run seems to hide is still found. + #[test] + fn nested_groups_and_bin_runs() { + let payload = b"NESTED-MALWARETEST"; + let h = hex(payload); + let (a, b) = h.split_at(8); + let rtf = format!("{{\\rtf1{{\\objdata {a}{{\\*\\x}}{b}}}more}}"); + let mut budget = Budget::new(Limits::default()); + let entries = extract(Format::Rtf, &rtf.as_bytes(), &mut budget).unwrap(); + assert_eq!(entries[0].data, payload); + let rtf = format!("{{\\rtf1\\bin4 abcd{{\\objdata {h}}}}}"); + let entries = extract(Format::Rtf, &rtf.as_bytes(), &mut budget).unwrap(); + assert!(entries.iter().any(|e| e.data == payload)); } } diff --git a/crates/exav-unpack/src/formats/sevenz/aes.rs b/crates/exav-unpack/src/formats/sevenz/aes.rs index 08a99af1..e9432b4e 100644 --- a/crates/exav-unpack/src/formats/sevenz/aes.rs +++ b/crates/exav-unpack/src/formats/sevenz/aes.rs @@ -16,7 +16,8 @@ //! the downstream codec rejects (never a panic, never emitted as data). use crate::LimitHit; -use cbc::cipher::{block_padding::NoPadding, BlockModeDecrypt, KeyIvInit}; +use crate::kdf::Pass; +use cbc::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}; use sha2::{Digest, Sha256}; use std::io::{self, Cursor, Read}; @@ -123,30 +124,41 @@ fn utf16le(password: &str) -> Vec<u8> { /// ciphertext on first read (folder streams are bounded by the archive/budget), /// AES-256-CBC-decrypts it with no padding (7z streams are block-aligned; the /// exact unpacked length is enforced by the caller), and serves the plaintext. -pub(super) struct Aes7zReader { - inner: Option<Box<dyn Read>>, +pub(super) struct Aes7zReader<'a> { + inner: Option<Box<dyn Read + 'a>>, key: [u8; 32], iv: [u8; 16], plain: Cursor<Vec<u8>>, } -impl Aes7zReader { +impl<'a> Aes7zReader<'a> { /// Build the decryptor for a 7zAES coder. `password` is the passphrase to /// try. Returns an error if the properties are malformed, no password was /// supplied, or the KDF cost is out of range; the caller maps that to the /// existing "encrypted / unsupported" outcome. pub(super) fn new( - inner: Box<dyn Read>, + inner: Box<dyn Read + 'a>, properties: &[u8], - password: Option<&str>, + password: Option<Pass<'_>>, ) -> Result<Self, LimitHit> { - let Some(password) = password else { + let Some(pass) = password else { return Err(LimitHit::corrupt("7z: AES member needs a password".into())); }; let props = parse_props(properties).ok_or_else(|| LimitHit::corrupt("7z: bad AES props".into()))?; - let key = derive_key(&utf16le(password), &props.salt, props.num_cycles_power) - .ok_or_else(|| LimitHit::corrupt("7z: AES KDF cost too high".into()))?; + let word = utf16le(pass.word); + // The key is made once per password, salt and count in a scan, and the + // scan has a bound on the rounds it spends ([`crate::kdf`]). + let key = match props.num_cycles_power { + NO_HASH_CYCLES => derive_key(&word, &props.salt, NO_HASH_CYCLES), + cycles if cycles > MAX_CYCLES_POWER => None, + cycles => pass + .kdf + .sevenz_key(&word, &props.salt, cycles, 1u64 << cycles, || { + derive_key(&word, &props.salt, cycles) + }), + } + .ok_or_else(|| LimitHit::corrupt("7z: AES KDF cost too high".into()))?; Ok(Aes7zReader { inner: Some(inner), key, @@ -173,7 +185,7 @@ impl Aes7zReader { } } -impl Read for Aes7zReader { +impl Read for Aes7zReader<'_> { fn read(&mut self, out: &mut [u8]) -> io::Result<usize> { if self.inner.is_some() { self.fill()?; @@ -223,8 +235,12 @@ mod tests { let plain = b"CreateObject(\"WScript.Shell\") EICAR body padded out to blocks!!".to_vec(); let ct = encrypt(&key, &parsed.iv, plain.clone()); - let mut reader = - Aes7zReader::new(Box::new(Cursor::new(ct)), &props, Some("s3cr3t")).unwrap(); + let kdf = crate::kdf::Kdf::default(); + let pass = Pass { + word: "s3cr3t", + kdf: &kdf, + }; + let mut reader = Aes7zReader::new(Box::new(Cursor::new(ct)), &props, Some(pass)).unwrap(); let mut got = Vec::new(); reader.read_to_end(&mut got).unwrap(); // Decryption yields the block-padded plaintext; the real caller truncates @@ -242,8 +258,12 @@ mod tests { let key = derive_key(&utf16le("right"), &parsed.salt, parsed.num_cycles_power).unwrap(); let plain = vec![0x41u8; 32]; let ct = encrypt(&key, &parsed.iv, plain.clone()); - let mut reader = - Aes7zReader::new(Box::new(Cursor::new(ct)), &props, Some("wrong")).unwrap(); + let kdf = crate::kdf::Kdf::default(); + let pass = Pass { + word: "wrong", + kdf: &kdf, + }; + let mut reader = Aes7zReader::new(Box::new(Cursor::new(ct)), &props, Some(pass)).unwrap(); let mut got = Vec::new(); reader.read_to_end(&mut got).unwrap(); assert_ne!(got, plain, "wrong password must not recover plaintext"); @@ -251,14 +271,56 @@ mod tests { #[test] fn dos_guard_and_missing_password() { + let kdf = crate::kdf::Kdf::default(); + let pass = Pass { + word: "x", + kdf: &kdf, + }; // Cycle power above the cap is rejected. let hot = build_props(40, b"s", &[0u8; 16]); - assert!(Aes7zReader::new(Box::new(Cursor::new(vec![])), &hot, Some("x")).is_err()); + assert!(Aes7zReader::new(Box::new(Cursor::new(vec![])), &hot, Some(pass)).is_err()); // Missing password is rejected. let ok = build_props(16, b"s", &[0u8; 16]); assert!(Aes7zReader::new(Box::new(Cursor::new(vec![])), &ok, None).is_err()); // Malformed props are rejected. - assert!(Aes7zReader::new(Box::new(Cursor::new(vec![])), &[], Some("x")).is_err()); + assert!(Aes7zReader::new(Box::new(Cursor::new(vec![])), &[], Some(pass)).is_err()); + } + + /// Thousands of folders of one archive, which 7-Zip gives the same key, cost + /// one derivation; a scan that asks for more than its share is refused + /// the rest, whatever the passwords. + #[test] + fn a_scan_derives_a_key_once_and_within_its_share() { + let kdf = crate::kdf::Kdf::default(); + let props = build_props(16, b"s", &[9u8; 16]); + let start = std::time::Instant::now(); + for _ in 0..2000 { + let pass = Pass { + word: "same", + kdf: &kdf, + }; + assert!(Aes7zReader::new(Box::new(Cursor::new(vec![])), &props, Some(pass)).is_ok()); + } + // 2000 derivations of 2^16 would be 1.3 s here; one is a few ms. + assert!(start.elapsed() < std::time::Duration::from_millis(500)); + assert_eq!(kdf.sevenz_spent(), 1 << 16); + // Many passwords at 2^24: 4 are derived, then the scan has no share left. + let hot = build_props(24, b"s", &[9u8; 16]); + let mut derived = 0; + for n in 0..8 { + let word = format!("password {n}"); + let pass = Pass { + word: &word, + kdf: &kdf, + }; + if Aes7zReader::new(Box::new(Cursor::new(vec![])), &hot, Some(pass)).is_ok() { + derived += 1; + } + } + assert_eq!( + derived, 3, + "2^26 less the 2^16 already spent is three of 2^24" + ); } /// Build a 7zAES property blob with an explicit salt and IV (both present). diff --git a/crates/exav-unpack/src/formats/sevenz/decode.rs b/crates/exav-unpack/src/formats/sevenz/decode.rs index b742fcf8..4694cd8e 100644 --- a/crates/exav-unpack/src/formats/sevenz/decode.rs +++ b/crates/exav-unpack/src/formats/sevenz/decode.rs @@ -3,8 +3,8 @@ use super::header::Coder; use super::parse::*; -use crate::formats::lzma::SansIo; use crate::LimitHit; +use crate::formats::lzma::SansIo; use lzma_rust2::filter::bcj::BcjReader; use std::io::{self, Read}; @@ -27,13 +27,13 @@ pub(super) fn is_known_codec(id: &[u8]) -> bool { /// `password` is the passphrase to try for an AES-encrypted coder (ignored by /// every other codec); `None` (or a build without the `decrypt` feature) leaves /// AES reported as unsupported. -pub(super) fn wrap_coder( - inner: Box<dyn Read>, +pub(super) fn wrap_coder<'a>( + inner: Box<dyn Read + 'a>, coder: &Coder, expected_size: usize, - password: Option<&str>, + password: Option<crate::kdf::Pass<'_>>, max_buffer: u64, -) -> Result<Box<dyn Read>, LimitHit> { +) -> Result<Box<dyn Read + 'a>, LimitHit> { let _ = password; match coder.method_id.as_slice() { x if x == ID_COPY => Ok(inner), diff --git a/crates/exav-unpack/src/formats/sevenz/entry.rs b/crates/exav-unpack/src/formats/sevenz/entry.rs index 80e22e86..ba687bae 100644 --- a/crates/exav-unpack/src/formats/sevenz/entry.rs +++ b/crates/exav-unpack/src/formats/sevenz/entry.rs @@ -1,6 +1,6 @@ //! Public entry point for 7z extraction. -use super::header::{ordered_coder_iter, parse_archive}; +use super::header::ordered_coder_iter; use super::parse::SIGNATURE_HEADER_SIZE; use crate::*; use std::io::{Cursor, Read}; @@ -35,14 +35,14 @@ fn has_unsupported_codec(block: &super::header::Block, has_password: bool) -> Op /// data, without decoding it. The streaming path reads this incrementally (skip /// to a file's offset, then `take(size)`) so the whole decompressed solid block /// is never buffered. `password` is threaded to an AES coder (if any). -fn decode_block_reader( +pub(super) fn decode_block_reader<'a>( block: &super::header::Block, - pack_data: &[u8], + pack_data: Box<dyn Read + 'a>, expected_total: u64, - password: Option<&str>, + password: Option<crate::kdf::Pass<'_>>, max_buffer: u64, -) -> Result<Box<dyn Read>, LimitHit> { - let mut current: Box<dyn Read> = Box::new(Cursor::new(pack_data.to_vec())); +) -> Result<Box<dyn Read + 'a>, LimitHit> { + let mut current = pack_data; for coder_idx in ordered_coder_iter(block) { let coder = &block.coders[coder_idx]; // Each coder decodes to its own declared size, the one for its first @@ -71,7 +71,7 @@ fn decode_block_reader( fn decode_block_graph( block: &super::header::Block, pack_streams: &[&[u8]], - password: Option<&str>, + password: Option<crate::kdf::Pass<'_>>, max_buffer: u64, ) -> Result<Decoded, LimitHit> { // Exclusive prefix sums: the global index of each coder's first in/out stream. @@ -101,7 +101,7 @@ fn decode_block_graph( in_base: &'a [u64], out_base: &'a [u64], producer: &'a dyn Fn(u64) -> Option<usize>, - password: Option<&'a str>, + password: Option<crate::kdf::Pass<'a>>, max_buffer: u64, } @@ -226,7 +226,7 @@ fn decode_block( pack_data: &[u8], pack_sizes: &[u64], expected_total: u64, - password: Option<&str>, + password: Option<crate::kdf::Pass<'_>>, max_buffer: u64, ) -> Result<Decoded, LimitHit> { // A folder whose coders take more inputs than there are coders cannot be a @@ -248,7 +248,13 @@ fn decode_block( } return decode_block_graph(block, &slices, password, max_buffer); } - let mut current = decode_block_reader(block, pack_data, expected_total, password, max_buffer)?; + let mut current = decode_block_reader( + block, + Box::new(Cursor::new(pack_data)), + expected_total, + password, + max_buffer, + )?; // A 7z block is a *solid* unit that may hold many members; its decompressed // size can amplify far beyond the packed input. Bound it by the global // peak-buffer limit. @@ -292,9 +298,9 @@ impl Read for Failed { /// bytes have been read. The members of a block follow each other in it, so /// the next one starts where this one left off: starting the decoder over for /// each is a pass over the block per member. -struct Solid { +struct Solid<'a> { block: usize, - reader: Box<dyn Read>, + reader: Box<dyn Read + 'a>, pos: u64, } @@ -340,15 +346,16 @@ fn skip_reader(r: &mut dyn Read, n: u64) -> (u64, bool) { (skipped, false) } -/// The packed bytes of folder `block_idx`, and the size of each of its packed -/// streams. A folder can have several (BCJ2 folders have four), laid end to -/// end; the sizes let the graph decoder split them apart again. `Err` says -/// why the header puts them out of reach. -fn block_pack_data<'a>( +/// Where the packed bytes of folder `block_idx` are in the archive, as +/// `[start, end)`, and the size of each of its packed streams. A folder can +/// have several (BCJ2 folders have four), laid end to end; the sizes let the +/// graph decoder split them apart again. `Err` says why the header puts them +/// out of reach. +fn block_pack_data( archive: &super::header::Archive, block_idx: usize, - data: &'a [u8], -) -> Result<(&'a [u8], Vec<u64>), &'static str> { + len: usize, +) -> Result<((usize, usize), Vec<u64>), &'static str> { const MISSING: &str = "7z: folder names a packed stream the header does not list"; const PAST_END: &str = "7z: packed data runs past the end of the archive"; let block = &archive.blocks[block_idx]; @@ -376,37 +383,62 @@ fn block_pack_data<'a>( .ok() .zip(usize::try_from(end).ok()) .ok_or(PAST_END)?; - data.get(range.0..range.1) - .map(|packed| (packed, sizes)) - .ok_or(PAST_END) + (range.1 <= len).then_some((range, sizes)).ok_or(PAST_END) } -/// Walk a 7z archive. Its header is at the end, so the container is read whole; -/// each member's decompressed output then streams, which is where a solid +/// The packed bytes `range` of `src`, held, for the paths that decode a folder +/// whole: an encrypted one, and one with several packed streams. +fn pack_bytes<'a>( + src: &'a dyn crate::source::ByteSource, + range: (usize, usize), + max_buffer: u64, +) -> Result<std::borrow::Cow<'a, [u8]>, LimitHit> { + let len = range.1.saturating_sub(range.0); + if len as u64 > max_buffer { + return Err(LimitHit::new(format!( + "7z folder of {len} packed bytes is decoded whole, over the {max_buffer}-byte buffer limit" + ))); + } + let bytes = src.window(range.0, len); + if bytes.len() != len { + return Err(LimitHit::corrupt( + "7z: the packed data could not be read".into(), + )); + } + Ok(bytes) +} + +/// Walk a 7z archive. Its header is at the end and is read from there; each +/// folder's packed bytes are read as its members are, so the archive is never +/// held, and each member's decompressed output streams, which is where a solid /// block can be far larger than the archive. pub(crate) fn walk<T>( src: &dyn crate::source::ByteSource, budget: &mut Budget, visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { - let data = crate::stream::read_whole(Format::SevenZip, src, budget)?; - stream_sevenz(&data, budget, visit) + stream_sevenz(src, budget, visit) } /// Streaming 7z extraction (pattern A): each file's solid block is built as a -/// forward-only `Read`; the file is emitted by skipping to its offset and handing -/// the visitor a `take(size)` window — the decompressed block is never buffered, -/// so a huge solid archive is scanned in bounded memory. AES members keep the -/// buffered CRC/password path (rare). The 7z container itself is still parsed -/// from `data` (the header lives at the end → random access), but its members' -/// decompressed output streams. +/// forward-only `Read` over its packed bytes in the source; the file is emitted +/// by skipping to its offset and handing the visitor a `take(size)` window — the +/// decompressed block is never buffered, so a huge solid archive is scanned in +/// bounded memory. AES members and folders with several packed streams (BCJ2) +/// keep the buffered path, bounded by the buffer limit. fn stream_sevenz<T>( - data: &[u8], + src: &dyn crate::source::ByteSource, budget: &mut Budget, visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { - use crate::stream::{emit_bytes, emit_stream, MemberMeta}; - let archive = match parse_archive(data, &budget.passwords) { + use crate::stream::{MemberMeta, emit_bytes, emit_stream}; + let kdf = std::sync::Arc::clone(&budget.kdf); + let archive = match super::header::parse_archive_with( + src, + &budget.passwords, + &kdf, + budget.limits.max_buffer_bytes, + ) { Ok(a) => a, Err(e) => { let reason = e.reason.as_str(); @@ -418,7 +450,7 @@ fn stream_sevenz<T>( budget.count_entry()?; let meta = MemberMeta { name: "encrypted.7z".to_string(), - comp_size: data.len() as u64, + comp_size: src.len() as u64, size: None, encrypted: true, unsupported: Some("7z: encrypted header, unsupported codec"), @@ -489,7 +521,7 @@ fn stream_sevenz<T>( continue; } - let (pack_data, block_pack_sizes) = match block_pack_data(&archive, block_idx, data) { + let (pack_range, block_pack_sizes) = match block_pack_data(&archive, block_idx, src.len()) { Ok(v) => v, Err(reason) => { let meta = out_of_reach(name, reason); @@ -555,6 +587,7 @@ fn stream_sevenz<T>( if let Some(dec) = &state.good { file_data = member(dec); } else { + let packed = pack_bytes(src, pack_range, max_buffer)?; for (i, pw) in passwords.iter().enumerate() { if state.wrong[i] { continue; @@ -563,10 +596,13 @@ fn stream_sevenz<T>( // damage: try the next one. match decode_block( block, - pack_data, + &packed, &block_pack_sizes, block_total, - Some(pw.as_str()), + Some(crate::kdf::Pass { + word: pw.as_str(), + kdf: &kdf, + }), max_buffer, ) { Ok((dec, None)) => match member(&dec) { @@ -612,23 +648,6 @@ fn stream_sevenz<T>( continue; } - // Non-AES: stream. A file whose start is past the peak-buffer limit sits - // in a block bigger than we would decode buffered. Its bytes are not - // reachable, and a member nobody looked at is reported rather than - // dropped — the rest of the archive still scans. - if bytes_to_skip > max_buffer { - let meta = MemberMeta { - name, - comp_size: file.size, - size: Some(file.size), - unsupported: Some("7z: member starts past the peak-buffer limit"), - ..MemberMeta::default() - }; - if let Some(t) = visit(&meta, None, budget) { - return Ok(Some(t)); - } - continue; - } // A multi-input folder (BCJ2) cannot be streamed as a linear chain: its // filter needs all four streams present at once. Decode the block // buffered instead, bounded by the same peak-buffer limit. @@ -636,10 +655,11 @@ fn stream_sevenz<T>( .as_ref() .is_some_and(|s| s.block == block_idx && s.pos <= bytes_to_skip) { - let reader: Box<dyn Read> = if block.coders.iter().any(|c| c.num_in_streams > 1) { + let reader: Box<dyn Read + '_> = if block.coders.iter().any(|c| c.num_in_streams > 1) { + let packed = pack_bytes(src, pack_range, max_buffer)?; let (decoded, failed) = decode_block( block, - pack_data, + &packed, &block_pack_sizes, block_total, None, @@ -647,7 +667,12 @@ fn stream_sevenz<T>( )?; Box::new(Cursor::new(decoded).chain(Failed(failed))) } else { - decode_block_reader(block, pack_data, block_total, None, max_buffer)? + let packed = Box::new(crate::source::Reader::range( + src, + pack_range.0, + pack_range.1, + )); + decode_block_reader(block, packed, block_total, None, max_buffer)? }; solid = Some(Solid { block: block_idx, @@ -658,7 +683,12 @@ fn stream_sevenz<T>( let Some(s) = solid.as_mut() else { continue; }; - let (skipped, failed) = skip_reader(s.reader.as_mut(), bytes_to_skip - s.pos); + // The members before this one in the block are decoded and dropped, + // which is the work a scan limit is for: charged before it is done, so + // a block that declares more than the limit allows is not decoded. + let to_skip = bytes_to_skip.saturating_sub(s.pos); + budget.charge_scan(to_skip)?; + let (skipped, failed) = skip_reader(s.reader.as_mut(), to_skip); s.pos += skipped; if s.pos < bytes_to_skip { // The block ended before this file's offset: the sub-stream table diff --git a/crates/exav-unpack/src/formats/sevenz/header.rs b/crates/exav-unpack/src/formats/sevenz/header.rs index 889055fd..2c822b1c 100644 --- a/crates/exav-unpack/src/formats/sevenz/header.rs +++ b/crates/exav-unpack/src/formats/sevenz/header.rs @@ -56,7 +56,30 @@ pub(super) struct Archive { // ─── Public entry point ──────────────────────────────────────────────────── +/// [`parse_archive_with`] with a scan of its own (the one that reads only this +/// archive). +#[cfg(test)] pub(super) fn parse_archive(data: &[u8], passwords: &[String]) -> Result<Archive, LimitHit> { + parse_archive_with( + &data, + passwords, + &crate::kdf::Kdf::default(), + crate::Limits::default().max_buffer_bytes, + ) +} + +/// The archive's structure, its encrypted header opened with the first of +/// `passwords` that does it; the derivations count against `kdf`, the scan's. +/// Only the signature header, the header and the packed header are read from +/// `src`, and the header is held up to `max_buffer` bytes. +pub(super) fn parse_archive_with( + src: &dyn crate::source::ByteSource, + passwords: &[String], + kdf: &crate::kdf::Kdf, + max_buffer: u64, +) -> Result<Archive, LimitHit> { + let head = src.window(0, 32); + let data: &[u8] = &head; if data.len() < 32 { return Err(LimitHit::corrupt("7z: file too small".into())); } @@ -80,16 +103,40 @@ pub(super) fn parse_archive(data: &[u8], passwords: &[String]) -> Result<Archive .checked_add(next_header_offset) .and_then(|v| usize::try_from(v).ok()) .ok_or_else(|| LimitHit::corrupt("7z: next header offset overflow".into()))?; - let nh_end = nh_start - .checked_add(next_header_size as usize) + let nh_end = usize::try_from(next_header_size) + .ok() + .and_then(|n| nh_start.checked_add(n)) .ok_or_else(|| LimitHit::corrupt("7z: next header offset overflow".into()))?; - if nh_end > data.len() { + if nh_end > src.len() { return Err(LimitHit::corrupt( "7z: next header extends past end of file".into(), )); } + // The header is parsed from memory, whole. + if next_header_size > max_buffer { + return Err(LimitHit::new(format!( + "7z header is {next_header_size} bytes, over the {max_buffer}-byte buffer limit" + ))); + } + + // An archive of nothing is the signature header alone. + if next_header_size == 0 { + return Ok(Archive { + pack_pos: 0, + pack_sizes: Vec::new(), + blocks: Vec::new(), + files: Vec::new(), + stream_map: calculate_stream_map(&[], &[], &[]), + }); + } - let nh = &data[nh_start..nh_end]; + let nh_bytes = src.window(nh_start, nh_end.saturating_sub(nh_start)); + if nh_bytes.len() != nh_end.saturating_sub(nh_start) { + return Err(LimitHit::corrupt( + "7z: next header could not be read".into(), + )); + } + let nh: &[u8] = &nh_bytes; let mut nr = SevenZReader::new(nh); let first_nid = nr.read_nid()?; @@ -111,12 +158,12 @@ pub(super) fn parse_archive(data: &[u8], passwords: &[String]) -> Result<Archive // correct one yields bytes that parse as a valid 7z header. let mut decoded = None; for pw in passwords { - if let Ok(bytes) = decompress_encoded_header(data, &mut streams_info, Some(pw)) + let pass = crate::kdf::Pass { word: pw, kdf }; + if let Ok(bytes) = decompress_encoded_header(src, &mut streams_info, Some(pass)) + && parse_header(&bytes).is_ok() { - if parse_header(&bytes).is_ok() { - decoded = Some(bytes); - break; - } + decoded = Some(bytes); + break; } } decoded.ok_or_else(|| { @@ -125,7 +172,7 @@ pub(super) fn parse_archive(data: &[u8], passwords: &[String]) -> Result<Archive } else { // No password (or no AES): a `-mhe` header without a password // surfaces as unsupported via the AES coder, never a silent clean. - decompress_encoded_header(data, &mut streams_info, None)? + decompress_encoded_header(src, &mut streams_info, None)? } } NID_HEADER => nh.to_vec(), @@ -555,6 +602,7 @@ fn parse_files_info( let mut empty_streams = BitSet::new(num_files); let mut empty_files = BitSet::new(num_files); + let mut num_empty = 0usize; loop { let prop_type = r.read_nid()?; @@ -562,16 +610,20 @@ fn parse_files_info( break; } let _prop_size = read_var_u64(r)?; + let prop_start = r.pos; match prop_type { NID_EMPTY_STREAM => { empty_streams = BitSet::read_bits(r, num_files, BitSet::new(num_files))?; + // EmptyFile and Anti have one bit per entry that has no + // stream, not one per entry. + num_empty = (0..num_files).filter(|&i| empty_streams.get(i)).count(); } NID_EMPTY_FILE => { - empty_files = BitSet::read_bits(r, num_files, BitSet::new(num_files))?; + empty_files = BitSet::read_bits(r, num_empty, BitSet::new(num_empty))?; } NID_ANTI => { - let _ = BitSet::read_bits(r, num_files, BitSet::new(num_files))?; + let _ = BitSet::read_bits(r, num_empty, BitSet::new(num_empty))?; } NID_NAME => { let external = r.read_byte()?; @@ -631,10 +683,18 @@ fn parse_files_info( let _ = r.read_bytes(_prop_size as usize)?; } } + // A property ends where its size says, whatever was read of it. + if let Some(end) = prop_start + .checked_add(_prop_size as usize) + .filter(|&e| e <= r.data.len()) + { + r.pos = end; + } } // Post-process: determine has_stream / is_directory for each file let mut non_empty_counter = 0usize; + let mut empty_counter = 0usize; let total_unpack_sub_streams: usize = blocks.iter().map(|b| b.num_unpack_sub_streams).sum(); @@ -660,7 +720,8 @@ fn parse_files_info( non_empty_counter += 1; } else { file.has_stream = false; - file.is_directory = !empty_files.get(i); + file.is_directory = !empty_files.get(empty_counter); + empty_counter += 1; file.size = 0; } } @@ -725,9 +786,9 @@ fn calculate_stream_map(blocks: &[Block], files: &[FileEntry], pack_sizes: &[u64 // ─── Decompress encoded header ───────────────────────────────────────────── fn decompress_encoded_header( - data: &[u8], + src: &dyn crate::source::ByteSource, streams_info: &mut StreamsInfo, - password: Option<&str>, + password: Option<crate::kdf::Pass<'_>>, ) -> Result<Vec<u8>, LimitHit> { if streams_info.blocks.is_empty() { return Err(LimitHit::corrupt("7z: encoded header has no blocks".into())); @@ -759,14 +820,13 @@ fn decompress_encoded_header( .and_then(|v| v.checked_add(pack_stream_offset)) .and_then(|v| usize::try_from(v).ok()) .ok_or_else(|| LimitHit::corrupt("7z: pack stream offset overflow".into()))?; - let pack_size = streams_info.pack_sizes[pack_idx] as usize; - - let pack_end = pack_offset - .checked_add(pack_size) - .filter(|&e| e <= data.len()) + let pack_end = usize::try_from(streams_info.pack_sizes[pack_idx]) + .ok() + .and_then(|n| pack_offset.checked_add(n)) + .filter(|&e| e <= src.len()) .ok_or_else(|| LimitHit::corrupt("7z: pack stream extends past end of file".into()))?; - let pack_data = &data[pack_offset..pack_end]; + let pack_data = Box::new(crate::source::Reader::range(src, pack_offset, pack_end)); // Sum of attacker-controlled coder output sizes: saturate instead of `.sum()` // (which panics on overflow). `cap_prealloc` bounds the resulting allocation. let total_unpack: u64 = block @@ -775,27 +835,25 @@ fn decompress_encoded_header( .copied() .fold(0u64, u64::saturating_add); - // Build decoder chain and decompress - // We must own the data so Box<dyn Read> can be 'static - let owned_pack_data = pack_data.to_vec(); - let reader = std::io::Cursor::new(owned_pack_data); - let mut current: Box<dyn std::io::Read> = Box::new(reader); - // The 7z parser threads no `Budget`, so the encoded header/TOC is bounded by // the *default* global peak-buffer limit. A header is metadata (not the // operator's tuning surface), and it must be buffered whole for random-access // parsing — so a fixed default ceiling is the right bound here. let max_buffer = crate::Limits::default().max_buffer_bytes; - - let chain = ordered_coder_iter(block); - for coder_idx in chain { - let coder = &block.coders[coder_idx]; - // Header decompression: `password` is threaded so AES-encrypted headers - // (`-mhe=on`) can be decrypted when a candidate password is supplied. - current = - super::decode::wrap_coder(current, coder, total_unpack as usize, password, max_buffer)?; + // Its decoder may hold what it is given, so what it is given is bounded + // too, not by the archive's size. + if (pack_end.saturating_sub(pack_offset)) as u64 > max_buffer { + return Err(LimitHit::new("7z encoded header exceeds max-buffer".into())); } + // Build the decoder chain as a block's is built: each coder decodes to its + // own declared size (an AES coder's output is its padded input, an LZMA + // coder behind it is the header's own length), not to their sum. `password` + // is threaded so AES-encrypted headers (`-mhe=on`) can be decrypted when a + // candidate password is supplied. + let mut current = + super::entry::decode_block_reader(block, pack_data, total_unpack, password, max_buffer)?; + let (buf, truncated) = crate::bounded_read(&mut current, max_buffer) .map_err(|e| LimitHit::corrupt(format!("7z: decompress header: {e}")))?; if truncated { @@ -830,9 +888,13 @@ fn parse_header(data: &[u8]) -> Result<Archive, LimitHit> { streams = Some(parse_streams_info(&mut r)?); } NID_FILES_INFO => { - let s = streams.as_ref().ok_or_else(|| { - LimitHit::corrupt("7z: NID_FILES_INFO before NID_MAIN_STREAMS_INFO".into()) - })?; + // An archive of empty files and directories has no streams. + let s = streams.get_or_insert_with(|| StreamsInfo { + pack_pos: 0, + pack_sizes: Vec::new(), + blocks: Vec::new(), + sub_streams_info: None, + }); files = Some(parse_files_info(&mut r, &s.blocks, &s.sub_streams_info)?); } _ => { @@ -941,6 +1003,52 @@ pub(super) fn ordered_coder_iter(block: &Block) -> Vec<usize> { mod tests { use super::*; + /// The signature header and the header it points to. + fn archive_of(header: &[u8]) -> Vec<u8> { + let mut d = vec![b'7', b'z', 0xBC, 0xAF, 0x27, 0x1C, 0, 4, 0, 0, 0, 0]; + d.extend_from_slice(&0u64.to_le_bytes()); + d.extend_from_slice(&(header.len() as u64).to_le_bytes()); + d.extend_from_slice(&[0; 4]); + d.extend_from_slice(header); + d + } + + #[test] + fn an_archive_of_nothing_and_one_of_empty_files_have_no_streams() { + // The signature header alone. + let a = parse_archive(&archive_of(&[]), &[]).unwrap(); + assert!(a.files.is_empty()); + // Files with no streams info before them: one empty file, "a". + let header = [ + 0x01, 0x05, 0x01, 0x0E, 0x01, 0x80, 0x0F, 0x01, 0x80, 0x11, 0x05, 0x00, b'a', 0, 0, 0, + 0x00, 0x00, + ]; + let a = parse_archive(&archive_of(&header), &[]).unwrap(); + assert_eq!(a.files.len(), 1); + assert_eq!(a.files[0].name, "a"); + assert!(!a.files[0].has_stream && !a.files[0].is_directory); + } + + #[test] + fn archive_properties_with_ids_of_more_than_a_byte_are_skipped_by_their_size() { + // The empty-file header above, with three archive properties in front + // of its files: ids of 9 bytes, 2 bytes and 1 byte, as 7-Zip reads them. + let header = [ + 0x01, // header + 0x02, // archive properties + 0xFF, 1, 0, 0x9A, 0x78, 0x56, 0x34, 0x12, 0x3F, 0x03, 0xAA, 0xBB, + 0xCC, // id, size 3 + 0x80, 0x90, 0x01, 0xEE, // id 0x90, size 1 + 0x07, 0x00, // id 7, size 0 + 0x00, // end of properties + 0x05, 0x01, 0x0E, 0x01, 0x80, 0x0F, 0x01, 0x80, 0x11, 0x05, 0x00, b'a', 0, 0, 0, 0x00, + 0x00, + ]; + let a = parse_archive(&archive_of(&header), &[]).unwrap(); + assert_eq!(a.files.len(), 1); + assert_eq!(a.files[0].name, "a"); + } + #[test] fn varint_basic() { let data = [0x00]; diff --git a/crates/exav-unpack/src/formats/sevenz/mod.rs b/crates/exav-unpack/src/formats/sevenz/mod.rs index 73980a59..c4d40f94 100644 --- a/crates/exav-unpack/src/formats/sevenz/mod.rs +++ b/crates/exav-unpack/src/formats/sevenz/mod.rs @@ -20,7 +20,7 @@ pub(crate) use entry::walk; #[cfg(test)] mod tests { - use crate::{detect, extract, Budget, Format, Limits}; + use crate::{Budget, Format, Limits, detect, extract}; use std::collections::HashMap; use std::fs; @@ -213,9 +213,10 @@ mod tests { let data = fixture("aes_stored.7z"); // Correct password → recovered. let ok = extract_entries_pw(&data, &["hunter2"]); - assert!(ok - .values() - .any(|v| v.windows(16).any(|w| w == b"EICAR-7Z-AES-MAR"))); + assert!( + ok.values() + .any(|v| v.windows(16).any(|w| w == b"EICAR-7Z-AES-MAR")) + ); // Wrong password → no plaintext member; reported unsupported instead. let mut budget = Budget::with_passwords(Limits::default(), vec!["wrongpw".into()]); let entries = extract(Format::SevenZip, &data, &mut budget).expect("no error"); @@ -225,9 +226,11 @@ mod tests { .all(|e| e.unsupported.is_some() || !e.data.windows(5).any(|w| w == b"EICAR")), "wrong password must not surface plaintext" ); - assert!(entries - .iter() - .any(|e| e.encrypted && e.unsupported.is_some())); + assert!( + entries + .iter() + .any(|e| e.encrypted && e.unsupported.is_some()) + ); } /// A solid multi-file AES block decrypts all members with one password. @@ -266,15 +269,38 @@ mod tests { } } + /// 40 folders (7-Zip gives the folders of an archive one key), each with a + /// CRC that no password satisfies, tried with five passwords at 2^19 rounds: + /// the keys are derived once each, not once for each folder (200 times, 2 s + /// in a release build; and 2000 folders took 20 s, 30 at 2^24 took 9 s). Every + /// member is still reported encrypted. + #[cfg(feature = "decrypt")] + #[test] + fn a_scan_derives_the_key_of_a_password_once_for_all_the_folders() { + let data = fixture("aes_40_folders_2p19.7z"); + let words = ["a", "b", "c", "d", "e"].map(String::from).to_vec(); + let mut budget = Budget::with_passwords(Limits::default(), words); + let entries = extract(Format::SevenZip, &data, &mut budget).unwrap(); + assert_eq!(entries.len(), 40); + assert!( + entries + .iter() + .all(|e| e.encrypted && e.unsupported.is_some()) + ); + assert_eq!(budget.kdf.sevenz_spent(), 5 << 19); + } + /// Without a password an AES archive is still reported encrypted/unsupported. #[test] fn aes_without_password_unsupported() { let data = fixture("aes_lzma2.7z"); let mut budget = Budget::new(Limits::default()); let entries = extract(Format::SevenZip, &data, &mut budget).expect("no error"); - assert!(entries - .iter() - .any(|e| e.encrypted && e.unsupported.is_some())); + assert!( + entries + .iter() + .any(|e| e.encrypted && e.unsupported.is_some()) + ); } /// Fuzz regressions: crafted 7z headers with absurd stream/file counts must @@ -387,4 +413,185 @@ mod tests { assert!(entries[0].data == payload, "{filter:?} altered the member"); } } + + /// Every member `walk` hands over, a streamed one read to its end, and how + /// the walk ended. + type Walked = (Vec<(String, Vec<u8>, Option<&'static str>)>, bool); + + fn walked_from( + src: &dyn crate::source::ByteSource, + mut budget: Budget, + ) -> (Walked, Result<(), crate::LimitHit>) { + use crate::Member; + let mut out = Vec::new(); + let end = crate::walk( + Format::SevenZip, + src, + &mut budget, + &mut |meta, content, _| { + let data = match content { + None => Vec::new(), + Some(Member::Bytes(b)) => b, + Some(Member::Stream(r)) => { + let mut b = Vec::new(); + let _ = r.read_to_end(&mut b); + b + } + }; + out.push((meta.name.clone(), data, meta.unsupported)); + None::<()> + }, + ); + ((out, end.is_ok()), end.map(|_| ())) + } + + fn cache_of(blob: &[u8]) -> crate::source::BlockCache<std::io::Cursor<Vec<u8>>> { + // Blocks and a capacity far under the archive, so it is never in memory. + crate::source::BlockCache::with_sizes(std::io::Cursor::new(blob.to_vec()), 64, 4 * 64) + .unwrap() + } + + /// An archive on a source that is not in memory gives the members it gives + /// when it is, under a buffer limit far under the archive. + #[test] + fn an_archive_on_a_source_that_is_not_in_memory_gives_the_same_members() { + for name in [ + "lzma2.7z", + "lzma.7z", + "bzip2.7z", + "deflate.7z", + "copy.7z", + "ppmd.7z", + "lzma2_solid.7z", + "lzma2_nonsolid.7z", + "bcj_lzma.7z", + "delta_lzma.7z", + "ppmd_plain_header.7z", + "many_entries.7z", + ] { + let blob = fixture(name); + let (held, _) = walked_from(&blob.as_slice(), Budget::new(Limits::default())); + // PPMd holds its model, which is 64 KiB at the least. + let limits = Limits { + max_buffer_bytes: if name.starts_with("ppmd") { + 1 << 20 + } else { + 1000 + }, + ..Limits::default() + }; + let (cached, end) = walked_from(&cache_of(&blob), Budget::new(limits)); + end.unwrap_or_else(|h| panic!("{name}: {h:?}")); + assert_eq!(held.0, cached.0, "{name}"); + assert!(!held.0.is_empty(), "{name}"); + } + } + + /// A solid folder of three files, stored, so that the third starts 6000 + /// bytes into it. + fn solid_of_three() -> (Vec<u8>, Vec<Vec<u8>>) { + use sevenz_rust2::{ArchiveEntry, ArchiveWriter, EncoderMethod, SourceReader}; + use std::io::Cursor; + let files: Vec<Vec<u8>> = vec![ + (0..3000u32).map(|i| (i * 7 % 251) as u8).collect(), + (0..3000u32).map(|i| (i * 13 % 241) as u8).collect(), + b"the tail".to_vec(), + ]; + let mut sink = Cursor::new(Vec::new()); + { + let mut w = ArchiveWriter::new(&mut sink).unwrap(); + w.set_content_methods(vec![EncoderMethod::COPY.into()]); + let entries = ["a", "b", "c"] + .iter() + .map(|n| ArchiveEntry::new_file(n)) + .collect(); + let readers = files + .iter() + .map(|f| SourceReader::new(Cursor::new(f.clone()))) + .collect(); + w.push_archive_entries(entries, readers).unwrap(); + w.finish().unwrap(); + } + (sink.into_inner(), files) + } + + /// A member far into a solid folder is read, not reported: it starts past + /// the buffer limit, which a folder streamed through is not held under. + #[test] + fn a_member_far_into_a_solid_folder_is_read_not_reported() { + let (blob, files) = solid_of_three(); + let limits = Limits { + max_buffer_bytes: 1000, + ..Limits::default() + }; + let ((got, _), end) = walked_from(&cache_of(&blob), Budget::new(limits)); + end.unwrap(); + let want: Vec<_> = ["a", "b", "c"] + .iter() + .zip(&files) + .map(|(n, f)| (n.to_string(), f.clone(), None)) + .collect(); + assert_eq!(got, want); + } + + /// The members before the one wanted are decoded and dropped, which counts + /// against the scan budget: a folder that declares more than it allows is + /// not decoded to reach a member. + #[test] + fn what_is_skipped_to_reach_a_member_counts_against_the_scan_budget() { + let (blob, _) = solid_of_three(); + // A visitor that reads no member, so that reaching the third skips the + // 6000 bytes of the two before it. + let reached = |scanned: u64| { + let mut budget = Budget::new(Limits { + max_scanned_bytes: scanned, + ..Limits::default() + }); + let mut names = Vec::new(); + let end = crate::walk( + Format::SevenZip, + &blob.as_slice(), + &mut budget, + &mut |meta, _, _| { + names.push(meta.name.clone()); + None::<()> + }, + ); + (names, end) + }; + let (names, end) = reached(1 << 20); + end.unwrap(); + assert_eq!(names, ["a", "b", "c"]); + let (names, end) = reached(5000); + assert_eq!(names, ["a", "b"], "c is not reached"); + assert!(end.is_err_and(|h| !h.is_corrupt())); + } + + /// A folder decoded whole, as an encrypted one is, is held under the buffer + /// limit, read from where it is in the source. + #[cfg(feature = "decrypt")] + #[test] + fn an_encrypted_folder_is_held_under_the_buffer_limit_and_read_from_the_source() { + let blob = fixture("aes_stored.7z"); + let limits = Limits { + max_buffer_bytes: 4096, + ..Limits::default() + }; + let budget = Budget::with_passwords(limits, vec!["hunter2".into()]); + let ((got, _), end) = walked_from(&cache_of(&blob), budget); + end.unwrap(); + assert!( + got.iter() + .any(|(_, d, _)| d.windows(16).any(|w| w == b"EICAR-7Z-AES-MAR")) + ); + // Under a limit the packed bytes do not fit, it is the limit. + let limits = Limits { + max_buffer_bytes: 32, + ..Limits::default() + }; + let budget = Budget::with_passwords(limits, vec!["hunter2".into()]); + let (_, end) = walked_from(&cache_of(&blob), budget); + let hit = end.unwrap_err(); + assert!(!hit.is_corrupt(), "{}", hit.reason); + } } diff --git a/crates/exav-unpack/src/formats/sevenz/parse.rs b/crates/exav-unpack/src/formats/sevenz/parse.rs index bf6d8937..f6122539 100644 --- a/crates/exav-unpack/src/formats/sevenz/parse.rs +++ b/crates/exav-unpack/src/formats/sevenz/parse.rs @@ -106,8 +106,11 @@ impl<'a> SevenZReader<'a> { ])) } + /// A property id. 7-Zip reads it as a number, so an id from 128 up is more + /// than a byte; it is returned as 255, which no id the reader knows is, and + /// the property is skipped by its size. pub fn read_nid(&mut self) -> Result<u8, LimitHit> { - self.read_byte() + Ok(u8::try_from(read_var_u64(self)?).unwrap_or(u8::MAX)) } } diff --git a/crates/exav-unpack/src/formats/sfx.rs b/crates/exav-unpack/src/formats/sfx.rs index e345bdfe..3039df7a 100644 --- a/crates/exav-unpack/src/formats/sfx.rs +++ b/crates/exav-unpack/src/formats/sfx.rs @@ -99,10 +99,10 @@ fn find_archive_from(data: &[u8], from: usize) -> Option<(usize, &'static str)> let found = memchr::memmem::find_iter(hay, sig) .map(|rel| rel + from) .find(|&off| plausible(data, off)); - if let Some(off) = found { - if best.is_none_or(|(b, _)| off < b) { - best = Some((off, name)); - } + if let Some(off) = found + && best.is_none_or(|(b, _)| off < b) + { + best = Some((off, name)); } } best diff --git a/crates/exav-unpack/src/formats/sniff.rs b/crates/exav-unpack/src/formats/sniff.rs index c624d901..bc09fe98 100644 --- a/crates/exav-unpack/src/formats/sniff.rs +++ b/crates/exav-unpack/src/formats/sniff.rs @@ -38,12 +38,15 @@ pub(crate) fn is_in(p: &Probe, fmt: Format) -> bool { Format::Ace => d.get(7..14) == Some(b"**ACE**".as_slice()), // LZ4 frame, the pre-1.5 legacy frame, or a skippable frame - // (`0x184D2A50`..`5F`). + // (`0x184D2A50`..`5F`), which Zstandard has as well: those that are + // followed by a Zstandard frame are Zstandard's. Format::Lz4 => { d.len() > 7 && (d.starts_with(&[0x04, 0x22, 0x4D, 0x18]) || d.starts_with(&[0x02, 0x21, 0x4C, 0x18]) - || (d[0] & 0xF0 == 0x50 && d[1..4] == [0x2A, 0x4D, 0x18])) + || (d[0] & 0xF0 == 0x50 + && d[1..4] == [0x2A, 0x4D, 0x18] + && !zstd_after_skippable(d))) } // The `conectix` footer cookie. A fixed-disk image carries only the @@ -190,14 +193,143 @@ pub(crate) fn is_in(p: &Probe, fmt: Format) -> bool { d.starts_with(&[0x00, 0x05, 0x16, 0x00]) || d.starts_with(&[0x00, 0x05, 0x16, 0x07]) } + Format::Lzma => lzma_alone_header(d), + + // The lead: magic, then the major version of the format (1 to 4). + // The superblock's magic, in either byte order, and its 96 bytes. + Format::Squashfs => d.len() >= 96 && (d.starts_with(b"hsqs") || d.starts_with(b"sqsh")), + + // The superblock's magic, in either byte order, and the signature + // after it, at the start or 512 bytes in (room for boot code). + Format::Cramfs => [0usize, 512].iter().any(|&at| { + d.get(at..at.saturating_add(32)).is_some_and(|h| { + (h.starts_with(&[0x45, 0x3D, 0xCD, 0x28]) + || h.starts_with(&[0x28, 0xCD, 0x3D, 0x45])) + && h.get(16..32) == Some(b"Compressed ROMFS".as_slice()) + }) + }), + + // The eight bytes the first line of a romfs image is, and the file + // system's name after it. + Format::Romfs => d.len() >= 32 && d.starts_with(b"-rom1fs-"), + + // The superblock is 1024 bytes in, with its magic first (little-endian). + Format::Erofs => d.get(1024..1028) == Some(&[0xE2, 0xE1, 0xF5, 0xE0][..]), + + Format::Rpm => { + d.len() >= 96 && d.starts_with(&[0xED, 0xAB, 0xEE, 0xDB]) && (1..=4).contains(&d[4]) + } + _ => false, } } +/// Whether the start of a file is the header of an LZMA-alone stream, as the xz +/// utilities decide it when they are not told: valid properties, a dictionary +/// size of the form 2^n or 2^n + 2^(n-1) (what the encoders write), a size that +/// is unknown or below 256 GiB, and the zero byte every range coder starts +/// with. A file that is not LZMA passes by chance about once in 10^10, which +/// matters because this runs against every file. +fn lzma_alone_header(d: &[u8]) -> bool { + let Some(h) = d.get(..14) else { + return false; + }; + let dict = u32::from_le_bytes([h[1], h[2], h[3], h[4]]); + let size = u64::from_le_bytes(h[5..13].try_into().unwrap()); + h[0] < 9 * 5 * 5 + && (dict == u32::MAX + || (12..=31).any(|n| dict == 1 << n || (n > 12 && dict == (1 << n) + (1 << (n - 1))))) + && (size == u64::MAX || size < 1 << 38) + && h[13] == 0 +} + +/// Whether a file that is no other format is a ZIP that does not start with a +/// local header: behind a script line (a Python zipapp), padding or a stub, or +/// with the marker of a spanned archive in front. Readers find a ZIP by its end +/// record, so these open everywhere. The end record (and its comment) may be +/// followed by padding, the directory must end where it starts, and the first +/// entry's local header must be where its offset puts it, counted from the +/// archive's start or from the file's. ZIP64 is not followed. +#[cfg(feature = "zip")] +pub(crate) fn prefixed_zip(p: &Probe) -> bool { + const EOCD: usize = 22; + if p.len < EOCD + 30 { + return false; + } + let from = p.len.saturating_sub(EOCD + usize::from(u16::MAX)); + let tail = p.window(from, p.len - from); + let Some(rel) = tail.windows(4).rposition(|w| w == b"PK\x05\x06") else { + return false; + }; + let Some(end) = tail.get(rel..rel + EOCD) else { + return false; + }; + let eocd = from + rel; + let u16_at = |at: usize| usize::from(u16::from_le_bytes([end[at], end[at + 1]])); + let u32_at = |at: usize| u32::from_le_bytes([end[at], end[at + 1], end[at + 2], end[at + 3]]); + let (entries, cd_size, cd_off) = (u16_at(10), u32_at(12), u32_at(16)); + if eocd + EOCD + u16_at(20) > p.len + || entries == 0 + || entries == 0xFFFF + || cd_size == u32::MAX + || cd_off == u32::MAX + { + return false; + } + let Some(cd_start) = eocd.checked_sub(cd_size as usize) else { + return false; + }; + let Some(prefix) = cd_start.checked_sub(cd_off as usize) else { + return false; + }; + let entry = p.window(cd_start, 46); + if !entry.starts_with(b"PK\x01\x02") || entry.len() < 46 { + return false; + } + let local = u32::from_le_bytes([entry[42], entry[43], entry[44], entry[45]]) as usize; + p.window(prefix.saturating_add(local), 4) + .starts_with(b"PK\x03\x04") +} + +/// Whether `d` is skippable frames (`0x184D2A50`..`5F`, a length, that many +/// bytes) and then a Zstandard frame. A file may open with a skippable frame +/// and be LZ4 or Zstandard; only what follows says which. +pub(crate) fn zstd_after_skippable(d: &[u8]) -> bool { + let mut rest = d; + // A few frames is all a real file has; the bound keeps a long run cheap. + for _ in 0..16 { + match rest { + [m, 0x2A, 0x4D, 0x18, a, b, c, e, tail @ ..] if m & 0xF0 == 0x50 => { + let len = u32::from_le_bytes([*a, *b, *c, *e]) as usize; + match tail.get(len..) { + Some(next) => rest = next, + None => return false, + } + } + _ => return rest.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]), + } + } + false +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_skippable_frame_is_lz4_or_zstd_by_what_follows() { + let skip = [&[0x50, 0x2A, 0x4D, 0x18, 3, 0, 0, 0][..], b"xyz"].concat(); + let zstd = [&skip[..], &[0x28, 0xB5, 0x2F, 0xFD, 0, 0, 0, 0][..]].concat(); + let lz4 = [&skip[..], &[0x04, 0x22, 0x4D, 0x18, 0, 0, 0, 0][..]].concat(); + assert!(!is(&zstd, Format::Lz4)); + assert!(zstd_after_skippable(&zstd)); + assert!(is(&lz4, Format::Lz4)); + assert!(!zstd_after_skippable(&lz4)); + // Skippable frames and nothing after: LZ4 as before. + let only = [&skip[..], &[0; 8][..]].concat(); + assert!(is(&only, Format::Lz4)); + } + #[test] fn each_format_accepts_its_own_magic() { let cases: Vec<(Format, Vec<u8>)> = vec![ @@ -302,6 +434,35 @@ mod tests { assert!(!is(b"KDMV", Format::Vmdk)); } + fn lzma_header(props: u8, dict: u32, size: u64) -> Vec<u8> { + let mut h = vec![props]; + h.extend_from_slice(&dict.to_le_bytes()); + h.extend_from_slice(&size.to_le_bytes()); + h.extend_from_slice(&[0, 1, 2]); + h + } + + #[test] + fn a_header_of_lzma_utils_is_told() { + let is_lzma = |h: &[u8]| is(h, Format::Lzma); + // `lzma` writes 0x5D (lc 3, lp 0, pb 2) and a 2^n dictionary. + assert!(is_lzma(&lzma_header(0x5D, 1 << 26, u64::MAX))); + assert!(is_lzma(&lzma_header(0x5D, 3 << 23, 1234))); + assert!(is_lzma(&lzma_header(0x5D, u32::MAX, 0))); + // A properties byte past lc 8, lp 4, pb 4. + assert!(!is_lzma(&lzma_header(225, 1 << 26, 1))); + // A dictionary that is not 2^n or 2^n + 2^(n-1), or is below 4 KiB. + assert!(!is_lzma(&lzma_header(0x5D, (1 << 26) + 1, 1))); + assert!(!is_lzma(&lzma_header(0x5D, 1 << 11, 1))); + // A size of 256 GiB or more that is not "unknown". + assert!(!is_lzma(&lzma_header(0x5D, 1 << 26, 1 << 38))); + // A range coder starts with a zero byte. + let mut h = lzma_header(0x5D, 1 << 26, 1); + h[13] = 1; + assert!(!is_lzma(&h)); + assert!(!is_lzma(&h[..13])); + } + /// A format this module does not speak for must answer `false` rather than /// something accidental — the structural sniffs own those. #[test] diff --git a/crates/exav-unpack/src/formats/squashfs.rs b/crates/exav-unpack/src/formats/squashfs.rs new file mode 100644 index 00000000..305906cc --- /dev/null +++ b/crates/exav-unpack/src/formats/squashfs.rs @@ -0,0 +1,1103 @@ +//! SquashFS 4: the files of an image, read where they lie. +//! +//! The filesystem of firmware, live images, AppImages and snaps: a read-only +//! tree in blocks that are each compressed on its own. The image is a +//! `Read + Seek`, read where the walk goes: the superblock, the blocks of the +//! tables the walk passes through, and each file's blocks. Only a file is ever +//! held, bounded by the buffer limit. +//! +//! ```text +//! superblock block size, compression, where each table is, the root +//! inode table metadata blocks: one inode per file, with the sizes of +//! its data blocks, and the fragment its tail is in +//! directory table metadata blocks: runs of (name, inode) per directory +//! fragment table where each block that holds tails is +//! data blocks file contents, in blocks of the block size, each +//! compressed or not +//! ``` +//! +//! A metadata block is at most 8 KiB once decompressed, behind a 2-byte header +//! that says whether it is compressed and how long it is. An inode or a +//! directory is addressed by the metadata block it starts in, and the offset +//! in it, and may go on into the next block. Both byte orders are read (an +//! image from a big-endian machine begins `sqsh`). The compressions are gzip, +//! LZMA, LZO, XZ, LZ4 and Zstandard. A sparse block reads as zeros. A symbolic +//! link or a device node holds no file's bytes and is not emitted. Versions +//! before 4 are reported, not read. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek}; +use std::rc::Rc; + +use super::vol::{ + Attrs, Emit, Fill, Found, Got, Step, Vol, be16, be32, be64, give, le16, le32, le64, read_file, +}; +use crate::source::{ByteSource, Reader}; +use crate::stream::Visit; +use crate::{Budget, Entry, LimitHit, Mtime}; + +/// The most directories a path passes through. +const MAX_DEPTH: usize = 256; + +/// What a metadata block holds once decompressed, at most. +const META: usize = 8192; + +/// How many metadata blocks are kept. +const CACHED: usize = 64; + +/// The most a directory's listing is, bytes. +const MAX_LISTING: u64 = 64 << 20; + +/// The most data blocks of a file are read from a list. +const MAX_BLOCKS: u64 = 1 << 24; + +/// The size of a fragment that is none. +const NO_FRAGMENT: u32 = 0xFFFF_FFFF; + +/// `st_mode` file types. +const S_IFREG: u32 = 0o100000; +const S_IFDIR: u32 = 0o040000; + +/// Whether `data`, the start of an image, holds a SquashFS superblock's magic, +/// in either byte order. +pub(crate) fn is_squashfs(data: &[u8]) -> bool { + super::sniff::is(data, crate::Format::Squashfs) +} + +/// What the superblock says of the image. +struct Squash { + be: bool, + block_size: u64, + compression: u16, + fragments: u32, + root: u64, + inode_table: u64, + dir_table: u64, + frag_table: u64, + /// The decompressed bytes of the metadata blocks read, by place, and the + /// place of the block after. + cache: HashMap<u64, (Rc<Vec<u8>>, u64)>, + /// The last block of fragments read, and which. + fragment: Option<(u32, Rc<Vec<u8>>)>, +} + +/// What an inode is. +enum Kind { + Dir { + /// The directory table's block and the offset in it. + block: u64, + offset: usize, + /// The bytes of its listing. + size: u64, + }, + File { + blocks_start: u64, + size: u64, + fragment: u32, + fragment_offset: u64, + /// Where its list of block sizes is: the inode's place, the bytes + /// before the list, and how many sizes there are. + place: (u64, usize), + before: usize, + count: u64, + }, + Other, +} + +struct Inode { + kind: Kind, + mode: u32, + mtime: i64, +} + +impl Squash { + fn u16(&self, d: &[u8], at: usize) -> u16 { + if self.be { be16(d, at) } else { le16(d, at) } + } + + fn u32(&self, d: &[u8], at: usize) -> u32 { + if self.be { be32(d, at) } else { le32(d, at) } + } + + fn u64(&self, d: &[u8], at: usize) -> u64 { + if self.be { be64(d, at) } else { le64(d, at) } + } + + /// `src` decompressed with the image's compression, to at most `max` bytes. + fn unpack(&self, src: &[u8], max: usize) -> Option<Vec<u8>> { + let mut out = Vec::new(); + let limit = u64::try_from(max).ok()?.checked_add(1)?; + match self.compression { + 1 => { + crate::inflate::zlib_body(src)? + .take(limit) + .read_to_end(&mut out) + .ok()?; + } + 2 => { + let stream = lzma_rust2::LzmaStream::new_mem_limit(128 * 1024, None); + super::lzma::SansIo::new(src, stream) + .take(limit) + .read_to_end(&mut out) + .ok()?; + } + 3 => out = super::lzo::decompress(src, max).ok()?, + 4 => { + super::xz::XzReader::new(src) + .take(limit) + .read_to_end(&mut out) + .ok()?; + } + 5 => super::lz4::block(src, &mut out, max).ok()?, + 6 => { + super::zstd::ZstdReader::new(src) + .take(limit) + .read_to_end(&mut out) + .ok()?; + } + _ => return None, + } + (out.len() <= max).then_some(out) + } + + /// The metadata block at `at`, and where the one after it is. + fn meta_block(&mut self, v: &mut Vol, at: u64) -> Option<(Rc<Vec<u8>>, u64)> { + if let Some((data, next)) = self.cache.get(&at) { + return Some((Rc::clone(data), *next)); + } + let head = v.at(at, 2)?; + let header = self.u16(&head, 0); + let size = usize::from(header & 0x7FFF); + if size == 0 || size > META { + return None; + } + let raw = v.at(at.checked_add(2)?, size)?; + let data = if header & 0x8000 != 0 { + raw + } else { + self.unpack(&raw, META)? + }; + let next = at.checked_add(2)?.checked_add(size as u64)?; + if self.cache.len() >= CACHED { + self.cache.clear(); + } + let data = Rc::new(data); + self.cache.insert(at, (Rc::clone(&data), next)); + Some((data, next)) + } + + /// `n` bytes of a table that starts at `table`, from the offset `inner` of + /// the metadata block that is `block` bytes into it, going on into the + /// blocks after. + fn meta_read( + &mut self, + v: &mut Vol, + table: u64, + block: u64, + inner: usize, + n: usize, + ) -> Option<Vec<u8>> { + let mut at = table.checked_add(block)?; + let mut inner = inner; + let mut out: Vec<u8> = Vec::new(); + while out.len() < n { + let (data, next) = self.meta_block(v, at)?; + let left = data.len().checked_sub(inner)?; + let take = left.min(n.saturating_sub(out.len())); + if take == 0 { + return None; + } + out.extend_from_slice(data.get(inner..inner.checked_add(take)?)?); + inner = 0; + at = next; + } + Some(out) + } + + /// The inode at a reference: the block of the inode table it starts in + /// (above 16 bits) and the offset in it. + fn inode(&mut self, v: &mut Vol, reference: u64) -> Option<Inode> { + let (block, inner) = (reference >> 16, usize::from(reference as u16)); + let h = self.meta_read(v, self.inode_table, block, inner, 16)?; + let (ty, mode, mtime) = (self.u16(&h, 0), u32::from(self.u16(&h, 2)), self.u32(&h, 8)); + let fixed = match ty { + 1 => 32, + 8 => 40, + 2 => 32, + 9 => 56, + _ => 16, + }; + let b = self.meta_read(v, self.inode_table, block, inner, fixed)?; + let kind = match ty { + 1 => Kind::Dir { + block: u64::from(self.u32(&b, 16)), + offset: usize::from(self.u16(&b, 26)), + size: u64::from(self.u16(&b, 24)), + }, + 8 => Kind::Dir { + block: u64::from(self.u32(&b, 24)), + offset: usize::from(self.u16(&b, 34)), + size: u64::from(self.u32(&b, 20)), + }, + 2 | 9 => { + let (blocks_start, size, fragment, fragment_offset) = if ty == 2 { + ( + u64::from(self.u32(&b, 16)), + u64::from(self.u32(&b, 28)), + self.u32(&b, 20), + u64::from(self.u32(&b, 24)), + ) + } else { + ( + self.u64(&b, 16), + self.u64(&b, 24), + self.u32(&b, 44), + u64::from(self.u32(&b, 48)), + ) + }; + // A tail in a fragment is not a block of the list. + let count = if fragment == NO_FRAGMENT { + size.div_ceil(self.block_size) + } else { + size.checked_div(self.block_size)? + }; + Kind::File { + blocks_start, + size, + fragment, + fragment_offset, + place: (block, inner), + before: fixed, + count, + } + } + _ => Kind::Other, + }; + // The type in `mode` is the one that counts: the inode type says the + // layout, and a directory may be stored in the extended one. + let ftype = match ty { + 1 | 8 => S_IFDIR, + 2 | 9 => S_IFREG, + _ => 0, + }; + Some(Inode { + kind, + mode: (mode & 0o7777) | ftype, + mtime: i64::from(mtime), + }) + } + + /// The entries of a directory: name, type and inode reference. The flag is + /// false where the listing is damaged. + fn entries( + &mut self, + v: &mut Vol, + block: u64, + offset: usize, + size: u64, + left: &mut u64, + limit: usize, + ) -> (Vec<(String, u16, u64)>, bool) { + let mut out = Vec::new(); + // The listing is three bytes longer than what is in it. + let want = size.saturating_sub(3); + if want > MAX_LISTING || want > *left { + return (out, false); + } + *left = left.saturating_sub(want); + let Some(list) = self.meta_read( + v, + self.dir_table, + block, + offset, + crate::bytes::to_usize(want), + ) else { + return (out, false); + }; + let mut at = 0usize; + while at < list.len() { + let Some(h) = crate::bytes::at(&list, at, 12) else { + return (out, false); + }; + // The entries of one inode block: one fewer than they are, the + // block, and the number the others are relative to. + let count = u64::from(self.u32(h, 0)).saturating_add(1); + let start = u64::from(self.u32(h, 4)); + at = at.saturating_add(12); + for _ in 0..count { + let Some(e) = crate::bytes::at(&list, at, 8) else { + return (out, false); + }; + let (inner, ty) = (u64::from(self.u16(e, 0)), self.u16(e, 4)); + let name_len = usize::from(self.u16(e, 6)).saturating_add(1); + let Some(name) = crate::bytes::at(&list, at.saturating_add(8), name_len) else { + return (out, false); + }; + at = at.saturating_add(8).saturating_add(name_len); + if out.len() >= limit { + return (out, true); + } + out.push(( + String::from_utf8_lossy(name).into_owned(), + ty, + start.saturating_mul(1 << 16).saturating_add(inner), + )); + } + } + (out, true) + } + + /// Where fragment `n` is: its place and its size field. + fn fragment_place(&mut self, v: &mut Vol, n: u32) -> Option<(u64, u32)> { + if n >= self.fragments { + return None; + } + let index = u64::from(n.checked_div(512)?); + let loc = self.u64( + &v.at(self.frag_table.checked_add(index.checked_mul(8)?)?, 8)?, + 0, + ); + let slot = usize::try_from(n.checked_rem(512)?).ok()?; + // A block of the table is a block with no inner offset: it begins at its + // own place, not at one counted from a table. + let (data, _) = self.meta_block(v, loc)?; + let e = crate::bytes::at(&data, slot.checked_mul(16)?, 16)?; + Some((self.u64(e, 0), self.u32(e, 8))) + } + + /// The decompressed block of fragments `n`. + fn fragment_block(&mut self, v: &mut Vol, n: u32) -> Option<Rc<Vec<u8>>> { + if let Some((m, data)) = &self.fragment + && *m == n + { + return Some(Rc::clone(data)); + } + let (start, field) = self.fragment_place(v, n)?; + let data = Rc::new(self.data_block(v, start, field)?); + self.fragment = Some((n, Rc::clone(&data))); + Some(data) + } + + /// A block of data at `at`, as its size field says it is stored. + fn data_block(&mut self, v: &mut Vol, at: u64, field: u32) -> Option<Vec<u8>> { + let size = usize::try_from(field & 0x00FF_FFFF).ok()?; + let raw = v.at(at, size)?; + if field & 0x0100_0000 != 0 { + return Some(raw); + } + self.unpack(&raw, crate::bytes::to_usize(self.block_size)) + } + + /// The bytes of a file: held when they are at most `cap`, else read as + /// they go. + fn file<'a>(&'a mut self, v: &'a mut Vol<'_>, kind: &Kind, cap: u64) -> Got<'a> { + let Kind::File { + blocks_start, + size, + fragment, + fragment_offset, + place, + before, + count, + } = kind + else { + return Got::Why("not a file"); + }; + if *size == 0 { + return Got::Whole(Vec::new()); + } + if *count > MAX_BLOCKS { + return Got::Why("SquashFS file with more blocks than are followed"); + } + let n = crate::bytes::to_usize(*count); + let list = self.meta_read( + v, + self.inode_table, + place.0, + place.1, + before.saturating_add(n.saturating_mul(4)), + ); + let Some(list) = list else { + return Got::Why("SquashFS file whose list of blocks is unreadable"); + }; + let bs = crate::bytes::to_usize(self.block_size); + let fill = Blocks { + fs: self, + list, + before: *before, + n, + i: 0, + at: *blocks_start, + bs, + total: crate::bytes::to_usize(*size), + given: 0, + fragment: *fragment, + fragment_offset: *fragment_offset, + tail: false, + }; + read_file(v, fill, *size, cap) + } +} + +/// The blocks of a file, then its tail in a fragment, read and decompressed as +/// they are wanted. +struct Blocks<'a> { + fs: &'a mut Squash, + /// The inode's bytes: what comes before the list of blocks, and the list. + list: Vec<u8>, + before: usize, + n: usize, + /// The next block, and where it is stored. + i: usize, + at: u64, + bs: usize, + total: usize, + /// How many bytes of the file have been given. + given: usize, + fragment: u32, + fragment_offset: u64, + /// The tail has been given. + tail: bool, +} + +impl Fill for Blocks<'_> { + fn fill(&mut self, v: &mut Vol, out: &mut Vec<u8>) -> Step { + if self.i < self.n { + let field = self.fs.u32( + &self.list, + self.before.saturating_add(self.i.saturating_mul(4)), + ); + // The last block is what is left, if the tail is not in a fragment. + let want = self.bs.min(self.total.saturating_sub(self.given)); + let stored = u64::from(field & 0x00FF_FFFF); + if stored == 0 { + out.resize(out.len().saturating_add(want), 0); + } else { + match self.fs.data_block(v, self.at, field) { + Some(mut b) => { + b.resize(want, 0); + out.extend_from_slice(&b); + } + None => return Step::Broken, + } + self.at = self.at.saturating_add(stored); + } + self.given = self.given.saturating_add(want); + self.i = self.i.saturating_add(1); + return Step::More; + } + if self.fragment == NO_FRAGMENT || self.tail { + return Step::End; + } + let tail = self.total.saturating_sub(self.given); + let from = crate::bytes::to_usize(self.fragment_offset); + let part = self + .fs + .fragment_block(v, self.fragment) + .and_then(|b| crate::bytes::at(&b, from, tail).map(<[u8]>::to_vec)); + match part { + Some(p) => { + self.given = self.given.saturating_add(p.len()); + out.extend_from_slice(&p); + self.tail = true; + Step::More + } + None => Step::Broken, + } + } +} + +/// The image's superblock, or `None` where it describes none that is read. +fn open(v: &mut Vol) -> Option<Squash> { + let b = v.at(0, 96)?; + let be = b.starts_with(b"sqsh"); + let mut s = Squash { + be, + block_size: 0, + compression: 0, + fragments: 0, + root: 0, + inode_table: 0, + dir_table: 0, + frag_table: 0, + cache: HashMap::new(), + fragment: None, + }; + let (block_size, log) = (u64::from(s.u32(&b, 12)), s.u16(&b, 22)); + if s.u16(&b, 28) != 4 + || !(4096..=1 << 20).contains(&block_size) + || !block_size.is_power_of_two() + || block_size != 1u64.checked_shl(u32::from(log))? + { + return None; + } + s.block_size = block_size; + s.compression = s.u16(&b, 20); + s.fragments = s.u32(&b, 16); + s.root = s.u64(&b, 32); + s.inode_table = s.u64(&b, 64); + s.dir_table = s.u64(&b, 72); + s.frag_table = s.u64(&b, 80); + // The root is a directory of the inode table. + let root = s.inode(v, s.root)?; + matches!(root.kind, Kind::Dir { .. }).then_some(s) +} + +/// Walk a SquashFS image given whole as a source. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + super::volumes::entries(budget, visit, "", |budget, sink| { + walk_volume(Reader::new(src), src.len() as u64, budget, sink) + }) +} + +/// Walk the SquashFS image `disk`, which is `len` bytes long, handing each +/// file to `visit`. +pub(crate) fn walk_volume<D: Read + Seek, R>( + mut disk: D, + len: u64, + budget: &mut Budget, + visit: Emit<R>, +) -> Result<Option<R>, LimitHit> { + let max = crate::bytes::to_usize(budget.limits().max_buffer_bytes); + let mut v = Vol { + disk: &mut disk, + len, + max, + }; + let head = match v.at(0, 96) { + Some(h) if is_squashfs(&h) => h, + _ => return Ok(None), + }; + let be = head.starts_with(b"sqsh"); + let version = if be { be16(&head, 28) } else { le16(&head, 28) }; + if version != 4 { + budget.count_entry()?; + let note = Entry::unsupported( + "<squashfs-filesystem>".to_string(), + len, + false, + "SquashFS before version 4 is not read", + ); + return Ok(visit(Found::Held(note), budget)); + } + let Some(mut fs) = open(&mut v) else { + return Err(LimitHit::corrupt( + "squashfs open: the superblock or the root directory is unreadable".into(), + )); + }; + + // One more than the member limit, so the entry past it is counted and the + // walk stops there rather than ending short. + let limit = crate::bytes::to_usize(budget.limits().max_members).saturating_add(1); + // The directory listings of an image are all of its directory table. + let mut left = fs + .frag_table + .saturating_sub(fs.dir_table) + .saturating_mul(4096); + let mut damaged = false; + // A listing is a place and a length: an empty one is where the next begins. + let mut seen: HashSet<(u64, usize, u64)> = HashSet::new(); + let mut stack = Vec::new(); + if let Some(Inode { + kind: Kind::Dir { + block, + offset, + size, + }, + .. + }) = fs.inode(&mut v, fs.root) + { + seen.insert((block, offset, size)); + stack.push((block, offset, size, String::new(), 0usize)); + } + while let Some((block, offset, size, prefix, depth)) = stack.pop() { + let (list, intact) = fs.entries(&mut v, block, offset, size, &mut left, limit); + damaged |= !intact; + let mut below = Vec::new(); + for (name, _, reference) in list { + budget.count_entry()?; + let path = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + let Some(inode) = fs.inode(&mut v, reference) else { + damaged = true; + continue; + }; + match inode.kind { + Kind::Dir { + block, + offset, + size, + } => { + if depth >= MAX_DEPTH { + damaged = true; + } else if seen.insert((block, offset, size)) { + below.push((block, offset, size, path, depth.saturating_add(1))); + } + } + Kind::File { .. } => { + let attrs = Attrs { + mode: Some(inode.mode), + mtime: Some(Mtime::Unix(inode.mtime)), + }; + if let Some(r) = give(budget, &mut *visit, path, attrs, |cap| { + fs.file(&mut v, &inode.kind, cap) + })? { + return Ok(Some(r)); + } + } + // A link or a node holds no file's bytes. + Kind::Other => {} + } + } + stack.extend(below.into_iter().rev()); + } + if damaged { + return Err(LimitHit::corrupt( + "squashfs walk: the image is damaged; the files before the damage were scanned".into(), + )); + } + Ok(None) +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::super::vol::collected; + use super::*; + use crate::Limits; + use std::io::Cursor; + + const BS: usize = 4096; + + /// A file or a directory to put in an image. + enum N { + File(Vec<u8>), + Dir(Vec<(&'static str, N)>), + } + + /// An image written the way `mksquashfs` lays one out, nothing compressed: + /// the superblock, the data blocks, the inode table, the directory table and + /// the fragment table. One directory header to each entry. + struct Img { + be: bool, + data: Vec<u8>, + inodes: Vec<u8>, + dirs: Vec<u8>, + /// (place in the data, size field) of each fragment block. + fragments: Vec<(usize, u32)>, + count: u32, + } + + impl Img { + fn put16(&self, v: &mut Vec<u8>, x: u16) { + v.extend_from_slice(&if self.be { + x.to_be_bytes() + } else { + x.to_le_bytes() + }); + } + fn put32(&self, v: &mut Vec<u8>, x: u32) { + v.extend_from_slice(&if self.be { + x.to_be_bytes() + } else { + x.to_le_bytes() + }); + } + fn put64(&self, v: &mut Vec<u8>, x: u64) { + v.extend_from_slice(&if self.be { + x.to_be_bytes() + } else { + x.to_le_bytes() + }); + } + + /// A table as metadata blocks, none compressed. + fn meta(&self, table: &[u8]) -> Vec<u8> { + let mut out = Vec::new(); + for chunk in table.chunks(META) { + self.put16(&mut out, 0x8000 | chunk.len() as u16); + out.extend_from_slice(chunk); + } + out + } + + /// The place of byte `pos` of a table in its metadata blocks. + fn place(pos: usize) -> (u64, u16) { + ((pos / META * (META + 2)) as u64, (pos % META) as u16) + } + + fn inode_header(&mut self, ty: u16, mode: u16) { + let mut h = Vec::new(); + self.put16(&mut h, ty); + self.put16(&mut h, mode); + self.put16(&mut h, 0); + self.put16(&mut h, 0); + self.put32(&mut h, 1_700_000_000); + self.count += 1; + let n = self.count; + self.put32(&mut h, n); + self.inodes.extend_from_slice(&h); + } + + /// Write `n`; its inode reference and its type. + fn node(&mut self, n: &N, tail_in_fragment: bool) -> (u64, u16) { + match n { + N::File(bytes) => { + let start = self.data.len(); + let full = bytes.len() / BS; + let tail = bytes.len() % BS; + let (mut sizes, mut fragment, mut offset) = (Vec::new(), NO_FRAGMENT, 0u32); + let blocks = if tail > 0 && !tail_in_fragment { + full + 1 + } else { + full + }; + for i in 0..blocks { + let chunk = &bytes[i * BS..bytes.len().min((i + 1) * BS)]; + if chunk.iter().all(|&b| b == 0) { + // A sparse block is stored as nothing. + sizes.push(0u32); + } else { + sizes.push(chunk.len() as u32 | 0x0100_0000); + self.data.extend_from_slice(chunk); + } + } + if tail > 0 && tail_in_fragment { + // The tails share a fragment block per file here. + fragment = self.fragments.len() as u32; + offset = 0; + let at = self.data.len(); + self.data.extend_from_slice(&bytes[full * BS..]); + self.fragments.push((at, tail as u32 | 0x0100_0000)); + } + let (block, inner) = Img::place(self.inodes.len()); + self.inode_header(2, 0o100644); + let mut b = Vec::new(); + self.put32(&mut b, (96 + start) as u32); + self.put32(&mut b, fragment); + self.put32(&mut b, offset); + self.put32(&mut b, bytes.len() as u32); + for s in sizes { + self.put32(&mut b, s); + } + self.inodes.extend_from_slice(&b); + (block << 16 | u64::from(inner), 2) + } + N::Dir(entries) => { + let mut kids: Vec<_> = entries + .iter() + .map(|(name, n)| (*name, self.node(n, tail_in_fragment))) + .collect(); + kids.sort_by_key(|k| k.0); + let (dblock, doffset) = Img::place(self.dirs.len()); + let mut list = Vec::new(); + for (name, (reference, ty)) in &kids { + self.put32(&mut list, 0); + self.put32(&mut list, (reference >> 16) as u32); + self.put32(&mut list, 1); + self.put16(&mut list, (reference & 0xFFFF) as u16); + self.put16(&mut list, 0); + self.put16(&mut list, *ty); + self.put16(&mut list, (name.len() - 1) as u16); + list.extend_from_slice(name.as_bytes()); + } + self.dirs.extend_from_slice(&list); + let (block, inner) = Img::place(self.inodes.len()); + self.inode_header(1, 0o040755); + let mut b = Vec::new(); + self.put32(&mut b, dblock as u32); + self.put32(&mut b, 2); + self.put16(&mut b, (list.len() + 3) as u16); + self.put16(&mut b, doffset); + self.put32(&mut b, 1); + self.inodes.extend_from_slice(&b); + (block << 16 | u64::from(inner), 1) + } + } + } + + /// The image of `root`. + fn build(be: bool, root: &N, tail_in_fragment: bool) -> Vec<u8> { + let mut img = Img { + be, + data: Vec::new(), + inodes: Vec::new(), + dirs: Vec::new(), + fragments: Vec::new(), + count: 0, + }; + let (root_ref, _) = img.node(root, tail_in_fragment); + let inode_table = 96 + img.data.len(); + let inodes = img.meta(&img.inodes); + let dir_table = inode_table + inodes.len(); + let dirs = img.meta(&img.dirs); + // The fragment table: a metadata block of 16-byte entries, and the + // list of the places of those. + let mut entries = Vec::new(); + for &(at, size) in &img.fragments { + img.put64(&mut entries, (96 + at) as u64); + img.put32(&mut entries, size); + img.put32(&mut entries, 0); + } + let frag_block = img.meta(&entries); + let frag_block_at = dir_table + dirs.len(); + let mut index = Vec::new(); + if !img.fragments.is_empty() { + img.put64(&mut index, frag_block_at as u64); + } + let frag_table = frag_block_at + frag_block.len(); + let total = frag_table + index.len(); + let mut sb = Vec::new(); + sb.extend_from_slice(if be { b"sqsh" } else { b"hsqs" }); + img.put32(&mut sb, img.count); + img.put32(&mut sb, 1_700_000_000); + img.put32(&mut sb, BS as u32); + img.put32(&mut sb, img.fragments.len() as u32); + img.put16(&mut sb, 1); + img.put16(&mut sb, 12); + img.put16(&mut sb, 0xB); + img.put16(&mut sb, 0); + img.put16(&mut sb, 4); + img.put16(&mut sb, 0); + img.put64(&mut sb, root_ref); + img.put64(&mut sb, total as u64); + img.put64(&mut sb, 0); + img.put64(&mut sb, u64::MAX); + img.put64(&mut sb, inode_table as u64); + img.put64(&mut sb, dir_table as u64); + img.put64(&mut sb, frag_table as u64); + img.put64(&mut sb, u64::MAX); + assert_eq!(sb.len(), 96); + [sb, img.data, inodes, dirs, frag_block, index].concat() + } + } + + type Member = (String, Vec<u8>, Option<&'static str>); + + fn walked(img: &[u8], limits: Limits) -> (Vec<Member>, Result<(), LimitHit>) { + let mut out = Vec::new(); + let mut budget = Budget::new(limits); + let len = img.len() as u64; + let end = walk_volume(Cursor::new(img), len, &mut budget, &mut |e, _| { + let e = e.whole(); + out.push((e.name, e.data, e.unsupported)); + None::<()> + }); + (out, end.map(|_| ())) + } + + fn pat(n: usize, seed: u8) -> Vec<u8> { + (0..n) + .map(|i| (i as u8).wrapping_mul(7).wrapping_add(seed)) + .collect() + } + + fn tree() -> N { + let mut sparse = pat(BS, 1); + sparse.extend(vec![0u8; BS]); + sparse.extend(pat(100, 2)); + N::Dir(vec![ + ("a.txt", N::File(b"hello".to_vec())), + ("empty", N::File(Vec::new())), + ("big", N::File(pat(3 * BS + 77, 3))), + ("sparse", N::File(sparse)), + ( + "sub", + N::Dir(vec![ + ("deeper", N::Dir(vec![("x.bin", N::File(pat(BS, 4)))])), + ("y.bin", N::File(pat(10, 5))), + ]), + ), + ]) + } + + fn want() -> Vec<(String, Vec<u8>)> { + let mut sparse = pat(BS, 1); + sparse.extend(vec![0u8; BS]); + sparse.extend(pat(100, 2)); + vec![ + ("a.txt".into(), b"hello".to_vec()), + ("big".into(), pat(3 * BS + 77, 3)), + ("empty".into(), Vec::new()), + ("sparse".into(), sparse), + ("sub/deeper/x.bin".into(), pat(BS, 4)), + ("sub/y.bin".into(), pat(10, 5)), + ] + } + + fn sorted(mut e: Vec<Member>) -> Vec<(String, Vec<u8>)> { + assert!( + e.iter().all(|m| m.2.is_none()), + "{:?}", + e.iter().map(|m| (&m.0, m.2)).collect::<Vec<_>>() + ); + e.sort_by(|a, b| a.0.cmp(&b.0)); + e.into_iter().map(|m| (m.0, m.1)).collect() + } + + #[test] + fn the_files_come_out_of_an_image_of_either_byte_order_with_tails_and_holes() { + for be in [false, true] { + for tails in [false, true] { + let img = Img::build(be, &tree(), tails); + let (e, end) = walked(&img, Limits::default()); + end.unwrap_or_else(|h| panic!("be {be} tails {tails}: {h:?}")); + assert_eq!(sorted(e), want(), "be {be} tails {tails}"); + } + } + } + + #[test] + fn a_directory_that_is_empty_does_not_hide_the_one_after_it() { + let root = N::Dir(vec![ + ("a", N::Dir(vec![])), + ("b", N::Dir(vec![("f", N::File(b"in b".to_vec()))])), + ]); + let (e, end) = walked(&Img::build(false, &root, false), Limits::default()); + end.unwrap(); + assert_eq!(sorted(e), [("b/f".to_string(), b"in b".to_vec())]); + } + + #[test] + fn a_directory_that_lists_itself_is_walked_once() { + let mut img = Img::build(false, &tree(), false); + // The root's listing is the last directory table's first run; make the + // entry named `sub` (a directory) point at the root's inode instead. + let root_ref = u64::from_le_bytes(img[32..40].try_into().unwrap()); + let sub = img + .windows(3) + .position(|w| w == b"sub") + .filter(|&p| p > 8) + .unwrap(); + // The entry: offset u16, delta i16, type u16, name size u16, the name; + // the header before it has the inode block. + let header = sub - 8 - 12; + img[header + 4..header + 8].copy_from_slice(&((root_ref >> 16) as u32).to_le_bytes()); + img[sub - 8..sub - 6].copy_from_slice(&((root_ref & 0xFFFF) as u16).to_le_bytes()); + let (e, end) = walked(&img, Limits::default()); + end.unwrap(); + let names: Vec<_> = e.iter().map(|m| m.0.as_str()).collect(); + assert_eq!( + names.iter().filter(|n| n.ends_with("a.txt")).count(), + 1, + "{names:?}" + ); + } + + #[test] + fn a_file_whose_blocks_or_fragment_are_not_there_is_reported_with_what_there_is() { + // The fragment index past the fragments there are. + let mut img = Img::build(false, &N::Dir(vec![("f", N::File(pat(BS + 5, 9)))]), true); + let inode_table = u64::from_le_bytes(img[64..72].try_into().unwrap()) as usize; + // The file inode follows the header (16 bytes) after the block (2). + let at = inode_table + 2 + 16 + 4; + img[at..at + 4].copy_from_slice(&7u32.to_le_bytes()); + let (e, end) = walked(&img, Limits::default()); + end.unwrap(); + assert_eq!(e[0].2, Some("file could not be read from the volume")); + assert_eq!(e[0].1.len(), BS); + + // A block that runs past the image. + let mut img = Img::build(false, &N::Dir(vec![("f", N::File(pat(2 * BS, 9)))]), false); + let at = inode_table_of(&img) + 2 + 16 + 16; + img[at..at + 4].copy_from_slice(&(0x00FF_FFFFu32 | 0x0100_0000).to_le_bytes()); + let (e, _) = walked(&img, Limits::default()); + assert_eq!(e[0].2, Some("file could not be read from the volume")); + } + + fn inode_table_of(img: &[u8]) -> usize { + u64::from_le_bytes(img[64..72].try_into().unwrap()) as usize + } + + #[test] + fn what_is_not_a_superblock_is_not_an_image() { + let good = Img::build(false, &tree(), false); + // What is written, and where. + let edits: [(&str, usize, &[u8]); 5] = [ + ("a block size of 3000", 12, &3000u32.to_le_bytes()), + ("a block size that is not its log", 22, &[13, 0]), + ("a root inode in no block", 32, &[0xFF; 8]), + ("an inode table past the image", 64, &[0xFF; 8]), + ("a root that is a file", 32, &[0, 0, 0, 0, 0, 0, 0, 0]), + ]; + for (what, at, bytes) in edits { + let mut img = good.clone(); + img[at..at + bytes.len()].copy_from_slice(bytes); + let (e, end) = walked(&img, Limits::default()); + // A root that is a file: the first inode written is a file. + assert!( + end.as_ref().is_err_and(|h| h.is_corrupt()) || e.is_empty(), + "{what}" + ); + } + // Version 3 is reported and not read. + let mut v3 = good.clone(); + v3[28..30].copy_from_slice(&3u16.to_le_bytes()); + let (e, end) = walked(&v3, Limits::default()); + end.unwrap(); + assert_eq!(e[0].2, Some("SquashFS before version 4 is not read")); + } + + #[test] + fn the_entry_past_the_member_limit_is_counted_and_stops_the_walk() { + let root = N::Dir(vec![ + ("a", N::File(b"a".to_vec())), + ("b", N::File(b"b".to_vec())), + ("c", N::File(b"c".to_vec())), + ]); + let img = Img::build(false, &root, false); + let run = |max: u64| { + let limits = Limits { + max_members: max, + ..Limits::default() + }; + walked(&img, limits) + }; + let (e, end) = run(3); + end.unwrap(); + assert_eq!(e.len(), 3); + let (e, end) = run(2); + assert!(!end.unwrap_err().is_corrupt()); + assert_eq!(e.len(), 2); + } + + #[test] + fn files_over_the_buffer_limit_are_read_as_they_go_in_either_byte_order() { + for be in [false, true] { + for tails in [false, true] { + let img = Img::build(be, &tree(), tails); + let len = img.len() as u64; + let mut budget = Budget::new(Limits { + max_buffer_bytes: 100, + ..Limits::default() + }); + let (e, end) = + collected(|emit| walk_volume(Cursor::new(&img[..]), len, &mut budget, emit)); + end.unwrap_or_else(|h| panic!("be {be} tails {tails}: {h:?}")); + let mut got: Vec<(String, Vec<u8>)> = e + .iter() + .map(|(m, _)| (m.name.clone(), m.data.clone())) + .collect(); + got.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(got, want(), "be {be} tails {tails}"); + for (m, streamed) in &e { + assert_eq!(m.unsupported, None, "{}", m.name); + assert_eq!(*streamed, m.data.len() > 100, "{}", m.name); + } + assert!(e.iter().any(|(_, streamed)| *streamed)); + } + } + } +} diff --git a/crates/exav-unpack/src/formats/swf.rs b/crates/exav-unpack/src/formats/swf.rs index 421d50a3..94541e84 100644 --- a/crates/exav-unpack/src/formats/swf.rs +++ b/crates/exav-unpack/src/formats/swf.rs @@ -16,7 +16,7 @@ use std::io::{BufReader, Cursor, Read}; use super::lzma::SansIo; use crate::source::{ByteSource, Reader}; -use crate::stream::{emit_stream, MemberMeta, Visit}; +use crate::stream::{MemberMeta, Visit, emit_stream}; use crate::{Budget, LimitHit}; /// Walk an SWF movie: the rebuilt `FWS` header, then the body decoded as it is @@ -97,8 +97,8 @@ fn dict_size(declared: u32, want: u64, max_buffer: u64) -> u32 { #[cfg(test)] mod tests { use super::*; - use crate::{extract, Format, Limits}; - use flate2::{write::ZlibEncoder, Compression}; + use crate::{Format, Limits, extract}; + use flate2::{Compression, write::ZlibEncoder}; use std::io::Write; /// Assemble an 8-byte SWF header for `sig` with a `FileLength` covering the @@ -178,9 +178,11 @@ mod tests { fn short_and_non_swf_input_is_ignored() { let mut budget = Budget::new(Limits::default()); assert!(extract(Format::Swf, b"CW", &mut budget).unwrap().is_empty()); - assert!(extract(Format::Swf, b"NOTASWF!", &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Swf, b"NOTASWF!", &mut budget) + .unwrap() + .is_empty() + ); } // A `ZWS` header carries two attacker-chosen sizes, and the LZMA dictionary diff --git a/crates/exav-unpack/src/formats/szdd.rs b/crates/exav-unpack/src/formats/szdd.rs index 7f2ce67d..e952e221 100644 --- a/crates/exav-unpack/src/formats/szdd.rs +++ b/crates/exav-unpack/src/formats/szdd.rs @@ -41,7 +41,7 @@ pub(crate) fn walk<T>( budget: &mut Budget, visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { - use crate::stream::{emit_stream, whole, MemberMeta}; + use crate::stream::{MemberMeta, emit_stream, whole}; let hdr = src.window(0, 14); if !hdr.starts_with(SZDD_MAGIC) { return whole(Format::Szdd, src, budget, visit); diff --git a/crates/exav-unpack/src/formats/tar.rs b/crates/exav-unpack/src/formats/tar.rs index 50ef69cf..a347429b 100644 --- a/crates/exav-unpack/src/formats/tar.rs +++ b/crates/exav-unpack/src/formats/tar.rs @@ -3,7 +3,7 @@ use std::io::{Seek, SeekFrom}; use crate::source::{ByteSource, Reader}; -use crate::stream::{emit_stream, MemberMeta, Mtime, Visit}; +use crate::stream::{MemberMeta, Mtime, Visit, emit_stream}; use crate::{Budget, LimitHit}; pub(crate) fn walk<T>( diff --git a/crates/exav-unpack/src/formats/tnef.rs b/crates/exav-unpack/src/formats/tnef.rs index 40c47339..3c3692db 100644 --- a/crates/exav-unpack/src/formats/tnef.rs +++ b/crates/exav-unpack/src/formats/tnef.rs @@ -150,8 +150,10 @@ mod tests { #[test] fn non_tnef_yields_nothing() { let mut budget = Budget::new(Limits::default()); - assert!(extract(Format::Tnef, b"not a tnef file", &mut budget) - .unwrap() - .is_empty()); + assert!( + extract(Format::Tnef, b"not a tnef file", &mut budget) + .unwrap() + .is_empty() + ); } } diff --git a/crates/exav-unpack/src/formats/udf.rs b/crates/exav-unpack/src/formats/udf.rs index d94b224b..2d4f2961 100644 --- a/crates/exav-unpack/src/formats/udf.rs +++ b/crates/exav-unpack/src/formats/udf.rs @@ -43,7 +43,7 @@ use std::borrow::Cow; use std::collections::HashSet; use std::io::{self, Read, Seek, SeekFrom}; -use crate::stream::{emit_stream, MemberMeta, Visit}; +use crate::stream::{MemberMeta, Visit, emit_stream}; use crate::{Budget, LimitHit}; /// UDF descriptors are addressed in 2048-byte sectors regardless of the logical @@ -758,11 +758,11 @@ struct Node { fn walk<T>( img: &mut dyn Image, max_dir: u64, - seen_extents: &mut HashSet<u64>, + seen_extents: &mut HashSet<(u64, u64)>, sink: Emit<'_, T>, ) -> Result<Option<T>, LimitHit> { macro_rules! emit { - ($item:expr) => { + ($item:expr_2021) => { if let Some(t) = sink(&mut *img, $item)? { return Ok(Some(t)); } @@ -905,10 +905,10 @@ fn walk<T>( // Deduplicate against the ISO 9660 walk of the same image: a bridge // image's two trees point at the same blocks. let part = child.icb.part; - if let Some(s) = first_extent_sector(img, &vol, fe, part, &fe_head, ad_kind)? { - if !seen_extents.insert(s) { - continue; - } + if let Some(s) = first_extent_sector(img, &vol, fe, part, &fe_head, ad_kind)? + && !seen_extents.insert((s, declared)) + { + continue; } let Some(l) = layout(img, &vol, fe, part, &fe_head, FILE_TYPE_FILE)? else { // The directory names this file, so it exists; its extents just @@ -945,7 +945,7 @@ pub(crate) fn stream_udf<R: Read + Seek, T>( source: &mut R, budget: &mut Budget, visit: Visit<T>, - seen_extents: &mut HashSet<u64>, + seen_extents: &mut HashSet<(u64, u64)>, ) -> Result<Option<T>, LimitHit> { let head = crate::read_at(source, 0, RECOGNITION_END)?; if !has_udf(&head) { @@ -1013,20 +1013,21 @@ fn read_fids(dir: &[u8], parent: &str) -> Vec<Fid> { let total = impl_len.saturating_add(38).saturating_add(name_len); // `..` carries no name and points back up the tree. - if characteristics & FID_PARENT == 0 && name_len > 0 { - if let Some(raw) = crate::bytes::at(dir, name_at, name_len) { - let name = decode_name(raw); - let path = if parent.is_empty() { - name - } else { - format!("{parent}/{name}") - }; - out.push(Fid { - path, - icb, - is_dir: characteristics & FID_DIRECTORY != 0, - }); - } + if characteristics & FID_PARENT == 0 + && name_len > 0 + && let Some(raw) = crate::bytes::at(dir, name_at, name_len) + { + let name = decode_name(raw); + let path = if parent.is_empty() { + name + } else { + format!("{parent}/{name}") + }; + out.push(Fid { + path, + icb, + is_dir: characteristics & FID_DIRECTORY != 0, + }); } // Descriptors are padded to a four-byte boundary. let step = total.div_ceil(4).saturating_mul(4); diff --git a/crates/exav-unpack/src/formats/udif.rs b/crates/exav-unpack/src/formats/udif.rs index b26eec07..02ab7d77 100644 --- a/crates/exav-unpack/src/formats/udif.rs +++ b/crates/exav-unpack/src/formats/udif.rs @@ -7,12 +7,12 @@ #![deny(clippy::arithmetic_side_effects)] use std::io::{self, Cursor, Read, Seek, SeekFrom}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use base64::Engine; -use quick_xml::events::Event; use quick_xml::Reader; +use quick_xml::events::Event; use crate::LimitHit; @@ -374,7 +374,7 @@ fn decompress(entry_type: u32, compressed: &[u8], expected_len: usize) -> Result return Ok(Run { data: Vec::new(), damaged: true, - }) + }); } }, BLK_BZIP2 => crate::salvage( @@ -383,20 +383,17 @@ fn decompress(entry_type: u32, compressed: &[u8], expected_len: usize) -> Result ), // ULMO blocks are XZ-framed (stream magic FD 37 7A 58 5A 00). BLK_LZMA => crate::salvage(super::xz::XzReader::new(Cursor::new(compressed)), cap), - BLK_LZFSE => { - let mut decoder = lzfse_rust::LzfseRingDecoder::default(); - crate::salvage(decoder.reader_bytes(compressed), cap) - } + BLK_LZFSE => super::lzfse::decode(compressed, cap), BLK_ADC => { return Ok(Run { data: adc_decompress(compressed, expected_len), damaged: false, - }) + }); } other => { return Err(LimitHit::corrupt(format!( "UDIF unsupported block type {other:#010x}" - ))) + ))); } }; // The run's compressed bytes are all here, so a stream that runs out of @@ -461,7 +458,7 @@ fn parse_plist(xml: &str) -> Result<Vec<Partition>, LimitHit> { loop { match reader.read_event() { - Ok(Event::Start(e)) => match e.name().as_ref() { + Ok(Event::Start(e)) => match e.name().as_ref().as_bytes() { b"array" if last_key == "blkx" => { in_blkx = true; } @@ -471,9 +468,8 @@ fn parse_plist(xml: &str) -> Result<Vec<Partition>, LimitHit> { _ => {} }, Ok(Event::Text(e)) => { - // quick-xml 0.41 replaced `BytesText::unescape()` with - // `xml10_content()` (decode + XML-1.0 entity unescape). - let text = e.xml10_content().unwrap_or_default(); + // `xml10_content()` decodes and unescapes XML 1.0 entities. + let text = e.xml10_content(); let trimmed = text.trim(); if e.is_empty() || trimmed.is_empty() { continue; @@ -497,7 +493,7 @@ fn parse_plist(xml: &str) -> Result<Vec<Partition>, LimitHit> { } } Ok(Event::End(e)) => { - if e.name().as_ref() == b"array" { + if e.name().as_ref() == "array" { in_blkx = false; } } @@ -665,6 +661,36 @@ mod tests { assert!(!d.damage().load(Ordering::Relaxed)); } + fn lzfse(data: &[u8]) -> Vec<u8> { + let mut enc = Vec::new(); + lzfse_rust::encode_bytes(data, &mut enc).unwrap(); + enc + } + + #[test] + fn an_lzfse_run_decodes() { + let t = text(64 * 1024); + let enc = lzfse(&t); + assert!(enc.starts_with(b"bvx2")); + let mut d = open(udif(BLK_LZFSE, 128, &enc)); + assert_eq!(read_at(&mut d, 0, t.len()).unwrap(), t); + assert!(!d.damage().load(Ordering::Relaxed)); + } + + #[test] + fn an_lzfse_run_damaged_part_way_keeps_its_whole_blocks_and_is_flagged() { + // A whole LZVN block, then an FSE block whose header is damaged. + let (first, rest) = (text(3000), text(64 * 1024)); + let mut enc = lzfse(&first); + enc.truncate(enc.len() - 4); + let at = enc.len(); + enc.extend(lzfse(&rest)); + enc[at + 3] = b'Z'; + let mut d = open(udif(BLK_LZFSE, 136, &enc)); + assert_eq!(read_at(&mut d, 0, first.len()).unwrap(), first); + assert!(d.damage().load(Ordering::Relaxed)); + } + #[test] fn a_run_decoding_past_its_size_is_flagged() { let t = text(4096); diff --git a/crates/exav-unpack/src/formats/upx.rs b/crates/exav-unpack/src/formats/upx.rs index ab85ec94..caa12aeb 100644 --- a/crates/exav-unpack/src/formats/upx.rs +++ b/crates/exav-unpack/src/formats/upx.rs @@ -905,7 +905,7 @@ mod tests { fn deflate_block_roundtrips() { // UPX method 15 is a raw DEFLATE stream; verify the decoder recovers the // original size-bounded payload. - use flate2::{write::DeflateEncoder, Compression}; + use flate2::{Compression, write::DeflateEncoder}; use std::io::Write as _; let orig: Vec<u8> = (0..4096u32).map(|i| (i * 7) as u8).collect(); let mut enc = DeflateEncoder::new(Vec::new(), Compression::best()); diff --git a/crates/exav-unpack/src/formats/uuencode.rs b/crates/exav-unpack/src/formats/uuencode.rs index 5e883a20..655591f0 100644 --- a/crates/exav-unpack/src/formats/uuencode.rs +++ b/crates/exav-unpack/src/formats/uuencode.rs @@ -333,11 +333,7 @@ mod tests { fn uuencode(name: &str, data: &[u8]) -> Vec<u8> { let enc = |v: u8| -> u8 { let v = v & 0x3F; - if v == 0 { - b'`' - } else { - v + 0x20 - } + if v == 0 { b'`' } else { v + 0x20 } }; let mut out = format!("begin 644 {name}\n").into_bytes(); for chunk in data.chunks(45) { diff --git a/crates/exav-unpack/src/formats/vdisk.rs b/crates/exav-unpack/src/formats/vdisk.rs new file mode 100644 index 00000000..f9aae89c --- /dev/null +++ b/crates/exav-unpack/src/formats/vdisk.rs @@ -0,0 +1,667 @@ +//! Virtual disks read in place. +//! +//! VHD, VHDX, QCOW2 and VMDK declare a disk size that is not the file's. A +//! [`VDisk`] gives such a disk as a `Read + Seek` without holding it. The +//! container's tables are read from the source when a read first reaches a +//! unit (a cluster, block or grain), a stored unit is read straight from the +//! source, an unallocated one reads as zeros and costs nothing, and a +//! compressed one is decoded into a cache of at most [`CACHE_BYTES`] and +//! decoded again if it was evicted. +//! +//! [`walk`] hands the disk over in one of two ways. A disk that fits the buffer +//! limit is read into one member, as it always was, so its raw bytes are +//! scanned and typed one level down. A larger one is never held: its partitions +//! and filesystems are walked in place ([`super::volumes`]) and each file comes +//! out as a member. +//! +//! What the decoder does is bounded three ways. The tables are not held at all +//! (they are read from the source, which caches them), so a hostile table size +//! costs reads, not memory. The cache is a fixed size. And the bytes decoded +//! over the whole walk are counted against the scan budget, so an image that +//! makes the filesystem walker land on a different compressed unit for every +//! small read stops when the budget does. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] +// Each format uses a part of this module (VHD has no compressed units, QCOW2 and +// VMDK no big-endian tables); see `cap_prealloc` for why the feature lists are +// not spelled out. +#![allow(dead_code)] + +use std::borrow::Cow; +use std::cell::Cell; +use std::collections::{BTreeMap, HashMap}; +use std::io::{self, Read, Seek, SeekFrom}; +use std::rc::Rc; + +use super::volumes::{self, report}; +use crate::source::ByteSource; +use crate::stream::Visit; +use crate::{Budget, Entry, LimitHit, LimitKind}; + +/// Decoded units kept. +pub(crate) const CACHE_BYTES: usize = 32 * 1024 * 1024; + +/// The largest compressed unit a layout may declare: a unit is decoded whole +/// into the cache, which has to hold a few of them. +pub(crate) const MAX_PACKED_UNIT: u64 = (CACHE_BYTES / 2) as u64; + +/// Carried by the error a read fails with when the disk has decoded its +/// allowance. +const OVER_ALLOWANCE: &str = "exav:virtual-disk-decode-allowance"; + +/// Where a unit of the disk is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Map { + /// Not stored: reads as zeros. + Zero, + /// Stored as it reads, from this offset in the file, wholly inside it. + Plain(u64), + /// Compressed: `stored` bytes at `at`, fewer than the container says when + /// the file ends first (`cut`). + Packed { at: u64, stored: u64, cut: bool }, +} + +/// A compressed unit as far as it decoded. +pub(crate) struct Unpacked { + pub(crate) data: Vec<u8>, + /// Compressed bytes of the unit were left undecoded, or it decoded to more + /// than the unit holds: what was read from it may not be the disk's. + pub(crate) damaged: bool, +} + +/// What a container format says of its disk. +pub(crate) trait Layout { + /// The member the disk is handed over as, when it is handed over whole. + fn name(&self) -> &'static str; + + /// The disk's size in bytes. + fn size(&self) -> u64; + + /// Bytes per unit, never zero. + fn unit(&self) -> u64; + + /// Where unit `idx` is. A unit whose table entry cannot be read, or whose + /// stored bytes are not all in the file, is [`Map::Zero`]: the file is + /// short, so those bytes are absent, not hidden. + fn map(&self, idx: u64) -> Map; + + /// Decode unit `idx`, `out` bytes long, from its [`Map::Packed`] bytes. + fn unpack(&self, _idx: u64, _at: u64, _stored: u64, _cut: bool, _out: usize) -> Unpacked { + Unpacked { + data: Vec::new(), + damaged: true, + } + } + + /// What the walk found that the member alone would not say: a size and a + /// reason for each, reported ahead of it. + fn reports(&self, _health: &Health) -> Vec<(u64, &'static str)> { + Vec::new() + } + + /// Why the disk is not worth handing over: the reason replaces it. + /// `whole`: every unit was read. + fn unusable(&self, _health: &Health, _whole: bool) -> Option<&'static str> { + None + } +} + +/// What reads of a disk found, shared with the walker that reads it. +pub(crate) struct Health { + /// Compressed units decoded with a fault, counted per decode. + undecodable: Cell<u64>, + /// Compressed units that gave any bytes, damaged or not. + produced: Cell<u64>, + /// Bytes decoded so far, and the most the disk may decode. + decoded: Cell<u64>, + allowance: u64, + over: Cell<bool>, + /// The most the cache held at once. + cache_peak: Cell<usize>, +} + +impl Health { + fn new(allowance: u64) -> Rc<Self> { + Rc::new(Health { + undecodable: Cell::new(0), + produced: Cell::new(0), + decoded: Cell::new(0), + allowance, + over: Cell::new(false), + cache_peak: Cell::new(0), + }) + } + + /// Units reached whose compressed bytes would not decode. + pub(crate) fn undecodable(&self) -> u64 { + self.undecodable.get() + } + + /// Units that gave any bytes: a unit damaged part way still gave what + /// decoded before the damage. + pub(crate) fn produced(&self) -> u64 { + self.produced.get() + } + + /// Bytes decoded so far. + pub(crate) fn decoded(&self) -> u64 { + self.decoded.get() + } + + /// The most bytes of decoded units the cache held at once. + #[cfg(test)] + pub(crate) fn cache_peak(&self) -> usize { + self.cache_peak.get() + } + + /// A read was refused for decoding more than the disk may. + pub(crate) fn over_allowance(&self) -> bool { + self.over.get() + } + + fn charge(&self, n: u64) -> io::Result<()> { + let total = self.decoded.get().saturating_add(n); + if total > self.allowance { + self.over.set(true); + return Err(io::Error::other(OVER_ALLOWANCE)); + } + self.decoded.set(total); + Ok(()) + } +} + +/// Decoded units by index, the least recently used evicted past `cap` bytes. +struct Cache { + units: HashMap<u64, (Vec<u8>, u64)>, + /// Tick of last use to unit. + order: BTreeMap<u64, u64>, + bytes: usize, + tick: u64, + cap: usize, +} + +impl Cache { + fn new(cap: usize) -> Self { + Cache { + units: HashMap::new(), + order: BTreeMap::new(), + bytes: 0, + tick: 0, + cap, + } + } + + /// Mark `idx` used. Whether it is held. + fn touch(&mut self, idx: u64) -> bool { + let Some((_, used)) = self.units.get_mut(&idx) else { + return false; + }; + self.order.remove(used); + self.tick = self.tick.saturating_add(1); + *used = self.tick; + self.order.insert(self.tick, idx); + true + } + + fn get(&self, idx: u64) -> Option<&[u8]> { + self.units.get(&idx).map(|(d, _)| d.as_slice()) + } + + fn put(&mut self, idx: u64, data: Vec<u8>) { + while self.bytes.saturating_add(data.len()) > self.cap { + let Some((_, old)) = self.order.pop_first() else { + break; + }; + if let Some((d, _)) = self.units.remove(&old) { + self.bytes = self.bytes.saturating_sub(d.len()); + } + } + self.bytes = self.bytes.saturating_add(data.len()); + self.tick = self.tick.saturating_add(1); + self.order.insert(self.tick, idx); + if let Some((old, used)) = self.units.insert(idx, (data, self.tick)) { + self.order.remove(&used); + self.bytes = self.bytes.saturating_sub(old.len()); + } + } +} + +/// `len` bytes at `off` of `src`, or `None` when they are not all in it. +pub(crate) fn bytes_at(src: &dyn ByteSource, off: u64, len: usize) -> Option<Cow<'_, [u8]>> { + let off = usize::try_from(off).ok()?; + if off.checked_add(len)? > src.len() { + return None; + } + let w = src.window(off, len); + (w.len() == len).then_some(w) +} + +/// A big-endian `u32` at `off`. +pub(crate) fn be32(src: &dyn ByteSource, off: u64) -> Option<u32> { + let b = bytes_at(src, off, 4)?; + Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +/// A big-endian `u64` at `off`. +pub(crate) fn be64(src: &dyn ByteSource, off: u64) -> Option<u64> { + let b = bytes_at(src, off, 8)?; + Some(u64::from_be_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) +} + +/// A little-endian `u32` at `off`. +pub(crate) fn le32(src: &dyn ByteSource, off: u64) -> Option<u32> { + let b = bytes_at(src, off, 4)?; + Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) +} + +/// A little-endian `u64` at `off`. +pub(crate) fn le64(src: &dyn ByteSource, off: u64) -> Option<u64> { + let b = bytes_at(src, off, 8)?; + Some(u64::from_le_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) +} + +/// A virtual disk, as a `Read + Seek`. +pub(crate) struct VDisk<'a, L: Layout> { + src: &'a dyn ByteSource, + layout: L, + pos: u64, + health: Rc<Health>, + cache: Cache, + /// The unit the last read was in, so a filesystem reading it sector by + /// sector looks it up once. + last: Option<(u64, Map)>, +} + +impl<'a, L: Layout> VDisk<'a, L> { + /// A disk that may decode `allowance` bytes in all. + pub(crate) fn new(src: &'a dyn ByteSource, layout: L, allowance: u64) -> Self { + VDisk { + src, + layout, + pos: 0, + health: Health::new(allowance), + cache: Cache::new(CACHE_BYTES), + last: None, + } + } + + pub(crate) fn size(&self) -> u64 { + self.layout.size() + } + + pub(crate) fn health(&self) -> Rc<Health> { + self.health.clone() + } + + fn lookup(&mut self, idx: u64) -> Map { + if let Some((i, m)) = self.last + && i == idx + { + return m; + } + let m = self.layout.map(idx); + self.last = Some((idx, m)); + m + } + + /// Unit `idx` decoded, `out` bytes long when whole. + fn unpacked( + &mut self, + idx: u64, + (at, stored, cut): (u64, u64, bool), + out: usize, + ) -> io::Result<&[u8]> { + if !self.cache.touch(idx) { + self.health.charge(out as u64)?; + let u = self.layout.unpack(idx, at, stored, cut, out); + let h = &self.health; + if u.damaged { + h.undecodable.set(h.undecodable.get().saturating_add(1)); + } + if !u.data.is_empty() { + h.produced.set(h.produced.get().saturating_add(1)); + } + self.cache.put(idx, u.data); + let peak = self.health.cache_peak.get().max(self.cache.bytes); + self.health.cache_peak.set(peak); + } + Ok(self.cache.get(idx).unwrap_or(&[])) + } +} + +impl<L: Layout> Read for VDisk<'_, L> { + fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { + let size = self.layout.size(); + if buf.is_empty() || self.pos >= size { + return Ok(0); + } + let unit = self.layout.unit().max(1); + let idx = self.pos.checked_div(unit).unwrap_or(0); + let into = self.pos.checked_rem(unit).unwrap_or(0); + let unit_len = unit.min(size.saturating_sub(idx.saturating_mul(unit))); + let room = unit_len.saturating_sub(into); + let want = usize::try_from(room).map_or(buf.len(), |r| r.min(buf.len())); + let buf = &mut buf[..want]; + match self.lookup(idx) { + Map::Zero => buf.fill(0), + Map::Plain(at) => { + let from = at.saturating_add(into); + let w = bytes_at(self.src, from, want).ok_or_else(|| { + io::Error::new( + io::ErrorKind::UnexpectedEof, + self.src + .read_error() + .unwrap_or_else(|| "the image ended inside a stored unit".into()), + ) + })?; + buf.copy_from_slice(&w); + } + Map::Packed { at, stored, cut } => { + let out = usize::try_from(unit_len).unwrap_or(usize::MAX); + let skip = usize::try_from(into).unwrap_or(usize::MAX); + let data = self.unpacked(idx, (at, stored, cut), out)?; + let have = data.len().saturating_sub(skip).min(want); + if let Some(s) = data.get(skip..skip.saturating_add(have)) { + buf[..have].copy_from_slice(s); + } + // A unit that decoded short reads as zeros past what decoded. + buf[have..].fill(0); + } + } + self.pos = self.pos.saturating_add(want as u64); + Ok(want) + } +} + +impl<L: Layout> Seek for VDisk<'_, L> { + fn seek(&mut self, to: SeekFrom) -> io::Result<u64> { + let pos = match to { + SeekFrom::Start(p) => Some(p), + SeekFrom::End(d) => self.layout.size().checked_add_signed(d), + SeekFrom::Current(d) => self.pos.checked_add_signed(d), + } + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek outside the disk"))?; + self.pos = pos; + Ok(pos) + } +} + +/// The stop for a disk that decoded more than its allowance. +fn over_allowance(name: &str) -> LimitHit { + LimitHit::of_kind( + LimitKind::MaxScanSize, + format!("{name}: the bytes decoded to read the disk exceed the scan budget"), + ) +} + +/// Hand `disk` to the visitor. See the module note for the two ways. +pub(crate) fn walk<L: Layout, T>( + mut disk: VDisk<'_, L>, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + budget.count_entry()?; + let cap = budget.reserve()?; + let size = disk.size(); + let name = disk.layout.name(); + let health = disk.health(); + match usize::try_from(size) { + Ok(n) if size <= cap => { + let mut data = Vec::with_capacity(crate::cap_prealloc(n)); + if let Err(e) = (&mut disk).take(size).read_to_end(&mut data) { + if health.over_allowance() { + return Err(over_allowance(name)); + } + return Err(LimitHit::corrupt(format!("{name}: {e}"))); + } + if let Some(why) = disk.layout.unusable(&health, true) { + return report(name, size, why, budget, visit); + } + for (n, why) in disk.layout.reports(&health) { + if let Some(t) = report(name, n, why, budget, &mut *visit)? { + return Ok(Some(t)); + } + } + budget.commit(data.len() as u64); + crate::stream::emit_entry(Entry::new(name.to_string(), data), budget, visit) + } + _ => { + let found = volumes::walk_disk(&mut disk, volumes::Mode::Virtual, budget, &mut *visit); + budget.charge_scan(health.decoded())?; + // A walk that failed because the disk ran out of what it may + // decode failed for that, whatever it was reading. + match found { + Ok(Some(t)) => return Ok(Some(t)), + Err(_) if health.over_allowance() => return Err(over_allowance(name)), + Err(e) => return Err(e), + Ok(None) if health.over_allowance() => return Err(over_allowance(name)), + Ok(None) => {} + } + if let Some(why) = disk.layout.unusable(&health, false) { + return report(name, size, why, budget, visit); + } + for (n, why) in disk.layout.reports(&health) { + if let Some(t) = report(name, n, why, budget, &mut *visit)? { + return Ok(Some(t)); + } + } + Ok(None) + } + } +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +pub(crate) mod tests { + use super::*; + + /// A layout of `units` units of `unit` bytes where the test says what each is. + pub(crate) struct Table<'a> { + pub(crate) src: &'a [u8], + pub(crate) unit: u64, + pub(crate) size: u64, + pub(crate) maps: Vec<Map>, + pub(crate) decodes: Rc<Cell<u64>>, + } + + impl Layout for Table<'_> { + fn name(&self) -> &'static str { + "test-disk" + } + fn size(&self) -> u64 { + self.size + } + fn unit(&self) -> u64 { + self.unit + } + fn map(&self, idx: u64) -> Map { + usize::try_from(idx) + .ok() + .and_then(|i| self.maps.get(i).copied()) + .unwrap_or(Map::Zero) + } + /// The unit's stored bytes, backwards. + fn unpack(&self, _idx: u64, at: u64, stored: u64, _cut: bool, out: usize) -> Unpacked { + self.decodes.set(self.decodes.get() + 1); + let at = at as usize; + let mut d: Vec<u8> = self.src[at..at + stored as usize].to_vec(); + d.reverse(); + d.resize(out, 0); + Unpacked { + data: d, + damaged: false, + } + } + } + + fn read_all<L: Layout>(d: &mut VDisk<'_, L>) -> Vec<u8> { + let mut out = Vec::new(); + d.seek(SeekFrom::Start(0)).unwrap(); + d.read_to_end(&mut out).unwrap(); + out + } + + /// Four units of 8 bytes: stored, absent, compressed, stored past the end. + fn disk_of<'a>(file: &'a [u8], decodes: &Rc<Cell<u64>>) -> Table<'a> { + Table { + src: file, + unit: 8, + size: 30, + maps: vec![ + Map::Plain(0), + Map::Zero, + Map::Packed { + at: 8, + stored: 8, + cut: false, + }, + Map::Plain(8), + ], + decodes: decodes.clone(), + } + } + + #[test] + fn units_read_as_their_map_says_and_the_last_one_is_short() { + let file: Vec<u8> = (1..=16).collect(); + let decodes = Rc::new(Cell::new(0)); + let mut d = VDisk::new(&file, disk_of(&file, &decodes), u64::MAX); + let all = read_all(&mut d); + let mut want = Vec::new(); + want.extend_from_slice(&file[..8]); + want.extend_from_slice(&[0; 8]); + want.extend(file[8..16].iter().rev()); + want.extend_from_slice(&file[8..14]); + assert_eq!(all, want); + assert_eq!(d.size(), 30); + } + + #[test] + fn reads_that_cross_units_and_seeks_agree_with_the_whole() { + let file: Vec<u8> = (1..=16).collect(); + let decodes = Rc::new(Cell::new(0)); + let mut d = VDisk::new(&file, disk_of(&file, &decodes), u64::MAX); + let all = read_all(&mut d); + for (at, n) in [(0u64, 5usize), (6, 9), (15, 3), (23, 7), (29, 1)] { + d.seek(SeekFrom::Start(at)).unwrap(); + let mut got = vec![0u8; n]; + d.read_exact(&mut got).unwrap(); + assert_eq!(got, all[at as usize..at as usize + n], "({at}, {n})"); + } + // End and Current seeks; reading at the end is empty, past it too. + assert_eq!(d.seek(SeekFrom::End(-2)).unwrap(), 28); + assert_eq!(d.seek(SeekFrom::Current(-1)).unwrap(), 27); + assert!(d.seek(SeekFrom::Current(-100)).is_err()); + d.seek(SeekFrom::Start(30)).unwrap(); + assert_eq!(d.read(&mut [0u8; 4]).unwrap(), 0); + d.seek(SeekFrom::Start(u64::MAX)).unwrap(); + assert_eq!(d.read(&mut [0u8; 4]).unwrap(), 0); + assert!(d.seek(SeekFrom::Current(1)).is_err()); + } + + /// Decoded units are kept while the cache has room for them. + #[test] + fn a_decoded_unit_is_decoded_once_while_it_is_cached() { + let file: Vec<u8> = (1..=16).collect(); + let decodes = Rc::new(Cell::new(0)); + let mut d = VDisk::new(&file, disk_of(&file, &decodes), u64::MAX); + for _ in 0..3 { + d.seek(SeekFrom::Start(16)).unwrap(); + d.read_exact(&mut [0u8; 8]).unwrap(); + } + assert_eq!(decodes.get(), 1); + assert_eq!(d.health().produced(), 1); + } + + #[test] + fn cache_evicts_the_least_recently_used_and_decodes_again_to_the_same_bytes() { + let mut c = Cache::new(20); + c.put(1, vec![1; 8]); + c.put(2, vec![2; 8]); + assert!(c.touch(1)); + // 3 does not fit beside both: 2, the least recently used, goes. + c.put(3, vec![3; 8]); + assert!(c.get(1).is_some() && c.get(2).is_none() && c.get(3).is_some()); + assert!(c.bytes <= 20); + // A unit put again replaces itself without double counting. + c.put(3, vec![9; 4]); + assert_eq!(c.get(3), Some(&[9u8; 4][..])); + assert_eq!(c.bytes, 12); + assert!(!c.touch(2)); + } + + /// Reading A, then enough other units to evict it, then A again gives A. + #[test] + fn a_unit_read_again_after_eviction_is_the_same() { + // 64 packed units of 1 MiB, each its own pattern; a cache of 32 MiB + // holds 32 of them. + let unit = 1usize << 20; + let file: Vec<u8> = (0..64u32 * 1024).map(|i| (i % 251) as u8).collect(); + let decodes = Rc::new(Cell::new(0)); + let maps: Vec<Map> = (0..64) + .map(|i| Map::Packed { + at: (i % 8) * 8192, + stored: 8192, + cut: false, + }) + .collect(); + let layout = Table { + src: &file, + unit: unit as u64, + size: 64 * unit as u64, + maps, + decodes: decodes.clone(), + }; + let mut d = VDisk::new(&file, layout, u64::MAX); + let read = |d: &mut VDisk<'_, Table<'_>>, at: u64| { + d.seek(SeekFrom::Start(at)).unwrap(); + let mut b = vec![0u8; 8192]; + d.read_exact(&mut b).unwrap(); + b + }; + let a = read(&mut d, 0); + // Walk through 40 more units: A is evicted. + for i in 1..41u64 { + read(&mut d, i * unit as u64); + } + let before = decodes.get(); + assert_eq!(read(&mut d, 0), a); + assert_eq!(decodes.get(), before + 1, "A was decoded again"); + assert!(d.health().cache_peak() <= CACHE_BYTES + 8192); + } + + #[test] + fn decoding_past_the_allowance_stops_the_read_and_says_so() { + let file: Vec<u8> = (1..=16).collect(); + let decodes = Rc::new(Cell::new(0)); + let mut d = VDisk::new(&file, disk_of(&file, &decodes), 4); + d.seek(SeekFrom::Start(16)).unwrap(); + let e = d.read_exact(&mut [0u8; 8]).unwrap_err(); + assert_eq!(e.to_string(), OVER_ALLOWANCE); + assert!(d.health().over_allowance()); + assert_eq!(decodes.get(), 0, "nothing was decoded past the allowance"); + } + + /// A plain unit the source cannot deliver is an error, not zeros. + #[test] + fn a_stored_unit_the_source_lost_is_an_error() { + let file: Vec<u8> = (1..=4).collect(); + let layout = Table { + src: &file, + unit: 8, + size: 8, + maps: vec![Map::Plain(0)], + decodes: Rc::new(Cell::new(0)), + }; + let mut d = VDisk::new(&file, layout, u64::MAX); + let e = d.read_exact(&mut [0u8; 8]).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::UnexpectedEof); + } +} diff --git a/crates/exav-unpack/src/formats/vhd.rs b/crates/exav-unpack/src/formats/vhd.rs index 049f1f89..f5ba82a4 100644 --- a/crates/exav-unpack/src/formats/vhd.rs +++ b/crates/exav-unpack/src/formats/vhd.rs @@ -10,33 +10,38 @@ //! Format Specification* (the VHD footer / dynamic-disk header layout is public). //! All multi-byte fields are **big-endian**. //! -//! Two of the three disk types are reconstructed: +//! Two of the three disk types are read: //! //! * **Fixed** (`disk_type == 2`) — the file *is* the raw disk, followed by a -//! 512-byte footer. Extraction is a slice. +//! 512-byte footer. //! * **Dynamic** (`disk_type == 3`) — a Block Allocation Table maps each block //! of the virtual disk to a location in the file, or marks it unallocated. -//! Reconstruct the disk by walking the BAT; unallocated blocks read as zeroes, -//! exactly as they do when Windows mounts the image. +//! Unallocated blocks read as zeroes, exactly as they do when Windows mounts +//! the image. The table is read from the source as blocks are reached, never +//! held. //! -//! **Differencing** (`disk_type == 4`) images are *not* reconstructed: they hold +//! **Differencing** (`disk_type == 4`) images are *not* read: they hold //! only the delta against a parent `.vhd` that is not in this file, so the disk //! cannot be assembled from what we have. That is reported, never passed over — -//! see [`extract_vhd`]. +//! see [`walk`]. //! -//! The reconstructed disk is emitted as a single member, which the partition and -//! filesystem handlers then pick up in the normal way. +//! The disk is a [`VDisk`]: one member when it fits the buffer limit, else its +//! filesystems are walked in place (see `vdisk.rs`). // Every sum and product on a header's number is checked or saturating here; a // plain one fails the build, so the next edit cannot add the unchecked kind. #![deny(clippy::arithmetic_side_effects)] -use crate::{Budget, Entry, LimitHit, Sink}; +use super::vdisk::{self, Layout, Map, VDisk, be32, bytes_at}; +use super::volumes::report; +use crate::source::ByteSource; +use crate::stream::Visit; +use crate::{Budget, LimitHit}; -const FOOTER_LEN: usize = 512; +const FOOTER_LEN: u64 = 512; const FOOTER_COOKIE: &[u8; 8] = b"conectix"; const DYN_COOKIE: &[u8; 8] = b"cxsparse"; -const SECTOR: usize = 512; +const SECTOR: u64 = 512; /// Sentinel BAT entry meaning "this block has never been written". const BAT_UNUSED: u32 = 0xFFFF_FFFF; @@ -45,6 +50,8 @@ const DISK_TYPE_FIXED: u32 = 2; const DISK_TYPE_DYNAMIC: u32 = 3; const DISK_TYPE_DIFFERENCING: u32 = 4; +const NAME: &str = "vhd-disk"; + fn be_u32(d: &[u8], off: usize) -> u32 { crate::bytes::at(d, off, 4) .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])) @@ -59,226 +66,300 @@ fn be_u64(d: &[u8], off: usize) -> u64 { /// Locate the authoritative footer: the trailing copy, falling back to the /// leading one that dynamic and differencing images also keep at offset 0. -fn footer(data: &[u8]) -> Option<&[u8]> { - let tail = data.get(data.len().checked_sub(FOOTER_LEN)?..)?; - if tail.starts_with(FOOTER_COOKIE) { - return Some(tail); +fn footer(src: &dyn ByteSource) -> Option<Vec<u8>> { + let len = src.len() as u64; + if let Some(tail) = len + .checked_sub(FOOTER_LEN) + .and_then(|at| bytes_at(src, at, FOOTER_LEN as usize)) + && tail.starts_with(FOOTER_COOKIE) + { + return Some(tail.into_owned()); + } + let head = bytes_at(src, 0, FOOTER_LEN as usize)?; + head.starts_with(FOOTER_COOKIE).then(|| head.into_owned()) +} + +/// A fixed disk: the file up to its footer. +struct Flat { + size: u64, +} + +impl Layout for Flat { + fn name(&self) -> &'static str { + NAME + } + + fn size(&self) -> u64 { + self.size } - let head = data.get(..FOOTER_LEN)?; - head.starts_with(FOOTER_COOKIE).then_some(head) + + fn unit(&self) -> u64 { + 1 << 20 + } + + fn map(&self, idx: u64) -> Map { + Map::Plain(idx.saturating_mul(self.unit())) + } +} + +/// A dynamic disk: blocks placed by the Block Allocation Table. +struct Dynamic<'a> { + src: &'a dyn ByteSource, + bat: u64, + /// The virtual disk's size: the footer's, if the table covers it. + size: u64, + block: u64, + /// The sector bitmap ahead of each block, padded to a sector. + bitmap: u64, + file_len: u64, } -pub(crate) fn extract_vhd<R>( - data: &[u8], +impl Dynamic<'_> { + /// Where block `idx`, whose table entry is `ent`, is in the file, when + /// all of it is there. + fn place(&self, ent: u32, idx: u64) -> Option<u64> { + let host = u64::from(ent) + .saturating_mul(SECTOR) + .saturating_add(self.bitmap); + let n = self + .block + .min(self.size.saturating_sub(idx.saturating_mul(self.block))); + host.checked_add(n) + .is_some_and(|end| end <= self.file_len) + .then_some(host) + } + + /// Whether the table allocates blocks and every one of them is outside + /// the file: not a truncated image but one that was not read at all. + fn all_outside(&self) -> bool { + const STEP: u64 = 16 * 1024; + let blocks = self.size.div_ceil(self.block); + let (mut allocated, mut missing) = (0u64, 0u64); + let mut i = 0u64; + while i < blocks { + let n = blocks.saturating_sub(i).min(STEP); + let at = self.bat.saturating_add(i.saturating_mul(4)); + let Some(chunk) = bytes_at( + self.src, + at, + usize::try_from(n).unwrap_or(0).saturating_mul(4), + ) else { + break; + }; + for (k, e) in chunk.as_chunks::<4>().0.iter().enumerate() { + let ent = u32::from_be_bytes(*e); + if ent == BAT_UNUSED { + continue; + } + allocated = allocated.saturating_add(1); + let idx = i.saturating_add(k as u64); + if self.place(ent, idx).is_none() { + missing = missing.saturating_add(1); + } + } + i = i.saturating_add(n); + } + allocated > 0 && missing == allocated + } +} + +impl Layout for Dynamic<'_> { + fn name(&self) -> &'static str { + NAME + } + + fn size(&self) -> u64 { + self.size + } + + fn unit(&self) -> u64 { + self.block + } + + fn map(&self, idx: u64) -> Map { + let Some(ent) = be32(self.src, self.bat.saturating_add(idx.saturating_mul(4))) else { + return Map::Zero; + }; + if ent == BAT_UNUSED { + return Map::Zero; // never written: reads as zeroes when mounted, as here + } + // A BAT that points at a block outside the file is a truncated image: + // those bytes are absent rather than hidden, so they stay zero. + self.place(ent, idx).map_or(Map::Zero, Map::Plain) + } +} + +/// Walk a VHD's disk. +pub(crate) fn walk<T>( + src: &dyn ByteSource, budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - let Some(f) = footer(data) else { + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let Some(f) = footer(src) else { return Ok(None); }; // Footer layout: +16 data offset (u64), +48 current size (u64), // +60 disk type (u32). - let disk_type = be_u32(f, 60); - let current_size = be_u48_size(f); + let disk_type = be_u32(&f, 60); + let current_size = be_u64(&f, 48); + let allowance = budget.remaining_scan(); match disk_type { DISK_TYPE_FIXED => { // The disk is everything before the trailing footer. - let end = data.len().saturating_sub(FOOTER_LEN); - emit(data.get(..end).unwrap_or(&[]), "vhd-disk", budget, visit) + let size = (src.len() as u64).saturating_sub(FOOTER_LEN); + if size == 0 { + return Ok(None); + } + vdisk::walk(VDisk::new(src, Flat { size }, allowance), budget, visit) } - DISK_TYPE_DYNAMIC => extract_dynamic(data, f, current_size, budget, visit), + DISK_TYPE_DYNAMIC => dynamic(src, &f, current_size, budget, visit), DISK_TYPE_DIFFERENCING => { // The payload lives in a parent image this file does not contain, so // the disk cannot be assembled here at all. That is content // present-elsewhere-but-unreadable, not content absent: say so. - budget.count_entry()?; - Ok(visit( - Entry::unsupported( - "vhd-disk".to_string(), - current_size, - false, - "differencing VHD: the disk is a delta against a parent image not in this file", - ), - budget, - )) - } - other => { - budget.count_entry()?; - Ok(visit( - Entry::unsupported( - "vhd-disk".to_string(), - current_size, - false, - match other { - 0 | 1 => "VHD marked as no-disk/reserved", - _ => "unsupported VHD disk type", - }, - ), + report( + NAME, + current_size, + "differencing VHD: the disk is a delta against a parent image not in this file", budget, - )) + visit, + ) } + other => report( + NAME, + current_size, + match other { + 0 | 1 => "VHD marked as no-disk/reserved", + _ => "unsupported VHD disk type", + }, + budget, + visit, + ), } } -/// `current size` from the footer (+48), the virtual disk's byte length. -fn be_u48_size(f: &[u8]) -> u64 { - be_u64(f, 48) -} - -/// Reassemble a dynamic VHD from its Block Allocation Table. -fn extract_dynamic<R>( - data: &[u8], +/// Read a dynamic VHD through its Block Allocation Table. +fn dynamic<T>( + src: &dyn ByteSource, f: &[u8], current_size: u64, budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - let dyn_off = crate::bytes::to_usize(be_u64(f, 16)); - let Some(hdr) = crate::bytes::at(data, dyn_off, 1024) else { - return unreadable( - "dynamic VHD header lies outside the image", + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let file_len = src.len() as u64; + let Some(hdr) = bytes_at(src, be_u64(f, 16), 1024) else { + return report( + NAME, current_size, + "dynamic VHD header lies outside the image", budget, visit, ); }; if !hdr.starts_with(DYN_COOKIE) { - return unreadable( - "dynamic VHD header is missing or corrupt", + return report( + NAME, current_size, + "dynamic VHD header is missing or corrupt", budget, visit, ); } // Dynamic header: +16 BAT offset (u64), +28 max table entries (u32), // +32 block size (u32). - let bat_off = crate::bytes::to_usize(be_u64(hdr, 16)); - let max_entries = be_u32(hdr, 28) as usize; - let block_size = be_u32(hdr, 32) as usize; + let bat = be_u64(&hdr, 16); + let max_entries = u64::from(be_u32(&hdr, 28)); + let block = u64::from(be_u32(&hdr, 32)); // A block size that is not a sane power-of-two multiple of the sector size // would make the sector bitmap length meaningless. - if block_size == 0 - || !block_size.is_multiple_of(SECTOR) - || !block_size.is_power_of_two() - || max_entries == 0 - { - return unreadable( - "dynamic VHD geometry is implausible", + if block == 0 || !block.is_multiple_of(SECTOR) || !block.is_power_of_two() || max_entries == 0 { + return report( + NAME, current_size, + "dynamic VHD geometry is implausible", budget, visit, ); } // Each block is preceded by a sector bitmap, padded up to a sector boundary. - let bitmap_len = ((block_size / SECTOR).div_ceil(8)).next_multiple_of(SECTOR); - - let Some(bat) = crate::bytes::at(data, bat_off, max_entries.saturating_mul(4)) else { - return unreadable( - "dynamic VHD block allocation table lies outside the image", + let bitmap = ((block / SECTOR).div_ceil(8)).next_multiple_of(SECTOR); + // The table is read as blocks are reached, but it has to be in the file. + if !bat + .checked_add(max_entries.saturating_mul(4)) + .is_some_and(|end| end <= file_len) + { + return report( + NAME, current_size, + "dynamic VHD block allocation table lies outside the image", budget, visit, ); - }; - - // Reserve the whole reconstructed disk up front: a dynamic VHD can declare a - // very large virtual size from a small file, which is a decompression bomb by - // another name. - budget.count_entry()?; - let cap = budget.reserve()?; - let want = current_size.min((max_entries as u64).saturating_mul(block_size as u64)); - if want > cap { - return Ok(visit( - Entry::unsupported( - "vhd-disk".to_string(), - want, - false, - "dynamic VHD exceeds the per-member size budget", - ), - budget, - )); - } - - let mut disk = vec![0u8; want as usize]; - let mut missing = 0usize; - for i in 0..max_entries { - let ent = be_u32(bat, i.saturating_mul(4)); - if ent == BAT_UNUSED { - continue; // never written: reads as zeroes when mounted, as here - } - let src = (ent as usize) - .saturating_mul(SECTOR) - .saturating_add(bitmap_len); - let dst = i.saturating_mul(block_size); - if dst >= disk.len() { - break; - } - let n = block_size.min(disk.len().saturating_sub(dst)); - match crate::bytes::at(data, src, n) { - Some(block) => disk[dst..dst.saturating_add(n)].copy_from_slice(block), - // The BAT points at a block outside the file: the image is truncated, - // so those bytes are absent rather than hidden (they stay zero). - // Counted so a wholly bogus table can be told apart from a healthy - // one below. - None => missing = missing.saturating_add(1), - } } + let layout = Dynamic { + src, + bat, + size: current_size.min(max_entries.saturating_mul(block)), + block, + bitmap, + file_len, + }; // A BAT whose every allocated entry points outside the file is not a // truncated image, it is one exav failed to read at all. - let allocated = (0..max_entries) - .filter(|&i| be_u32(bat, i.saturating_mul(4)) != BAT_UNUSED) - .count(); - if allocated > 0 && missing == allocated { - return Ok(visit( - Entry::unsupported( - "vhd-disk".to_string(), - current_size, - false, - "dynamic VHD block table points entirely outside the image", - ), + if layout.all_outside() { + return report( + NAME, + current_size, + "dynamic VHD block table points entirely outside the image", budget, - )); + visit, + ); } - budget.commit(disk.len() as u64); - Ok(visit(Entry::new("vhd-disk".to_string(), disk), budget)) + let allowance = budget.remaining_scan(); + vdisk::walk(VDisk::new(src, layout, allowance), budget, visit) } -fn unreadable<R>( - reason: &'static str, - size: u64, - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - budget.count_entry()?; - Ok(visit( - Entry::unsupported("vhd-disk".to_string(), size, false, reason), - budget, - )) -} +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::*; -fn emit<R>( - slice: &[u8], - name: &str, - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - if slice.is_empty() { - return Ok(None); + /// Whether a table of `ents`, over a file of `file_len` bytes, in blocks of + /// 1 KiB each preceded by a one-sector bitmap, is outside the file. + fn outside(ents: &[u32], file_len: u64) -> bool { + let bat: Vec<u8> = ents.iter().flat_map(|e| e.to_be_bytes()).collect(); + let src = bat.as_slice(); + let l = Dynamic { + src: &src, + bat: 0, + size: ents.len() as u64 * 1024, + block: 1024, + bitmap: 512, + file_len, + }; + l.all_outside() } - budget.count_entry()?; - let cap = budget.reserve()?; - if slice.len() as u64 > cap { - return Ok(visit( - Entry::unsupported( - name.to_string(), - slice.len() as u64, - false, - "VHD disk exceeds the per-member size budget", - ), - budget, - )); + + #[test] + fn a_table_is_outside_the_file_only_if_every_allocated_block_is() { + // Sector 100 is at byte 51200: a block there ends at 51200 + 512 + 1024. + assert!(outside(&[100, 200], 40_000)); + assert!(!outside(&[100, 200], 60_000)); + // One block in, one out: a truncated image, not an unread one. + assert!(!outside(&[10, 200], 8000)); + // Nothing allocated is not "everything outside". + assert!(!outside(&[BAT_UNUSED, BAT_UNUSED], 100)); + // A block cut by the end of the file is not wholly in it. + assert!(outside(&[100], 51_200 + 512 + 1023)); + assert!(!outside(&[100], 51_200 + 512 + 1024)); + } + + /// A table entry at the top of the range names a block that is not there, + /// and the arithmetic placing it does not wrap. + #[test] + fn an_entry_at_the_top_of_the_range_is_outside() { + assert!(outside(&[u32::MAX - 1], 1 << 40)); } - budget.commit(slice.len() as u64); - Ok(visit(Entry::new(name.to_string(), slice.to_vec()), budget)) } diff --git a/crates/exav-unpack/src/formats/vhdx.rs b/crates/exav-unpack/src/formats/vhdx.rs index 96ef6e6a..4a6ed7c1 100644 --- a/crates/exav-unpack/src/formats/vhdx.rs +++ b/crates/exav-unpack/src/formats/vhdx.rs @@ -10,25 +10,30 @@ //! Windows uses (first three components byte-swapped). //! //! Payload blocks are stored uncompressed, so unlike QCOW2 the bytes are -//! visible to a raw scan wherever they happen to land. Reconstruction still +//! visible to a raw scan wherever they happen to land. Reading the disk still //! matters: blocks are placed in arbitrary file order and sparse ranges are //! absent entirely, so a payload spanning a block boundary appears in the file //! only as two disjoint fragments — and a filesystem inside cannot be walked at //! all until the guest disk is put back in order. //! -//! The reconstructed disk is emitted as a single member for the partition and -//! filesystem handlers to pick up. +//! The disk is a [`VDisk`]: one member when it fits the buffer limit, else its +//! filesystems are walked in place (see `vdisk.rs`). The block allocation table +//! is read from the source as blocks are reached. // Every sum and product on a header's number is checked or saturating here; a // plain one fails the build, so the next edit cannot add the unchecked kind. #![deny(clippy::arithmetic_side_effects)] -use crate::{Budget, Entry, LimitHit, Sink}; +use super::vdisk::{self, Health, Layout, Map, VDisk, bytes_at, le32, le64}; +use super::volumes::report; +use crate::source::ByteSource; +use crate::stream::Visit; +use crate::{Budget, LimitHit}; /// The two region tables, at 192 KiB and 256 KiB. The second is a mirror, read /// when the first does not parse. -const REGION_TABLE_1: usize = 192 * 1024; -const REGION_TABLE_2: usize = 256 * 1024; +const REGION_TABLE_1: u64 = 192 * 1024; +const REGION_TABLE_2: u64 = 256 * 1024; const REGION_SIGNATURE: &[u8; 4] = b"regi"; const METADATA_SIGNATURE: &[u8; 8] = b"metadata"; @@ -36,6 +41,8 @@ const METADATA_SIGNATURE: &[u8; 8] = b"metadata"; const MAX_REGION_ENTRIES: u32 = 2047; /// Likewise for metadata items — the table is one 64 KiB region. const MAX_METADATA_ENTRIES: u16 = 2047; +/// Both tables are one 64 KiB region. +const TABLE_LEN: usize = 64 * 1024; /// Region GUIDs, in stored (mixed-endian) byte order. const BAT_REGION: [u8; 16] = [ @@ -65,6 +72,8 @@ const PAYLOAD_BLOCK_PARTIALLY_PRESENT: u64 = 7; /// `FileParameters` flag bit 1: the disk is a delta against a parent image. const HAS_PARENT: u32 = 1 << 1; +const NAME: &str = "vhdx-disk"; + fn le_u16(d: &[u8], off: usize) -> u16 { crate::bytes::at(d, off, 2) .map(|b| u16::from_le_bytes([b[0], b[1]])) @@ -85,31 +94,36 @@ fn le_u64(d: &[u8], off: usize) -> u64 { /// Where a region lives in the file, and how long it is. struct Region { - offset: usize, - length: usize, + offset: u64, + length: u64, } /// Find the BAT and metadata regions in whichever region table parses. -fn regions(data: &[u8]) -> Option<(Region, Region)> { +fn regions(src: &dyn ByteSource) -> Option<(Region, Region)> { for base in [REGION_TABLE_1, REGION_TABLE_2] { - if crate::bytes::at(data, base, 4) != Some(REGION_SIGNATURE.as_slice()) { + let Some(t) = bytes_at(src, base, 16) else { + continue; + }; + if t[..4] != REGION_SIGNATURE[..] { continue; } - let count = le_u32(data, base.saturating_add(8)).min(MAX_REGION_ENTRIES); + let count = le32(src, base.saturating_add(8)) + .unwrap_or(0) + .min(MAX_REGION_ENTRIES); let (mut bat, mut meta) = (None, None); - for i in 0..count as usize { + for i in 0..u64::from(count) { let e = base.saturating_add(16).saturating_add(i.saturating_mul(32)); - let Some(guid) = crate::bytes::at(data, e, 16) else { + // Entry: GUID(16), FileOffset u64, Length u32, Required u32. + let Some(entry) = bytes_at(src, e, 28) else { break; }; - // Entry: GUID(16), FileOffset u64, Length u32, Required u32. let r = Region { - offset: crate::bytes::to_usize(le_u64(data, e.saturating_add(16))), - length: le_u32(data, e.saturating_add(24)) as usize, + offset: le_u64(&entry, 16), + length: u64::from(le_u32(&entry, 24)), }; - if guid == BAT_REGION { + if entry[..16] == BAT_REGION { bat = Some(r); - } else if guid == METADATA_REGION { + } else if entry[..16] == METADATA_REGION { meta = Some(r); } } @@ -120,7 +134,7 @@ fn regions(data: &[u8]) -> Option<(Region, Region)> { None } -/// The three metadata items reconstruction needs. +/// The three metadata items reading the disk needs. struct Geometry { block_size: u32, virtual_size: u64, @@ -128,12 +142,17 @@ struct Geometry { has_parent: bool, } -fn geometry(data: &[u8], meta: &Region) -> Option<Geometry> { +fn geometry(src: &dyn ByteSource, meta: &Region) -> Option<Geometry> { let base = meta.offset; - if crate::bytes::at(data, base, 8) != Some(METADATA_SIGNATURE.as_slice()) { + let table = bytes_at(src, base, TABLE_LEN).or_else(|| { + // A table cut short by the end of the file still has its entries. + let n = (src.len() as u64).saturating_sub(base); + bytes_at(src, base, usize::try_from(n).ok()?.min(TABLE_LEN)) + })?; + if table.get(..8) != Some(METADATA_SIGNATURE.as_slice()) { return None; } - let count = le_u16(data, base.saturating_add(10)).min(MAX_METADATA_ENTRIES); + let count = le_u16(&table, 10).min(MAX_METADATA_ENTRIES); let mut g = Geometry { block_size: 0, virtual_size: 0, @@ -142,22 +161,20 @@ fn geometry(data: &[u8], meta: &Region) -> Option<Geometry> { logical_sector_size: 512, has_parent: false, }; - for i in 0..count as usize { - let Some(e) = base.checked_add(i.saturating_mul(32).saturating_add(32)) else { - break; - }; - let Some(guid) = crate::bytes::at(data, e, 16) else { + for i in 0..usize::from(count) { + let e = i.saturating_mul(32).saturating_add(32); + let Some(guid) = crate::bytes::at(&table, e, 16) else { break; }; // Item offsets are relative to the start of the metadata region. - let item = base.saturating_add(le_u32(data, e.saturating_add(16)) as usize); + let item = base.saturating_add(u64::from(le_u32(&table, e.saturating_add(16)))); if guid == FILE_PARAMETERS { - g.block_size = le_u32(data, item); - g.has_parent = le_u32(data, item.saturating_add(4)) & HAS_PARENT != 0; + g.block_size = le32(src, item).unwrap_or(0); + g.has_parent = le32(src, item.saturating_add(4)).unwrap_or(0) & HAS_PARENT != 0; } else if guid == VIRTUAL_DISK_SIZE { - g.virtual_size = le_u64(data, item); + g.virtual_size = le64(src, item).unwrap_or(0); } else if guid == LOGICAL_SECTOR_SIZE { - let s = le_u32(data, item); + let s = le32(src, item).unwrap_or(0); if s != 0 { g.logical_sector_size = s; } @@ -166,26 +183,130 @@ fn geometry(data: &[u8], meta: &Region) -> Option<Geometry> { (g.block_size != 0 && g.virtual_size != 0).then_some(g) } -pub(crate) fn extract_vhdx<R>( - data: &[u8], +/// A VHDX disk: blocks placed by the Block Allocation Table. +struct Vhdx<'a> { + src: &'a dyn ByteSource, + bat: Region, + block: u64, + size: u64, + /// Payload entries between the sector-bitmap entries the BAT interleaves. + chunk_ratio: u64, + file_len: u64, + /// Blocks past the end of the table the region declares. + unlocatable: u64, +} + +impl Vhdx<'_> { + /// The BAT index of block `idx`: one sector-bitmap entry follows every + /// `chunk_ratio` payload entries, so it is not the block's index on the + /// disk. + fn bat_index(&self, idx: u64) -> u64 { + idx.saturating_add(idx.checked_div(self.chunk_ratio).unwrap_or(0)) + } + + /// Whether the region declares an entry for block `idx`. + fn has_entry(&self, idx: u64) -> bool { + self.bat_index(idx).saturating_mul(8).saturating_add(8) <= self.bat.length + } + + /// How many blocks, from the first, the table has an entry for. + fn covered(&self, blocks: u64) -> u64 { + // The entries needed grow with the index, so the blocks without one + // are a suffix. + let (mut lo, mut hi) = (0u64, blocks); + while lo < hi { + let mid = lo.saturating_add(hi.saturating_sub(lo) / 2); + if self.has_entry(mid) { + lo = mid.saturating_add(1); + } else { + hi = mid; + } + } + lo + } +} + +impl Layout for Vhdx<'_> { + fn name(&self) -> &'static str { + NAME + } + + fn size(&self) -> u64 { + self.size + } + + fn unit(&self) -> u64 { + self.block + } + + fn map(&self, idx: u64) -> Map { + // Past the end of the declared BAT region the table is short, so this + // block and every one after it has no entry to read. + if !self.has_entry(idx) { + return Map::Zero; + } + let at = self + .bat + .offset + .saturating_add(self.bat_index(idx).saturating_mul(8)); + let Some(entry) = le64(self.src, at) else { + return Map::Zero; + }; + let state = entry & 7; + if state != PAYLOAD_BLOCK_FULLY_PRESENT && state != PAYLOAD_BLOCK_PARTIALLY_PRESENT { + return Map::Zero; // unallocated or explicitly zero + } + // Bits 20..63 hold the file offset in megabytes. + let host = (entry >> 20) << 20; + let n = self + .block + .min(self.size.saturating_sub(idx.saturating_mul(self.block))); + // An entry pointing past the end of the file names bytes that are + // absent rather than hidden, so the block stays zero and stays quiet. + host.checked_add(n) + .is_some_and(|end| end <= self.file_len) + .then_some(host) + .map_or(Map::Zero, Map::Plain) + } + + fn reports(&self, _health: &Health) -> Vec<(u64, &'static str)> { + if self.unlocatable == 0 { + return Vec::new(); + } + // Reported alongside the disk, not instead of it: what was read + // is still worth scanning, and the hole is still worth knowing about. + vec![( + self.unlocatable.saturating_mul(self.block), + "VHDX block allocation table is shorter than the disk it describes", + )] + } +} + +/// Walk a VHDX's disk. +pub(crate) fn walk<T>( + src: &dyn ByteSource, budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - if !super::sniff::is(data, crate::Format::Vhdx) { + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let file_len = src.len() as u64; + if file_len <= REGION_TABLE_1 || bytes_at(src, 0, 8).as_deref() != Some(b"vhdxfile".as_slice()) + { return Ok(None); } - let Some((bat, meta)) = regions(data) else { + let Some((bat, meta)) = regions(src) else { return report( - "VHDX region table names no BAT or metadata region", + NAME, 0, + "VHDX region table names no BAT or metadata region", budget, visit, ); }; - let Some(g) = geometry(data, &meta) else { + let Some(g) = geometry(src, &meta) else { return report( - "VHDX metadata does not give the disk geometry", + NAME, 0, + "VHDX metadata does not give the disk geometry", budget, visit, ); @@ -194,8 +315,9 @@ pub(crate) fn extract_vhdx<R>( // The blocks this image lacks live in the parent, which is a separate // file — the same shape as a differencing VHD. return report( - "differencing VHDX: the disk is a delta against a parent image not in this file", + NAME, g.virtual_size, + "differencing VHDX: the disk is a delta against a parent image not in this file", budget, visit, ); @@ -203,97 +325,84 @@ pub(crate) fn extract_vhdx<R>( // The block size is a power of two between 1 MiB and 256 MiB; outside that // the chunk-ratio arithmetic below is meaningless. if !g.block_size.is_power_of_two() || !(1 << 20..=1 << 28).contains(&g.block_size) { - return report("implausible VHDX block size", g.virtual_size, budget, visit); - } - - budget.count_entry()?; - let cap = budget.reserve()?; - if g.virtual_size > cap { - return Ok(visit( - Entry::unsupported( - "vhdx-disk".to_string(), - g.virtual_size, - false, - "VHDX disk exceeds the per-member size budget", - ), + return report( + NAME, + g.virtual_size, + "implausible VHDX block size", budget, - )); + visit, + ); } - - // The BAT interleaves one sector-bitmap entry after every `chunk_ratio` - // payload entries, so a block's index in the table is not its index on the - // disk. let chunk_ratio = (1u64 << 23) .saturating_mul(u64::from(g.logical_sector_size)) .checked_div(u64::from(g.block_size)) .unwrap_or(0); - let block_size = g.block_size as usize; - let total_blocks = g.virtual_size.div_ceil(u64::from(g.block_size).max(1)); + let block = u64::from(g.block_size); + let total_blocks = g.virtual_size.div_ceil(block); + let mut layout = Vhdx { + src, + bat, + block, + size: g.virtual_size, + chunk_ratio, + file_len, + unlocatable: 0, + }; + layout.unlocatable = total_blocks.saturating_sub(layout.covered(total_blocks)); + let allowance = budget.remaining_scan(); + vdisk::walk(VDisk::new(src, layout, allowance), budget, visit) +} - let mut disk = vec![0u8; crate::bytes::to_usize(g.virtual_size)]; - // Blocks the BAT says are here but that could not be located. Zero-filling - // one and saying nothing would hand the scanner a clean-looking hole where - // real content should be. - let mut unlocatable_blocks = 0usize; +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::*; - for i in 0..total_blocks { - let bat_index = i.saturating_add(i.checked_div(chunk_ratio).unwrap_or(0)); - let bat_at = crate::bytes::to_usize(bat_index).saturating_mul(8); - // Past the end of the declared BAT region: the table is short, so this - // block and every one after it has no entry to read. - if bat_at.saturating_add(8) > bat.length { - unlocatable_blocks = unlocatable_blocks - .saturating_add(crate::bytes::to_usize(total_blocks.saturating_sub(i))); - break; - } - let entry = le_u64(data, bat.offset.saturating_add(bat_at)); - let state = entry & 7; - if state != PAYLOAD_BLOCK_FULLY_PRESENT && state != PAYLOAD_BLOCK_PARTIALLY_PRESENT { - continue; // unallocated or explicitly zero - } - // Bits 20..63 hold the file offset in megabytes. - let host = crate::bytes::to_usize((entry >> 20) << 20); - let guest = crate::bytes::to_usize(i).saturating_mul(block_size); - if guest >= disk.len() { - continue; - } - // An entry pointing past the end of the file names bytes that are - // absent rather than hidden, so the block stays zero and stays quiet. - let n = block_size.min(disk.len().saturating_sub(guest)); - if let Some(src) = crate::bytes::at(data, host, n) { - disk[guest..guest.saturating_add(n)].copy_from_slice(src); + fn layout<'a>(src: &'a dyn ByteSource, bat_len: u64, ratio: u64) -> Vhdx<'a> { + Vhdx { + src, + bat: Region { + offset: 0, + length: bat_len, + }, + block: 1 << 20, + size: 1 << 40, + chunk_ratio: ratio, + file_len: 0, + unlocatable: 0, } } - if unlocatable_blocks > 0 { - // Reported alongside the disk, not instead of it: what was reconstructed - // is still worth scanning, and the hole is still worth knowing about. - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - "vhdx-disk".to_string(), - (unlocatable_blocks as u64).saturating_mul(g.block_size as u64), - false, - "VHDX block allocation table is shorter than the disk it describes", - ), - budget, - ) { - return Ok(Some(r)); + /// The blocks the table has an entry for are found by search; they are the + /// ones a walk over every block would count, whatever the table's length + /// and however the sector-bitmap entries are interleaved. + #[test] + fn the_blocks_a_short_table_covers_are_those_with_an_entry() { + let src: &[u8] = &[]; + for ratio in [0u64, 1, 4, 16] { + for entries in 0..40u64 { + let l = layout(&src, entries * 8, ratio); + for blocks in [0u64, 1, 7, 33, 100] { + let brute = (0..blocks).take_while(|&i| l.has_entry(i)).count() as u64; + assert_eq!(l.covered(blocks), brute, "{ratio} {entries} {blocks}"); + } + } } + // An entry is eight bytes: a length that cuts one does not hold it. + let l = layout(&src, 8 * 3 + 7, 0); + assert_eq!(l.covered(10), 3); } - budget.commit(disk.len() as u64); - Ok(visit(Entry::new("vhdx-disk".to_string(), disk), budget)) -} -fn report<R>( - reason: &'static str, - size: u64, - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - budget.count_entry()?; - Ok(visit( - Entry::unsupported("vhdx-disk".to_string(), size, false, reason), - budget, - )) + /// With a sector-bitmap entry after every four payload entries, block 4 + /// is at index 5. + #[test] + fn a_block_is_found_past_the_sector_bitmap_entries() { + let src: &[u8] = &[]; + let l = layout(&src, 1 << 20, 4); + assert_eq!( + (0..8).map(|i| l.bat_index(i)).collect::<Vec<_>>(), + [0, 1, 2, 3, 5, 6, 7, 8] + ); + } } diff --git a/crates/exav-unpack/src/formats/vmdk.rs b/crates/exav-unpack/src/formats/vmdk.rs index dde71001..ae2cde93 100644 --- a/crates/exav-unpack/src/formats/vmdk.rs +++ b/crates/exav-unpack/src/formats/vmdk.rs @@ -10,28 +10,46 @@ //! Implemented from VMware's published Virtual Disk Format specification. Sparse //! header fields are **little-endian**. //! -//! The reconstructed guest disk is emitted as a single member for the partition -//! and filesystem handlers to pick up. +//! A sparse extent's tables are read from the source as grains are reached, +//! never held. A streamOptimized extent is a stream of marker sectors, each +//! grain preceded by its guest LBA and compressed length: one pass over the +//! markers indexes where each grain is (the index, one entry per grain, is +//! counted against the buffer limit), and a grain is inflated when a read +//! reaches it. +//! +//! The disk is a [`VDisk`]: one member when it fits the buffer limit, else its +//! filesystems are walked in place (see `vdisk.rs`). // Every sum and product on a header's number is checked or saturating here; a // plain one fails the build, so the next edit cannot add the unchecked kind. #![deny(clippy::arithmetic_side_effects)] -use crate::{Budget, Entry, LimitHit, Sink}; +use std::collections::HashMap; + +use super::vdisk::{self, Health, Layout, MAX_PACKED_UNIT, Map, Unpacked, VDisk, bytes_at, le32}; +use super::volumes::report; +use crate::source::ByteSource; +use crate::stream::Visit; +use crate::{Budget, LimitHit, Salvaged}; -/// `KDMV` — the sparse-extent header magic, little-endian on disk. const SECTOR: u64 = 512; const HEADER_LEN: usize = 512; -/// Grain-directory entry marking an unallocated range. -const GDE_UNALLOCATED: u32 = 0; - /// Marker sector kinds used by streamOptimized extents. const MARKER_EOS: u32 = 0; const MARKER_GT: u32 = 1; const MARKER_GD: u32 = 2; const MARKER_FOOTER: u32 = 3; +/// A grain table entry of 1 marks a grain that reads as zeroes. +const GTE_ZEROED: u32 = 1; + +/// What one entry of the streamOptimized index costs, for counting it against +/// the buffer limit. +const INDEX_ENTRY_COST: u64 = 64; + +const NAME: &str = "vmdk-disk"; + fn le_u32(d: &[u8], off: usize) -> u32 { crate::bytes::at(d, off, 4) .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) @@ -44,234 +62,547 @@ fn le_u64(d: &[u8], off: usize) -> u64 { .unwrap_or(0) } -pub(crate) fn extract_vmdk<R>( - data: &[u8], - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - if data.starts_with(b"# Disk DescriptorFile") { +/// A sparse extent: grain directory, grain tables, grains. +struct Sparse<'a> { + src: &'a dyn ByteSource, + /// Byte offset of the grain directory. + gd: u64, + /// Entries in a grain table. + gtes: u64, + grain: u64, + size: u64, + file_len: u64, +} + +impl Layout for Sparse<'_> { + fn name(&self) -> &'static str { + NAME + } + + fn size(&self) -> u64 { + self.size + } + + fn unit(&self) -> u64 { + self.grain + } + + fn map(&self, idx: u64) -> Map { + let i = idx.checked_div(self.gtes).unwrap_or(0); + let j = idx.checked_rem(self.gtes).unwrap_or(0); + let gde = le32(self.src, self.gd.saturating_add(i.saturating_mul(4))).unwrap_or(0); + if gde == 0 { + return Map::Zero; // no grain table: unallocated + } + let gt = u64::from(gde).saturating_mul(SECTOR); + // A grain table past the end is a truncated image. + if gt + .checked_add(self.gtes.saturating_mul(4)) + .is_none_or(|end| end > self.file_len) + { + return Map::Zero; + } + let gte = le32(self.src, gt.saturating_add(j.saturating_mul(4))).unwrap_or(0); + if gte == 0 || gte == GTE_ZEROED { + return Map::Zero; + } + let host = u64::from(gte).saturating_mul(SECTOR); + let n = self + .grain + .min(self.size.saturating_sub(idx.saturating_mul(self.grain))); + host.checked_add(n) + .is_some_and(|end| end <= self.file_len) + .then_some(host) + .map_or(Map::Zero, Map::Plain) + } +} + +/// Where a streamOptimized grain's marker data is. +#[derive(Clone, Copy)] +struct Grain { + at: u64, + stored: u64, + /// The file ends inside it. + cut: bool, +} + +/// A streamOptimized extent: grains found by walking its markers. +struct Stream<'a> { + src: &'a dyn ByteSource, + grain: u64, + size: u64, + index: HashMap<u64, Grain>, + /// Grains in the file that could not be indexed: off a grain boundary, or + /// more than the index holds. + unplaced: u64, +} + +impl<'a> Stream<'a> { + /// Walk the markers from where the grain data begins. + /// + /// Grain data begins after the header's declared `overhead` — the sectors + /// taken by the embedded descriptor and the redundant grain tables. + /// Starting at the end of the header instead lands in the middle of that + /// metadata and finds no grains at all. + fn new( + src: &'a dyn ByteSource, + overhead_sectors: u64, + grain: u64, + size: u64, + max_entries: usize, + ) -> Self { + let mut s = Stream { + src, + grain, + size, + index: HashMap::new(), + unplaced: 0, + }; + let file_len = src.len() as u64; + let mut pos = overhead_sectors + .saturating_mul(SECTOR) + .max(HEADER_LEN as u64); + // `pos` saturates at the top after a marker that skips past the file. + while pos.saturating_add(SECTOR) <= file_len { + // Marker: u64 LBA, u32 size, u32 type (the last only when size == 0). + let Some(m) = bytes_at(src, pos, 16) else { + break; + }; + let lba = le_u64(&m, 0); + let len = le_u32(&m, 8); + if len == 0 { + match le_u32(&m, 12) { + MARKER_EOS => break, + // Metadata regions: `lba` counts the sectors that follow. + MARKER_GT | MARKER_GD | MARKER_FOOTER => { + pos = pos + .saturating_add(SECTOR) + .saturating_add(lba.saturating_mul(SECTOR)); + } + _ => pos = pos.saturating_add(SECTOR), + } + continue; + } + // A data marker: 12 bytes of header then `len` bytes of deflate, + // padded out to a sector boundary. + let start = pos.saturating_add(12); + let len = u64::from(len); + // A grain cut by the end of the file: what is there is decoded, the + // rest is absent. + let cut = start.saturating_add(len) > file_len; + let guest = lba.saturating_mul(SECTOR); + if guest < size { + let idx = guest.checked_div(grain).unwrap_or(0); + let aligned = guest.checked_rem(grain) == Some(0); + let known = s.index.contains_key(&idx); + if !aligned || (!known && s.index.len() >= max_entries) { + s.unplaced = s.unplaced.saturating_add(1); + } else { + let stored = len.min(file_len.saturating_sub(start)); + // A later marker for a grain replaces an earlier one. + s.index.insert( + idx, + Grain { + at: start, + stored, + cut, + }, + ); + } + } + if cut { + break; + } + let consumed = len + .saturating_add(12) + .div_ceil(SECTOR) + .saturating_mul(SECTOR); + pos = pos.saturating_add(consumed); + } + s + } +} + +impl Layout for Stream<'_> { + fn name(&self) -> &'static str { + NAME + } + + fn size(&self) -> u64 { + self.size + } + + fn unit(&self) -> u64 { + self.grain + } + + fn map(&self, idx: u64) -> Map { + self.index.get(&idx).map_or(Map::Zero, |g| Map::Packed { + at: g.at, + stored: g.stored, + cut: g.cut, + }) + } + + fn unpack(&self, _idx: u64, at: u64, stored: u64, cut: bool, out: usize) -> Unpacked { + let raw = usize::try_from(stored) + .ok() + .and_then(|n| bytes_at(self.src, at, n)); + let Some(raw) = raw else { + return Unpacked { + data: Vec::new(), + damaged: true, + }; + }; + let s = match crate::inflate::zlib_reader(&raw[..]) { + Ok(Some(z)) => crate::salvage(z, out as u64), + // Cut inside the zlib header, or not a zlib stream. + Err(_) => Salvaged { + data: Vec::new(), + over_cap: false, + undecoded: false, + cut_short: true, + }, + Ok(None) => Salvaged { + data: Vec::new(), + over_cap: false, + undecoded: true, + cut_short: false, + }, + }; + // The grain's bytes are in this file; exav just could not read them. + // That is content left unexamined, not content absent. + Unpacked { + damaged: s.part_way(cut), + data: s.data, + } + } + + fn reports(&self, health: &Health) -> Vec<(u64, &'static str)> { + // Reported alongside the disk, not instead of it. A grain that fails + // to inflate leaves zeroes behind, and zeroes that nobody mentions + // read exactly like empty disk. + let mut out = Vec::new(); + if health.undecodable() > 0 { + out.push(( + health + .undecodable() + .saturating_mul(self.grain) + .min(self.size), + "streamOptimized VMDK: grains that could not be decompressed", + )); + } + if self.unplaced > 0 { + out.push(( + self.unplaced.saturating_mul(self.grain), + "streamOptimized VMDK: grains off a grain boundary or beyond the index", + )); + } + out + } + + fn unusable(&self, health: &Health, whole: bool) -> Option<&'static str> { + let none = self.index.is_empty() || (whole && health.produced() == 0); + (none && self.size > 0).then_some("streamOptimized VMDK: no grain could be decoded") + } +} + +/// What a VMDK header makes of an image. +enum Opened<'a> { + /// Not a VMDK extent at all. + No, + Sparse(Sparse<'a>), + Stream(Stream<'a>), + /// A file whose disk is not read: the size it declares, and why. + Unread(u64, &'static str), +} + +/// Read the header of `src`. A streamOptimized extent's markers are indexed +/// into at most `max_grains` entries. +fn open(src: &dyn ByteSource, max_grains: usize) -> Opened<'_> { + let head = src.window(0, HEADER_LEN); + if head.starts_with(b"# Disk DescriptorFile") { // The extents named here are separate files, absent from this one. - budget.count_entry()?; - return Ok(visit( - Entry::unsupported( - "vmdk-disk".to_string(), - data.len() as u64, - false, - "VMDK descriptor: the disk extents are separate files not in this one", - ), - budget, - )); + return Opened::Unread( + src.len() as u64, + "VMDK descriptor: the disk extents are separate files not in this one", + ); } - if !super::sniff::is(data, crate::Format::Vmdk) { - return Ok(None); + if !(src.len() > HEADER_LEN && head.starts_with(b"KDMV")) { + return Opened::No; } - let flags = le_u32(data, 8); - let capacity_sectors = le_u64(data, 12); - let grain_sectors = le_u64(data, 20); - let gd_offset = le_u64(data, 56); - let compress_algorithm = data + let flags = le_u32(&head, 8); + let capacity_sectors = le_u64(&head, 12); + let grain_sectors = le_u64(&head, 20); + let gtes = match le_u32(&head, 44) { + 0 => 512, + n => n, + }; + let gd_offset = le_u64(&head, 56); + let overhead_sectors = le_u64(&head, 64); + let compress_algorithm = head .get(77..79) .map(|b| u16::from_le_bytes([b[0], b[1]])) .unwrap_or(0); // Bit 16 of `flags` marks compressed grains; bit 17 marks the marker-based // (streamOptimized) layout. let compressed = flags & (1 << 16) != 0 || compress_algorithm == 1; - let virtual_size = capacity_sectors.saturating_mul(SECTOR); + let size = capacity_sectors.saturating_mul(SECTOR); if grain_sectors == 0 || !grain_sectors.is_power_of_two() { - return report("implausible VMDK grain size", virtual_size, budget, visit); - } - let grain_size = crate::bytes::to_usize(grain_sectors.saturating_mul(SECTOR)); - - budget.count_entry()?; - let cap = budget.reserve()?; - if virtual_size > cap { - return Ok(visit( - Entry::unsupported( - "vmdk-disk".to_string(), - virtual_size, - false, - "VMDK disk exceeds the per-member size budget", - ), - budget, - )); + return Opened::Unread(size, "implausible VMDK grain size"); } - let mut disk = vec![0u8; virtual_size as usize]; + let grain = grain_sectors.saturating_mul(SECTOR); // streamOptimized extents are written as a stream of marker sectors rather // than a navigable grain directory: each grain is preceded by its guest LBA // and compressed length, and the metadata markers can be skipped. if compressed || gd_offset == u64::MAX { - let (wrote, undecodable) = read_markers(data, &mut disk, grain_size); - if wrote == 0 && !disk.is_empty() { - return Ok(visit( - Entry::unsupported( - "vmdk-disk".to_string(), - virtual_size, - false, - "streamOptimized VMDK: no grain could be decoded", - ), - budget, - )); + // A grain is inflated whole into the cache. + if grain > MAX_PACKED_UNIT { + return Opened::Unread(size, "implausible VMDK grain size"); } - if undecodable > 0 { - // Reported alongside the disk, not instead of it. A grain that fails - // to inflate leaves zeroes behind, and zeroes that nobody mentions - // read exactly like empty disk. - budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - "vmdk-disk".to_string(), - (undecodable as u64).saturating_mul(grain_size as u64), - false, - "streamOptimized VMDK: grains that could not be decompressed", - ), - budget, - ) { - return Ok(Some(r)); - } + let layout = Stream::new(src, overhead_sectors, grain, size, max_grains); + if layout.index.is_empty() && size > 0 { + return Opened::Unread(size, "streamOptimized VMDK: no grain could be decoded"); } - budget.commit(disk.len() as u64); - return Ok(visit(Entry::new("vmdk-disk".to_string(), disk), budget)); + return Opened::Stream(layout); } // Plain sparse: grain directory -> grain tables -> grains. - let gd_byte = crate::bytes::to_usize(gd_offset.saturating_mul(SECTOR)); + if !gtes.is_power_of_two() || gtes > 1 << 16 { + return Opened::Unread(size, "implausible VMDK grain table size"); + } + let gtes = u64::from(gtes); + let gd = gd_offset.saturating_mul(SECTOR); let num_gde = capacity_sectors - .div_ceil(grain_sectors.saturating_mul(SECTOR)) - .max(1) as usize; - let Some(gd) = crate::bytes::at(data, gd_byte, num_gde.saturating_mul(4)) else { - return report( - "VMDK grain directory lies outside the image", - virtual_size, - budget, - visit, - ); - }; - for (i, gde_chunk) in gd.as_chunks::<4>().0.iter().enumerate() { - let gde = u32::from_le_bytes(*gde_chunk); - if gde == GDE_UNALLOCATED { - continue; - } - let gt_byte = crate::bytes::to_usize((gde as u64).saturating_mul(SECTOR)); - // A grain table is 512 entries in every published version. - let Some(gt) = crate::bytes::at(data, gt_byte, 512 * 4) else { - continue; + .div_ceil(grain_sectors.saturating_mul(gtes)) + .max(1); + let file_len = src.len() as u64; + if !gd + .checked_add(num_gde.saturating_mul(4)) + .is_some_and(|end| end <= file_len) + { + return Opened::Unread(size, "VMDK grain directory lies outside the image"); + } + Opened::Sparse(Sparse { + src, + gd, + gtes, + grain, + size, + file_len, + }) +} + +/// Walk a VMDK's disk. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let max_grains = + usize::try_from(budget.limits().max_buffer_bytes / INDEX_ENTRY_COST).unwrap_or(usize::MAX); + let allowance = budget.remaining_scan(); + match open(src, max_grains) { + Opened::No => Ok(None), + Opened::Unread(size, why) => report(NAME, size, why, budget, visit), + Opened::Sparse(l) => vdisk::walk(VDisk::new(src, l, allowance), budget, visit), + Opened::Stream(l) => vdisk::walk(VDisk::new(src, l, allowance), budget, visit), + } +} + +// The tests lay out their inputs by index arithmetic on small constants. +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::*; + use std::io::{Read, Seek, SeekFrom}; + + /// A sparse extent of 512 sectors in grains of one sector: the header, the + /// grain directory at sector 1, its one grain table at sectors 2 to 5, and + /// grain data from sector 6. `gte` is the table entry of grain 0. + fn sparse(gte: u32, data: &[u8]) -> Vec<u8> { + let mut f = vec![0u8; 7 * 512]; + f[..4].copy_from_slice(b"KDMV"); + f[12..20].copy_from_slice(&512u64.to_le_bytes()); + f[20..28].copy_from_slice(&1u64.to_le_bytes()); + f[44..48].copy_from_slice(&512u32.to_le_bytes()); + f[56..64].copy_from_slice(&1u64.to_le_bytes()); + f[512..516].copy_from_slice(&2u32.to_le_bytes()); + f[1024..1028].copy_from_slice(>e.to_le_bytes()); + f[6 * 512..6 * 512 + data.len()].copy_from_slice(data); + f + } + + fn first(img: &[u8], n: usize) -> Vec<u8> { + let src = &img; + let Opened::Sparse(l) = open(src, usize::MAX) else { + panic!("a sparse extent"); }; - for (j, gte_chunk) in gt.as_chunks::<4>().0.iter().enumerate() { - let gte = u32::from_le_bytes(*gte_chunk); - if gte == 0 { - continue; - } - let guest = i - .saturating_mul(512) - .saturating_add(j) - .saturating_mul(grain_size); - if guest >= disk.len() { - continue; - } - let n = grain_size.min(disk.len().saturating_sub(guest)); - let host = crate::bytes::to_usize((gte as u64).saturating_mul(SECTOR)); - if let Some(src) = crate::bytes::at(data, host, n) { - disk[guest..guest.saturating_add(n)].copy_from_slice(src); - } + let mut d = VDisk::new(src, l, u64::MAX); + let mut out = vec![0u8; n]; + d.read_exact(&mut out).unwrap(); + out + } + + fn unread(img: &[u8]) -> &'static str { + let src = img; + match open(&src, usize::MAX) { + Opened::Unread(_, why) => why, + _ => panic!("the image should not open as a disk"), } } - budget.commit(disk.len() as u64); - Ok(visit(Entry::new("vmdk-disk".to_string(), disk), budget)) -} -/// Walk a streamOptimized extent's marker sectors, inflating each grain into -/// place. Returns how many grains were written, and how many were present but -/// would not inflate. -/// -/// Grain data begins after the header's declared `overhead` — the sectors taken -/// by the embedded descriptor and the redundant grain tables. Starting at the -/// end of the header instead lands in the middle of that metadata and finds no -/// grains at all. -fn read_markers(data: &[u8], disk: &mut [u8], grain_size: usize) -> (usize, usize) { - let overhead_sectors = le_u64(data, 64); - let mut pos = crate::bytes::to_usize(overhead_sectors.saturating_mul(SECTOR)).max(HEADER_LEN); - let mut wrote = 0usize; - let mut undecodable = 0usize; - // `pos` saturates at the top of `usize` after a marker that skips past the file. - while pos.saturating_add(512) <= data.len() { - // Marker: u64 LBA, u32 size, u32 type (the last only when size == 0). - let lba = le_u64(data, pos); - let size = le_u32(data, pos.saturating_add(8)) as usize; - if size == 0 { - let kind = le_u32(data, pos.saturating_add(12)); - match kind { - MARKER_EOS => break, - // Metadata regions: `lba` counts the sectors that follow. - MARKER_GT | MARKER_GD | MARKER_FOOTER => { - let skip = crate::bytes::to_usize(lba).saturating_mul(SECTOR as usize); - pos = pos.saturating_add(512).saturating_add(skip); - continue; - } - _ => { - pos = pos.saturating_add(512); - continue; - } - } + #[test] + fn a_grain_is_read_from_its_table_entry() { + assert_eq!(&first(&sparse(6, b"payload"), 7), b"payload"); + } + + /// An entry of 1 is a grain that reads as zeros; read as the sector it + /// names, it would be the extent's own descriptor area. + #[test] + fn a_grain_table_entry_of_one_reads_zeros() { + // Sector 1 is the grain directory, which does not start with zeros. + assert_eq!(first(&sparse(1, b"payload"), 7), [0u8; 7]); + assert_eq!(first(&sparse(0, b"payload"), 7), [0u8; 7]); + } + + #[test] + fn a_grain_or_table_outside_the_file_reads_as_zeros() { + assert_eq!(first(&sparse(7, b"payload"), 7), [0u8; 7]); + assert_eq!(first(&sparse(u32::MAX, b"payload"), 7), [0u8; 7]); + let mut img = sparse(6, b"payload"); + img[512..516].copy_from_slice(&6u32.to_le_bytes()); + assert_eq!(first(&img, 7), [0u8; 7]); + } + + #[test] + fn what_the_header_says_that_cannot_be_followed_is_reported() { + let mut img = sparse(6, b"x"); + img[20..28].copy_from_slice(&3u64.to_le_bytes()); + assert!(unread(&img).contains("grain size")); + let mut img = sparse(6, b"x"); + img[20..28].copy_from_slice(&0u64.to_le_bytes()); + assert!(unread(&img).contains("grain size")); + for gtes in [3u32, 1 << 20] { + let mut img = sparse(6, b"x"); + img[44..48].copy_from_slice(>es.to_le_bytes()); + assert!(unread(&img).contains("grain table size"), "{gtes}"); } - // A data marker: 12 bytes of header then `size` bytes of deflate, - // padded out to a sector boundary. - let start = pos.saturating_add(12); - // A grain cut by the end of the file: what is there is decoded, the - // rest is absent. - let rest_absent = start.saturating_add(size) > data.len(); - let Some(raw) = data.get(start..start.saturating_add(size).min(data.len())) else { - break; - }; - let guest = crate::bytes::to_usize(lba).saturating_mul(SECTOR as usize); - if guest < disk.len() { - let s = match crate::inflate::zlib_reader(raw) { - Ok(Some(z)) => crate::salvage(z, grain_size as u64), - // Cut inside the zlib header, or not a zlib stream. - Err(_) => crate::Salvaged { - data: Vec::new(), - over_cap: false, - undecoded: false, - cut_short: true, - }, - Ok(None) => crate::Salvaged { - data: Vec::new(), - over_cap: false, - undecoded: true, - cut_short: false, - }, - }; - let n = s.data.len().min(disk.len().saturating_sub(guest)); - disk[guest..guest.saturating_add(n)].copy_from_slice(&s.data[..n]); - if n > 0 { - wrote = wrote.saturating_add(1); - } - // The grain's bytes are in this file; exav just could not read - // them. That is content left unexamined, not content absent. - if s.part_way(rest_absent) { - undecodable = undecodable.saturating_add(1); - } + // The directory at the top of the range: the sum must not wrap. + for gd in [1000u64, u64::MAX - 1, 1 << 60] { + let mut img = sparse(6, b"x"); + img[56..64].copy_from_slice(&gd.to_le_bytes()); + assert!(unread(&img).contains("grain directory"), "{gd}"); } - if rest_absent { - break; + // Compressed grains are inflated whole into the cache: not a huge one. + let mut img = sparse(6, b"x"); + img[8..12].copy_from_slice(&(1u32 << 16).to_le_bytes()); + img[20..28].copy_from_slice(&(1u64 << 30).to_le_bytes()); + assert!(unread(&img).contains("grain size")); + let descriptor = b"# Disk DescriptorFile\nversion=1\n".to_vec(); + assert!(unread(&descriptor).contains("descriptor")); + let short: &[u8] = b"KDMV"; + assert!(matches!(open(&short, usize::MAX), Opened::No)); + } + + /// One data marker for a grain of one sector, deflate of `grain`, then the + /// end-of-stream marker. + fn stream(markers: &[(u64, &[u8])]) -> Vec<u8> { + use std::io::Write; + let mut f = vec![0u8; 512]; + f[..4].copy_from_slice(b"KDMV"); + f[8..12].copy_from_slice(&(1u32 << 16).to_le_bytes()); + f[12..20].copy_from_slice(&512u64.to_le_bytes()); + f[20..28].copy_from_slice(&1u64.to_le_bytes()); + f[56..64].copy_from_slice(&u64::MAX.to_le_bytes()); + f[64..72].copy_from_slice(&1u64.to_le_bytes()); + for (lba, grain) in markers { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(grain).unwrap(); + let z = z.finish().unwrap(); + f.extend_from_slice(&lba.to_le_bytes()); + f.extend_from_slice(&(z.len() as u32).to_le_bytes()); + f.extend_from_slice(&z); + f.resize(f.len().next_multiple_of(512), 0); } - let consumed = size.saturating_add(12).div_ceil(512).saturating_mul(512); - pos = pos.saturating_add(consumed); + f.extend_from_slice(&[0u8; 512]); + f } - (wrote, undecodable) -} -fn report<R>( - reason: &'static str, - size: u64, - budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - budget.count_entry()?; - Ok(visit( - Entry::unsupported("vmdk-disk".to_string(), size, false, reason), - budget, - )) + fn grains(img: &[u8], max: usize) -> Option<(Vec<u8>, u64)> { + let src = &img; + let Opened::Stream(l) = open(src, max) else { + return None; + }; + let unplaced = l.unplaced; + let mut d = VDisk::new(src, l, u64::MAX); + let mut out = vec![0u8; 4 * 512]; + d.seek(SeekFrom::Start(0)).unwrap(); + d.read_exact(&mut out).unwrap(); + Some((out, unplaced)) + } + + /// A marker for a grain already seen replaces it, as a later write does. + #[test] + fn a_later_marker_replaces_an_earlier_one_for_the_same_grain() { + let (a, b) = ([b'a'; 512], [b'b'; 512]); + let img = stream(&[(1, &a), (1, &b), (2, &a)]); + let (d, unplaced) = grains(&img, usize::MAX).unwrap(); + assert_eq!(&d[..512], &[0u8; 512][..]); + assert_eq!(&d[512..1024], &b[..]); + assert_eq!(&d[1024..1536], &a[..]); + assert_eq!(unplaced, 0); + } + + /// The index is counted against a limit; a grain past it is counted as one + /// that could not be placed, not dropped. + #[test] + fn grains_past_the_index_limit_are_counted_not_dropped() { + let a = [b'a'; 512]; + let img = stream(&[(0, &a), (1, &a), (2, &a)]); + let (d, unplaced) = grains(&img, 2).unwrap(); + assert_eq!(&d[..1024], &[b'a'; 1024][..]); + assert_eq!(&d[1024..1536], &[0u8; 512][..]); + assert_eq!(unplaced, 1); + } + + /// A grain damaged part way gave what decoded before the damage, and that + /// is handed over with the report; a disk is not "no grain decoded" for + /// having one bad grain. + #[test] + fn a_grain_damaged_part_way_keeps_its_prefix_and_is_reported() { + let text: Vec<u8> = (0..512u32).map(|i| b'a' + (i * 7 % 26) as u8).collect(); + let mut img = stream(&[(0, &text)]); + // The grain's deflate stream starts 12 bytes into the marker at 512; + // damage its second half. + let len = u32::from_le_bytes(img[520..524].try_into().unwrap()) as usize; + for b in &mut img[512 + 12 + len / 2..512 + 12 + len] { + *b ^= 0xFF; + } + let mut budget = crate::Budget::new(crate::Limits::default()); + let got = crate::extract(crate::Format::Vmdk, &img.as_slice(), &mut budget).unwrap(); + assert!( + got[0] + .unsupported + .is_some_and(|w| w.contains("could not be decompressed")) + ); + let disk = &got[1]; + assert!(disk.unsupported.is_none() && disk.name == "vmdk-disk"); + assert!(disk.data.len() == 512 * 512); + assert!( + disk.data.starts_with(&text[..3]), + "the decoded prefix is kept" + ); + } + + #[test] + fn a_stream_with_no_grain_is_reported() { + assert!(matches!( + open(&stream(&[]).as_slice(), usize::MAX), + Opened::Unread(_, w) if w.contains("no grain") + )); + } } diff --git a/crates/exav-unpack/src/formats/vol.rs b/crates/exav-unpack/src/formats/vol.rs new file mode 100644 index 00000000..38e94fb6 --- /dev/null +++ b/crates/exav-unpack/src/formats/vol.rs @@ -0,0 +1,532 @@ +//! A volume read at the offsets its own structures give, for the filesystem +//! readers that walk one in place (NTFS, HFS+, APFS). + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] +// Each build has the filesystems its features give it. +#![allow(dead_code)] + +use std::io::{self, Read, Seek, SeekFrom}; + +use crate::{Budget, Entry, LimitHit, Mtime}; + +/// A `Read + Seek` volume, whichever it is. +pub(crate) trait Disk: Read + Seek {} +impl<T: Read + Seek + ?Sized> Disk for T {} + +/// How much of the volume one read asks of it at once. +const STEP: usize = 1 << 20; + +/// The volume, read at offsets the filesystem's own numbers give. +pub(crate) struct Vol<'a> { + pub(crate) disk: &'a mut dyn Disk, + pub(crate) len: u64, + /// The most an on-disk structure (a table, an attribute list) is read + /// whole: the buffer limit. Only NTFS reads whole structures of any size. + pub(crate) max: usize, +} + +impl Vol<'_> { + /// `len` bytes at `off`, or `None` when they are not all in the volume. + pub(crate) fn at(&mut self, off: u64, len: usize) -> Option<Vec<u8>> { + let mut out = Vec::new(); + self.append(off, len, &mut out).then_some(out) + } + + /// Append `len` bytes at `off` to `out`, if they are all in the volume. + /// They are read a step at a time, so what is held grows with what is read. + pub(crate) fn append(&mut self, off: u64, len: usize, out: &mut Vec<u8>) -> bool { + let Some(end) = off.checked_add(len as u64) else { + return false; + }; + if end > self.len || self.disk.seek(SeekFrom::Start(off)).is_err() { + return false; + } + let base = out.len(); + let mut done = 0usize; + while done < len { + let step = len.saturating_sub(done).min(STEP); + let from = base.saturating_add(done); + out.resize(from.saturating_add(step), 0); + if self.disk.read_exact(&mut out[from..]).is_err() { + out.truncate(base); + return false; + } + done = done.saturating_add(step); + } + true + } +} + +/// What reading a file of a volume came to. +pub(crate) enum Got<'a> { + Whole(Vec<u8>), + /// What could be read of a file the volume does not hold all of. + Short(Vec<u8>), + /// A file too long to hold, of that many bytes, to be read as it goes. It + /// fails with an error where the volume stops giving its bytes. + Stream(Box<dyn Read + 'a>, u64), + /// Longer than a member may be, of that many bytes, and not a file this + /// reader can read as it goes: reported, and the walk goes on. + Big(u64), + Why(&'static str), + /// Stored encrypted. + Locked(&'static str), +} + +impl Got<'_> { + /// The member for the file at `path`. A file to be read as it goes is not + /// one: [`give`] hands it over before it gets here. + pub(crate) fn entry(self, path: String) -> Entry { + match self { + Got::Whole(d) => Entry::new(path, d), + Got::Short(d) => Entry { + unsupported: Some("file could not be read from the volume"), + ..Entry::new(path, d) + }, + Got::Stream(_, size) => { + Entry::unsupported(path, size, false, "file was not read as it goes") + } + Got::Big(size) => { + Entry::unsupported(path, size, false, "file exceeds the per-member size budget") + } + Got::Why(why) => Entry::unsupported(path, 0, false, why), + Got::Locked(why) => Entry::unsupported(path, 0, true, why), + } + } +} + +/// A file a filesystem walker hands over. +pub(crate) enum Found<'a> { + /// A member held whole. + Held(Entry), + /// A member read as it goes: its name, mode and time in the entry, which + /// has no data, and the reader of its bytes. + Read(Entry, &'a mut dyn Read), +} + +impl Found<'_> { + /// The member whole, a file read as it goes read to its end, or to 32 MiB: + /// a damaged volume can give a file of any length, holes and all. + #[cfg(test)] + pub(crate) fn whole(self) -> Entry { + match self { + Found::Held(e) => e, + Found::Read(mut e, r) => { + if let Err(err) = r.take(32 << 20).read_to_end(&mut e.data) { + e.unsupported = Some(Box::leak(err.to_string().into_boxed_str())); + } + e + } + } + } +} + +/// What a filesystem walker hands each file to. +pub(crate) type Emit<'a, R> = &'a mut dyn FnMut(Found<'_>, &mut Budget) -> Option<R>; + +/// What one step of producing a file's bytes came to. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Step { + /// Bytes were appended, and there may be more. + More, + /// The file is over. + End, + /// The volume does not give the rest of the file. + Broken, +} + +/// The bytes of a file, produced a piece at a time. +pub(crate) trait Fill { + /// Append the next bytes of the file to `out`, at most [`STEP`] of them. + fn fill(&mut self, v: &mut Vol, out: &mut Vec<u8>) -> Step; +} + +/// A file read to its end where it is as long as `size`, which is held, so +/// it is within what a member may hold. What was read stays where the +/// volume stops giving more. +pub(crate) fn drain<'a>(fill: &mut impl Fill, v: &mut Vol, size: u64) -> Got<'a> { + let mut out = Vec::new(); + let end = loop { + match fill.fill(v, &mut out) { + Step::More => {} + other => break other, + } + }; + if end == Step::End && out.len() as u64 == size { + Got::Whole(out) + } else { + Got::Short(out) + } +} + +/// A file of `size` bytes: held when it is within `cap`, else read as it goes. +pub(crate) fn read_file<'a, 'b, F: Fill + 'a>( + v: &'a mut Vol<'b>, + mut fill: F, + size: u64, + cap: u64, +) -> Got<'a> +where + 'b: 'a, +{ + if size <= cap { + return drain(&mut fill, v, size); + } + Got::Stream( + Box::new(Streamed { + v, + fill, + buf: Vec::new(), + at: 0, + step: Step::More, + }), + size, + ) +} + +/// A [`Fill`] as a `Read`. +struct Streamed<'a, 'b, F> { + v: &'a mut Vol<'b>, + fill: F, + buf: Vec<u8>, + at: usize, + step: Step, +} + +impl<F: Fill> Read for Streamed<'_, '_, F> { + fn read(&mut self, out: &mut [u8]) -> io::Result<usize> { + loop { + if let Some(rest) = self.buf.get(self.at..).filter(|r| !r.is_empty()) { + let n = rest.len().min(out.len()); + out[..n].copy_from_slice(&rest[..n]); + self.at = self.at.saturating_add(n); + return Ok(n); + } + match self.step { + Step::End => return Ok(0), + Step::Broken => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "file could not be read from the volume", + )); + } + Step::More => { + self.buf.clear(); + self.at = 0; + self.step = self.fill.fill(self.v, &mut self.buf); + } + } + } + } +} + +/// A stretch of a file. +#[derive(Clone, Copy)] +pub(crate) enum Piece { + /// `len` bytes at `off` of the volume. + Disk(u64, u64), + /// `len` bytes of zeros: a hole, or what was never written. + Zeros(u64), +} + +/// What a [`Plan`] gives next. +pub(crate) enum Next { + Piece(Piece), + End, + /// The volume does not say where the rest of the file is. + Broken, +} + +/// Where the stretches of a file are, in order, found as they are wanted. +pub(crate) trait Plan { + fn next(&mut self, v: &mut Vol) -> Next; +} + +/// A [`Plan`]'s stretches as the first `left` bytes of a file. +pub(crate) struct Pieces<P> { + plan: P, + left: u64, + cur: Option<Piece>, +} + +impl<P: Plan> Pieces<P> { + pub(crate) fn new(plan: P, size: u64) -> Self { + Pieces { + plan, + left: size, + cur: None, + } + } +} + +impl<P: Plan> Fill for Pieces<P> { + fn fill(&mut self, v: &mut Vol, out: &mut Vec<u8>) -> Step { + if self.left == 0 { + return Step::End; + } + let piece = match self.cur.take() { + Some(p) => p, + None => match self.plan.next(v) { + Next::Piece(p) => p, + // The file is longer than what the volume says it holds. + Next::End | Next::Broken => return Step::Broken, + }, + }; + let (Piece::Disk(_, len) | Piece::Zeros(len)) = piece; + let take = len.min(self.left).min(STEP as u64); + let n = crate::bytes::to_usize(take); + let read = match piece { + Piece::Disk(off, _) => v.append(off, n, out), + Piece::Zeros(_) => { + out.resize(out.len().saturating_add(n), 0); + true + } + }; + if !read { + return Step::Broken; + } + self.left = self.left.saturating_sub(take); + // What is left of the piece, if any. + let left = len.saturating_sub(take); + if left > 0 { + self.cur = Some(match piece { + Piece::Disk(off, _) => Piece::Disk(off.saturating_add(take), left), + Piece::Zeros(_) => Piece::Zeros(left), + }); + } + Step::More + } +} + +/// A plan that is a list of stretches, with a flag for whether the list is +/// whole where it ends. +pub(crate) struct List { + pub(crate) pieces: std::vec::IntoIter<Piece>, + pub(crate) intact: bool, +} + +impl Plan for List { + fn next(&mut self, _: &mut Vol) -> Next { + match self.pieces.next() { + Some(p) => Next::Piece(p), + None if self.intact => Next::End, + None => Next::Broken, + } + } +} + +/// What a volume records of a file besides its bytes. +#[derive(Clone, Copy, Default)] +pub(crate) struct Attrs { + /// `st_mode`. + pub(crate) mode: Option<u32>, + pub(crate) mtime: Option<Mtime>, +} + +/// The file at `path`, read under the bounds a member has and handed to +/// `visit`: held when it fits, else read as it goes. The caller has counted +/// the member. +pub(crate) fn give<'a, R>( + budget: &mut Budget, + visit: Emit<R>, + path: String, + attrs: Attrs, + read: impl FnOnce(u64) -> Got<'a>, +) -> Result<Option<R>, LimitHit> { + let cap = budget.reserve()?; + Ok(hand(budget, visit, path, attrs, read(cap))) +} + +/// What a volume gave of the file at `path`, handed to `visit`. The caller has +/// counted the member. +pub(crate) fn hand<R>( + budget: &mut Budget, + visit: Emit<R>, + path: String, + attrs: Attrs, + got: Got<'_>, +) -> Option<R> { + let (mut entry, mut stream) = match got { + Got::Stream(reader, size) => { + let mut e = Entry::new(path, Vec::new()); + e.comp_size = size; + (e, Some(reader)) + } + other => (other.entry(path), None), + }; + entry.mode = attrs.mode; + entry.mtime = attrs.mtime; + match stream.as_mut() { + Some(reader) => visit(Found::Read(entry, &mut **reader), budget), + None => { + budget.commit(entry.data.len() as u64); + visit(Found::Held(entry), budget) + } + } +} + +/// The integer of `N` bytes at `off`, or 0 where the data ends: a record is +/// length-checked once, and its fields are then read without a check each. +fn field<const N: usize>(d: &[u8], off: usize) -> [u8; N] { + crate::bytes::at(d, off, N) + .and_then(|b| b.try_into().ok()) + .unwrap_or([0; N]) +} + +pub(crate) fn be16(d: &[u8], off: usize) -> u16 { + u16::from_be_bytes(field(d, off)) +} + +pub(crate) fn be32(d: &[u8], off: usize) -> u32 { + u32::from_be_bytes(field(d, off)) +} + +pub(crate) fn be64(d: &[u8], off: usize) -> u64 { + u64::from_be_bytes(field(d, off)) +} + +pub(crate) fn le16(d: &[u8], off: usize) -> u16 { + u16::from_le_bytes(field(d, off)) +} + +pub(crate) fn le32(d: &[u8], off: usize) -> u32 { + u32::from_le_bytes(field(d, off)) +} + +pub(crate) fn le64(d: &[u8], off: usize) -> u64 { + u64::from_le_bytes(field(d, off)) +} + +/// What a walk handed over, each member whole with whether it was read as it +/// goes, and what the walk returned. For the readers' tests. +#[cfg(test)] +pub(crate) fn collected<T>(run: impl FnOnce(Emit<()>) -> T) -> (Vec<(Entry, bool)>, T) { + let mut out = Vec::new(); + let end = run(&mut |found, _| { + let streamed = matches!(found, Found::Read(..)); + out.push((found.whole(), streamed)); + None + }); + (out, end) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn a_read_is_all_in_the_volume_or_absent() { + let mut c = Cursor::new((0u8..10).collect::<Vec<_>>()); + let mut v = Vol { + disk: &mut c, + len: 10, + max: 1 << 20, + }; + assert_eq!(v.at(2, 3), Some(vec![2, 3, 4])); + assert_eq!(v.at(10, 0), Some(vec![])); + assert_eq!(v.at(8, 3), None, "past the end"); + assert_eq!(v.at(u64::MAX, 2), None, "the sum overflows"); + let mut out = vec![9]; + assert!(!v.append(8, 3, &mut out)); + assert_eq!(out, [9], "a failed append leaves what was there"); + } + + fn plan(pieces: Vec<Piece>, intact: bool) -> List { + List { + pieces: pieces.into_iter(), + intact, + } + } + + #[test] + fn a_file_longer_than_a_step_is_read_in_steps_across_disk_and_holes() { + let data: Vec<u8> = (0..STEP * 2 + 100).map(|i| (i % 251) as u8).collect(); + let mut c = Cursor::new(data.clone()); + let mut v = Vol { + disk: &mut c, + len: data.len() as u64, + max: 1 << 20, + }; + let size = (data.len() + STEP + 5) as u64; + let pieces = vec![ + Piece::Disk(0, data.len() as u64), + Piece::Zeros(STEP as u64 + 5), + ]; + let got = read_file(&mut v, Pieces::new(plan(pieces, true), size), size, 10); + let Got::Stream(mut r, n) = got else { + panic!("not read as it goes"); + }; + assert_eq!(n, size); + let mut out = Vec::new(); + r.read_to_end(&mut out).unwrap(); + let mut want = data; + want.resize(size as usize, 0); + assert!(out == want, "the bytes differ"); + } + + #[test] + fn a_file_the_volume_stops_giving_is_read_up_to_there_and_then_fails() { + let mut c = Cursor::new(vec![7u8; 100]); + let mut v = Vol { + disk: &mut c, + len: 100, + max: 1 << 20, + }; + // The file is 300 bytes, and the volume has the first 100 of them. + for pieces in [ + plan(vec![Piece::Disk(0, 100)], false), + plan(vec![Piece::Disk(0, 100), Piece::Disk(100, 200)], true), + ] { + let got = read_file(&mut v, Pieces::new(pieces, 300), 300, 10); + let Got::Stream(mut r, _) = got else { + panic!("not read as it goes"); + }; + let mut out = Vec::new(); + let err = r.read_to_end(&mut out).expect_err("the end is not there"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(out, vec![7u8; 100]); + } + } + + #[test] + fn a_file_within_the_limit_is_held_whole_or_short() { + let mut c = Cursor::new(vec![7u8; 100]); + let mut v = Vol { + disk: &mut c, + len: 100, + max: 1 << 20, + }; + let whole = read_file( + &mut v, + Pieces::new(plan(vec![Piece::Disk(0, 60)], true), 60), + 60, + 60, + ); + assert!(matches!(&whole, Got::Whole(d) if *d == vec![7u8; 60])); + drop(whole); + // A file longer than what the plan holds is short, not whole. + let short = read_file( + &mut v, + Pieces::new(plan(vec![Piece::Disk(0, 60)], true), 80), + 80, + 80, + ); + assert!(matches!(short, Got::Short(d) if d == vec![7u8; 60])); + } + + #[test] + fn fields_are_read_in_their_order_and_zero_past_the_end() { + let d = [1u8, 2, 3, 4, 5, 6, 7, 8]; + assert_eq!(be16(&d, 0), 0x0102); + assert_eq!(le16(&d, 0), 0x0201); + assert_eq!(be32(&d, 1), 0x0203_0405); + assert_eq!(le32(&d, 1), 0x0504_0302); + assert_eq!(be64(&d, 0), 0x0102_0304_0506_0708); + assert_eq!(le64(&d, 0), 0x0807_0605_0403_0201); + assert_eq!(be32(&d, 6), 0); + assert_eq!(le64(&d, usize::MAX), 0); + } +} diff --git a/crates/exav-unpack/src/formats/volumes.rs b/crates/exav-unpack/src/formats/volumes.rs new file mode 100644 index 00000000..b1b7c490 --- /dev/null +++ b/crates/exav-unpack/src/formats/volumes.rs @@ -0,0 +1,373 @@ +//! The partitions and filesystems of a disk, walked where they lie. +//! +//! A disk image is a `Read + Seek`; the filesystem readers (NTFS, FAT, and ext +//! for the one volume it holds) take one, so a file comes out of a disk +//! without the disk, or the volume around it, being held. +//! +//! A volume of at most the buffer limit is handed over whole, as a stream, as +//! a partition of a raw disk always was: its raw bytes are scanned and typed +//! one level down, which also reaches whatever it holds that is not a +//! filesystem exav reads. A larger one that is a filesystem exav reads is +//! walked in place. A larger one that is not is streamed when the disk is a +//! file ([`Mode::Raw`]), where those bytes are the input, and reported when +//! the disk is a virtual one ([`Mode::Virtual`]), where its size is only what +//! a header declares and reading it could be a long walk through zeros. + +// Every sum and product on a header's number is checked or saturating here; a +// plain one fails the build, so the next edit cannot add the unchecked kind. +#![deny(clippy::arithmetic_side_effects)] +// Each build has the filesystems and the partition walker its features give it. +#![allow(dead_code, unused_imports, unused_variables)] + +use std::io::{self, Read, Seek, SeekFrom}; + +use super::vol::{Emit, Found}; +use crate::stream::{MemberMeta, Region, Visit, emit_entry, emit_stream}; +use crate::{Budget, Entry, LimitHit}; + +/// A member that is only a report: `reason` about `size` bytes of `name`. +pub(crate) fn report<T>( + name: &str, + size: u64, + reason: &'static str, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + report_enc(name, size, false, reason, budget, visit) +} + +/// [`report`] of content that is encrypted. +pub(crate) fn report_enc<T>( + name: &str, + size: u64, + encrypted: bool, + reason: &'static str, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + budget.count_entry()?; + emit_entry( + Entry::unsupported(name.to_string(), size, encrypted, reason), + budget, + visit, + ) +} + +/// What is read to tell a filesystem apart. +const HEAD: usize = 4096; + +/// How far into a virtual disk the bytes before the first partition are +/// scanned: the boot code, where a bootkit lives. +const BOOT_AREA: u64 = 1024 * 1024; + +/// What the disk is. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Mode { + /// A file's bytes: the disk was scanned as the object it is. + Raw, + /// A virtual disk's declared bytes. + Virtual, +} + +/// A filesystem that is read in place. +#[derive(Clone, Copy)] +enum Fs { + #[cfg(feature = "ntfs")] + Ntfs, + #[cfg(feature = "fat")] + Fat, + #[cfg(feature = "ext")] + Ext, + #[cfg(feature = "exfat")] + Exfat, + #[cfg(feature = "squashfs")] + Squashfs, + #[cfg(feature = "cramfs")] + Cramfs, + #[cfg(feature = "romfs")] + Romfs, + #[cfg(feature = "erofs")] + Erofs, + #[cfg(feature = "dmg")] + Hfs, + #[cfg(feature = "dmg")] + Apfs, +} + +/// The filesystem a volume starting with `head` is. The order is `detect`'s. +fn sniff(head: &[u8]) -> Option<Fs> { + #[cfg(feature = "dmg")] + if super::apfs::is_apfs(head) { + return Some(Fs::Apfs); + } + #[cfg(feature = "dmg")] + if super::hfsplus::is_hfsplus(head) { + return Some(Fs::Hfs); + } + #[cfg(feature = "ext")] + if super::ext::is_ext(head) { + return Some(Fs::Ext); + } + #[cfg(feature = "fat")] + if super::fat::is_fat(head) { + return Some(Fs::Fat); + } + #[cfg(feature = "exfat")] + if super::exfat::is_exfat(head) { + return Some(Fs::Exfat); + } + #[cfg(feature = "squashfs")] + if super::squashfs::is_squashfs(head) { + return Some(Fs::Squashfs); + } + #[cfg(feature = "cramfs")] + if super::cramfs::is_cramfs(head) { + return Some(Fs::Cramfs); + } + #[cfg(feature = "romfs")] + if super::romfs::is_romfs(head) { + return Some(Fs::Romfs); + } + #[cfg(feature = "erofs")] + if super::erofs::is_erofs(head) { + return Some(Fs::Erofs); + } + #[cfg(feature = "ntfs")] + if super::sniff::is(head, crate::Format::Ntfs) { + return Some(Fs::Ntfs); + } + let _ = head; + None +} + +/// `[start, start + len)` of a disk, as a disk of its own. +pub(crate) struct Window<'a, D> { + disk: &'a mut D, + start: u64, + len: u64, + pos: u64, +} + +impl<'a, D> Window<'a, D> { + pub(crate) fn new(disk: &'a mut D, start: u64, len: u64) -> Self { + Window { + disk, + start, + len, + pos: 0, + } + } +} + +impl<D: Read + Seek> Read for Window<'_, D> { + fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { + let left = self.len.saturating_sub(self.pos); + let want = usize::try_from(left).map_or(buf.len(), |l| l.min(buf.len())); + if want == 0 { + return Ok(0); + } + let at = self + .start + .checked_add(self.pos) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + self.disk.seek(SeekFrom::Start(at))?; + let n = self.disk.read(&mut buf[..want])?; + self.pos = self.pos.saturating_add(n as u64); + Ok(n) + } +} + +impl<D> Seek for Window<'_, D> { + fn seek(&mut self, to: SeekFrom) -> io::Result<u64> { + let pos = match to { + SeekFrom::Start(p) => Some(p), + SeekFrom::End(d) => self.len.checked_add_signed(d), + SeekFrom::Current(d) => self.pos.checked_add_signed(d), + } + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek outside the volume"))?; + self.pos = pos; + Ok(pos) + } +} + +/// Run a filesystem walker, which hands each file to an [`Emit`], as a walk +/// handing members to `visit`, each name under `prefix`. A file read as it +/// goes is a streamed member. +pub(crate) fn entries<T>( + budget: &mut Budget, + visit: Visit<T>, + prefix: &str, + run: impl FnOnce( + &mut Budget, + Emit<Result<T, LimitHit>>, + ) -> Result<Option<Result<T, LimitHit>>, LimitHit>, +) -> Result<Option<T>, LimitHit> { + let under = |e: Entry| { + if prefix.is_empty() { + e + } else { + Entry { + name: format!("{prefix}/{}", e.name), + ..e + } + } + }; + let mut emit = |found: Found<'_>, budget: &mut Budget| -> Option<Result<T, LimitHit>> { + match found { + Found::Held(e) => emit_entry(under(e), budget, &mut *visit).transpose(), + Found::Read(e, reader) => { + let e = under(e); + let meta = MemberMeta { + name: e.name, + comp_size: e.comp_size, + size: Some(e.comp_size), + mtime: e.mtime, + mode: e.mode, + ..MemberMeta::default() + }; + emit_stream(&meta, reader, budget, &mut *visit).transpose() + } + } + }; + run(budget, &mut emit)?.transpose() +} + +/// Walk the filesystem `fs` that fills `[start, start + len)` of `disk`, each +/// file's name under `prefix`. +fn walk_fs<D: Read + Seek, T>( + fs: Fs, + disk: &mut D, + start: u64, + len: u64, + prefix: &str, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let window = Window::new(disk, start, len); + entries(budget, visit, prefix, |budget, sink| match fs { + #[cfg(feature = "ntfs")] + Fs::Ntfs => super::ntfs::walk_volume(window, len, budget, sink), + #[cfg(feature = "fat")] + Fs::Fat => super::fat::walk_volume(window, len, budget, sink), + #[cfg(feature = "ext")] + Fs::Ext => super::ext::walk_volume(window, len, budget, sink), + #[cfg(feature = "exfat")] + Fs::Exfat => super::exfat::walk_volume(window, len, budget, sink), + #[cfg(feature = "squashfs")] + Fs::Squashfs => super::squashfs::walk_volume(window, len, budget, sink), + #[cfg(feature = "cramfs")] + Fs::Cramfs => super::cramfs::walk_volume(window, len, budget, sink), + #[cfg(feature = "romfs")] + Fs::Romfs => super::romfs::walk_volume(window, len, budget, sink), + #[cfg(feature = "erofs")] + Fs::Erofs => super::erofs::walk_volume(window, len, budget, sink), + #[cfg(feature = "dmg")] + Fs::Hfs => super::hfsplus::walk_volume(window, budget, sink), + #[cfg(feature = "dmg")] + Fs::Apfs => super::apfs::walk_volume(window, budget, sink), + }) +} + +/// Walk a disk image: the filesystem that starts it, or the volumes its +/// partition table lists. A disk that is neither is reported: its bytes were +/// not read. +pub(crate) fn walk_disk<D: Read + Seek, T>( + disk: &mut D, + mode: Mode, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let len = disk + .seek(SeekFrom::End(0)) + .map_err(|e| LimitHit::corrupt(format!("disk: {e}")))?; + let head = crate::read_at(disk, 0, HEAD)?; + if let Some(fs) = sniff(&head) { + return walk_fs(fs, disk, 0, len, "", budget, visit); + } + #[cfg(feature = "partition")] + { + let regions = super::partition::stream_offsets(disk)?; + if !regions.is_empty() { + return walk_regions(disk, regions, mode, budget, visit); + } + // A table with nothing in it holds nothing. + if super::partition::is_table(&head, len) { + return Ok(None); + } + } + let why = "the disk is larger than the buffer limit and is neither a filesystem nor a \ + partition table exav reads, so its bytes were not examined"; + report("<disk>", len, why, budget, visit) +} + +/// Walk the volumes of a partitioned disk. +pub(crate) fn walk_regions<D: Read + Seek, T>( + disk: &mut D, + regions: Vec<Region>, + mode: Mode, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let cap = budget.limits().max_buffer_bytes; + if mode == Mode::Virtual { + let first = regions + .iter() + .filter_map(|r| match r { + Region::Member(_, off, _) => Some(*off), + Region::Unwalked(..) => None, + }) + .min(); + let n = first.map_or(0, |f| f.min(BOOT_AREA).min(cap)); + if let Some(n) = usize::try_from(n).ok().filter(|&n| n > 0) { + let boot = crate::read_at(disk, 0, n)?; + budget.count_entry()?; + let e = Entry::new("<boot-area>".to_string(), boot); + if let Some(t) = emit_entry(e, budget, &mut *visit)? { + return Ok(Some(t)); + } + } + } + for region in regions { + let (name, off, len) = match region { + Region::Member(name, off, len) => (name, off, len), + // A part of the table that was not walked arrives as it always did. + Region::Unwalked(..) => { + if let Some(t) = crate::stream::stream_stored(disk, budget, &mut *visit, [region])? + { + return Ok(Some(t)); + } + continue; + } + }; + let found = if len > cap { + let head = crate::read_at(&mut Window::new(disk, off, len), 0, HEAD)?; + match sniff(&head) { + Some(fs) => walk_fs(fs, disk, off, len, &name, budget, &mut *visit)?, + None if mode == Mode::Raw => stream_volume(disk, name, off, len, budget, visit)?, + None => { + let why = "a volume larger than the buffer limit that is not a filesystem \ + exav reads in place, so its bytes were not examined"; + report(&name, len, why, budget, &mut *visit)? + } + } + } else { + stream_volume(disk, name, off, len, budget, &mut *visit)? + }; + if found.is_some() { + return Ok(found); + } + } + Ok(None) +} + +/// Hand the visitor the bytes of a volume as a stream. +fn stream_volume<D: Read + Seek, T>( + disk: &mut D, + name: String, + off: u64, + len: u64, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + crate::stream::stream_stored(disk, budget, visit, [Region::Member(name, off, len)]) +} diff --git a/crates/exav-unpack/src/formats/wim/mod.rs b/crates/exav-unpack/src/formats/wim/mod.rs index a52c2a73..68c78d38 100644 --- a/crates/exav-unpack/src/formats/wim/mod.rs +++ b/crates/exav-unpack/src/formats/wim/mod.rs @@ -27,8 +27,13 @@ #![deny(clippy::arithmetic_side_effects)] use std::collections::HashMap; +use std::io::{self, Read}; -use crate::{Budget, Entry, LimitHit, Sink}; +use sha1::{Digest, Sha1}; + +use crate::source::{ByteSource, Reader}; +use crate::stream::{MemberMeta, Visit, emit_entry, emit_stream}; +use crate::{Budget, Entry, LimitHit}; /// One offset-table entry: a 24-byte resource header, then part number, /// reference count and the 20-byte SHA-1. @@ -113,105 +118,250 @@ fn resource_header(data: &[u8], off: usize) -> Resource { } } -fn sha1_of(data: &[u8]) -> [u8; HASH_LEN] { - use sha1::{Digest, Sha1}; - let mut h = Sha1::new(); - h.update(data); - let out = h.finalize(); - let mut r = [0u8; HASH_LEN]; - r.copy_from_slice(&out); - r +/// The most a chunk may decode to. The codecs' windows go to 2 MiB; a header +/// that names more is not a chunk size any image has. +const MAX_CHUNK: u32 = 1 << 24; + +/// A resource's bytes, decoded a chunk at a time as they are read. +struct ResourceReader<'a> { + src: &'a dyn ByteSource, + codec: Codec, + /// Where the chunks' bytes start in the image, and how many there are. + body: usize, + body_len: usize, + /// The start of chunks 1 to n-1 in the body, as the image gives them. + table: Vec<u8>, + wide: bool, + chunk: usize, + n_chunks: usize, + original: u64, + /// The next chunk to decode, and how many bytes have been decoded. + next: usize, + given: u64, + /// The chunk being served, and how much of it has been. + buf: Vec<u8>, + at: usize, } -/// Decompress one resource. `Err` names a reason worth reporting; `Ok` bytes are -/// not checked against the recorded hash here. -fn read_resource( - data: &[u8], +fn bad(why: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, why) +} + +impl ResourceReader<'_> { + /// Where chunk `i` starts in the body. + fn start(&self, i: usize) -> usize { + let entry = if self.wide { 8 } else { 4 }; + let at = i.saturating_sub(1).saturating_mul(entry); + if i == 0 { + 0 + } else if self.wide { + crate::bytes::to_usize(le_u64(&self.table, at)) + } else { + le_u32(&self.table, at) as usize + } + } + + /// Decode the chunk that is next into `buf`. + fn decode(&mut self) -> io::Result<()> { + let i = self.next; + let s = self.start(i); + let e = if i.saturating_add(1) < self.n_chunks { + self.start(i.saturating_add(1)) + } else { + self.body_len + }; + if s > e || e > self.body_len { + return Err(bad("WIM chunk lies outside its resource")); + } + let want = crate::bytes::to_usize( + (self.chunk as u64).min(self.original.saturating_sub(self.given)), + ); + // A chunk that did not compress is stored verbatim, and is then exactly + // as long as what it decodes to; only that much of it is read. + let stored = e.saturating_sub(s) >= want; + let len = if stored { want } else { e.saturating_sub(s) }; + let cdata = self.src.window(self.body.saturating_add(s), len); + if cdata.len() != len { + return Err(bad("WIM chunk could not be read")); + } + self.buf = if stored { + cdata.into_owned() + } else { + match self.codec { + Codec::Xpress => xpress::decompress(&cdata, want).ok_or(bad("WIM XPRESS chunk"))?, + Codec::Lzx => lzx::decompress(&cdata, want).ok_or(bad("WIM LZX chunk"))?, + // An uncompressed image should have had the flag clear; if a + // resource claims compression anyway there is nothing to + // decode it with. + Codec::None | Codec::Lzms => return Err(bad("WIM resource names no chunk codec")), + } + }; + self.at = 0; + self.next = i.saturating_add(1); + self.given = self.given.saturating_add(self.buf.len() as u64); + Ok(()) + } +} + +impl Read for ResourceReader<'_> { + fn read(&mut self, out: &mut [u8]) -> io::Result<usize> { + loop { + if let Some(rest) = self.buf.get(self.at..).filter(|r| !r.is_empty()) { + let n = rest.len().min(out.len()); + out[..n].copy_from_slice(&rest[..n]); + self.at = self.at.saturating_add(n); + return Ok(n); + } + if self.next >= self.n_chunks || self.given >= self.original { + return Ok(0); + } + self.decode()?; + } + } +} + +/// A reader of resource `res`'s bytes. `Err` names a reason worth reporting; +/// the bytes are not checked against the recorded hash here. +fn open_resource<'a>( + src: &'a dyn ByteSource, res: &Resource, codec: Codec, chunk_size: u32, - budget: &mut Budget, -) -> Result<Vec<u8>, &'static str> { - let start = crate::bytes::to_usize(res.offset); - let raw = crate::bytes::at(data, start, crate::bytes::to_usize(res.size_in_wim)) - .ok_or("WIM resource lies outside the image")?; + max_buffer: u64, +) -> Result<Box<dyn Read + 'a>, &'static str> { + let outside = "WIM resource lies outside the image"; + let start = usize::try_from(res.offset).map_err(|_| outside)?; + let len = usize::try_from(res.size_in_wim).map_err(|_| outside)?; + let end = start.checked_add(len).filter(|&e| e <= src.len()); + let Some(end) = end else { + return Err(outside); + }; if res.flags & RESOURCE_COMPRESSED == 0 { - return Ok(raw.to_vec()); + return Ok(Box::new(Reader::range(src, start, end))); } if codec == Codec::Lzms { return Err("WIM LZMS-compressed resource"); } - let cap = budget.limits.max_buffer_bytes; - if res.original_size > cap { - return Err("WIM resource exceeds max-buffer"); + let chunk = chunk_size.max(1); + if chunk > MAX_CHUNK { + return Err("WIM chunk size is larger than any codec's window"); } - let original = crate::bytes::to_usize(res.original_size); - let chunk = chunk_size.max(1) as usize; - let n_chunks = original.div_ceil(chunk).max(1); + let chunk = chunk as usize; + let original = res.original_size; + let n_chunks = crate::bytes::to_usize(original.div_ceil(chunk as u64)).max(1); // The chunk table gives the start of chunks 1..n-1 relative to the end of // the table; chunk 0 always starts there. Entries are u32 unless the // uncompressed resource is over 4 GiB. - let wide = res.original_size > u32::MAX as u64; + let wide = original > u64::from(u32::MAX); let entry = if wide { 8 } else { 4 }; let table_len = n_chunks.saturating_sub(1).saturating_mul(entry); - let table = raw.get(..table_len).ok_or("WIM chunk table is truncated")?; - let body = &raw[table_len..]; + if table_len > len { + return Err("WIM chunk table is truncated"); + } + if table_len as u64 > max_buffer { + return Err("WIM chunk table exceeds max-buffer"); + } + let table = src.window(start, table_len); + if table.len() != table_len { + return Err("WIM chunk table is truncated"); + } + Ok(Box::new(ResourceReader { + src, + codec, + body: start.saturating_add(table_len), + body_len: len.saturating_sub(table_len), + table: table.into_owned(), + wide, + chunk, + n_chunks, + original, + next: 0, + given: 0, + buf: Vec::new(), + at: 0, + })) +} - let chunk_start = |i: usize| -> usize { - let at = i.saturating_sub(1).saturating_mul(entry); - if i == 0 { - 0 - } else if wide { - crate::bytes::to_usize(le_u64(table, at)) - } else { - le_u32(table, at) as usize - } +/// Resource `res` read whole, up to `max_buffer` bytes. +fn read_resource( + src: &dyn ByteSource, + res: &Resource, + codec: Codec, + chunk_size: u32, + max_buffer: u64, +) -> Result<Vec<u8>, &'static str> { + let compressed = res.flags & RESOURCE_COMPRESSED != 0; + let size = if compressed { + res.original_size + } else { + res.size_in_wim }; + if size > max_buffer { + return Err("WIM resource exceeds max-buffer"); + } + let mut reader = open_resource(src, res, codec, chunk_size, max_buffer)?; + let mut out = Vec::new(); + reader + .by_ref() + .take(size.saturating_add(1)) + .read_to_end(&mut out) + .map_err(|_| "WIM resource did not decode")?; + Ok(out) +} - let mut out = Vec::with_capacity(original); - for i in 0..n_chunks { - let s = chunk_start(i); - let e = if i.saturating_add(1) < n_chunks { - chunk_start(i.saturating_add(1)) - } else { - body.len() - }; - let Some(cdata) = body.get(s..e.max(s)) else { - return Err("WIM chunk lies outside its resource"); - }; - let want = chunk.min(original.saturating_sub(out.len())); - // A chunk that did not compress is stored verbatim, and is then exactly - // as long as what it decodes to. - if cdata.len() >= want { - out.extend_from_slice(&cdata[..want]); - continue; +/// A resource's bytes, hashed as they are read: at the end they are the +/// resource the image recorded, or they are not. +struct Checked<'a> { + inner: Box<dyn Read + 'a>, + hasher: Sha1, + expected: [u8; HASH_LEN], +} + +impl Read for Checked<'_> { + fn read(&mut self, out: &mut [u8]) -> io::Result<usize> { + let n = self.inner.read(out)?; + if n > 0 { + self.hasher.update(&out[..n]); + } else if !out.is_empty() && self.hasher.clone().finalize().as_slice() != self.expected { + return Err(crate::checksum_mismatch("WIM SHA-1")); } - let decoded = match codec { - Codec::Xpress => xpress::decompress(cdata, want).ok_or("WIM XPRESS chunk")?, - Codec::Lzx => lzx::decompress(cdata, want).ok_or("WIM LZX chunk")?, - // An uncompressed image should have had the flag clear; if a - // resource claims compression anyway there is nothing to decode it - // with. - Codec::None | Codec::Lzms => return Err("WIM resource names no chunk codec"), - }; - out.extend_from_slice(&decoded); + Ok(n) } - out.truncate(original); - Ok(out) } mod lzx; -pub(crate) fn extract_wim<R>( - data: &[u8], +/// A member that is only a report. +fn report<T>( + name: String, + size: u64, + reason: &'static str, budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + emit_entry(Entry::unsupported(name, size, false, reason), budget, visit) +} + +/// How much of the image is read to tell it is one and to read its header. +const HEADER_READ: usize = 256; + +/// Walk a WIM image. Its header, its table of resources and its directory +/// tree are read from where they are in the source; each file resource is then +/// decoded a chunk at a time as it is read, so neither the image nor a +/// resource is held. +pub(crate) fn walk<T>( + src: &dyn ByteSource, + budget: &mut Budget, + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let head = src.window(0, HEADER_READ); + let data: &[u8] = &head; if !crate::formats::sniff::is(data, crate::Format::Wim) { return Ok(None); } + let max_buffer = budget.limits.max_buffer_bytes; let flags = le_u32(data, 16); let codec = if flags & FLAG_COMPRESS_LZMS != 0 { Codec::Lzms @@ -229,44 +379,45 @@ pub(crate) fn extract_wim<R>( // The offset table is itself a resource. let table_res = resource_header(data, 48); - let Some(table) = crate::bytes::at( - data, - crate::bytes::to_usize(table_res.offset), - crate::bytes::to_usize(table_res.size_in_wim), - ) else { + let table_at = usize::try_from(table_res.offset).ok(); + let table_len = usize::try_from(table_res.size_in_wim).ok(); + let inside = table_at + .zip(table_len) + .filter(|(at, len)| at.checked_add(*len).is_some_and(|end| end <= src.len())); + let Some((table_at, table_len)) = inside else { budget.count_entry()?; - return Ok(visit( - Entry::unsupported( - "<wim-offset-table>".to_string(), - 0, - false, - "WIM offset table lies outside the image", - ), + return report( + "<wim-offset-table>".to_string(), + 0, + "WIM offset table lies outside the image", budget, - )); + visit, + ); }; + let listed = table_len.checked_div(TABLE_ENTRY_LEN).unwrap_or(0); + let table = src.window( + table_at, + listed.min(MAX_RESOURCES).saturating_mul(TABLE_ENTRY_LEN), + ); let mut resources: Vec<Resource> = Vec::new(); - let listed = table.len().checked_div(TABLE_ENTRY_LEN).unwrap_or(0); for i in 0..listed.min(MAX_RESOURCES) { let o = i.saturating_mul(TABLE_ENTRY_LEN); - let mut r = resource_header(table, o); - if let Some(h) = crate::bytes::at(table, o.saturating_add(30), HASH_LEN) { + let mut r = resource_header(&table, o); + if let Some(h) = crate::bytes::at(&table, o.saturating_add(30), HASH_LEN) { r.hash.copy_from_slice(h); } resources.push(r); } if listed > MAX_RESOURCES { budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - format!("<wim-resources-beyond-{MAX_RESOURCES}>"), - 0, - false, - "too many WIM resources to enumerate them all", - ), + if let Some(r) = report( + format!("<wim-resources-beyond-{MAX_RESOURCES}>"), + 0, + "too many WIM resources to enumerate them all", budget, - ) { + &mut *visit, + )? { return Ok(Some(r)); } } @@ -279,7 +430,7 @@ pub(crate) fn extract_wim<R>( .iter() .filter(|r| r.flags & RESOURCE_METADATA != 0) { - if let Ok(meta) = read_resource(data, res, codec, chunk_size, budget) { + if let Ok(meta) = read_resource(src, res, codec, chunk_size, max_buffer) { collect_names(&meta, &mut names); } } @@ -297,57 +448,52 @@ pub(crate) fn extract_wim<R>( // The rest of this resource is in another part of the image set, // which exav is not scanning. budget.count_entry()?; - if let Some(r) = visit( - Entry::unsupported( - name, - res.size_in_wim, - false, - "WIM resource continues in another part of a spanned image", - ), + if let Some(r) = report( + name, + res.size_in_wim, + "WIM resource continues in another part of a spanned image", budget, - ) { + &mut *visit, + )? { return Ok(Some(r)); } continue; } budget.count_entry()?; - let decoded = read_resource(data, res, codec, chunk_size, budget); - let bytes = match decoded { - // Decoded in full. A SHA-1 mismatch after that hides nothing: the - // bytes are scanned, as a ZIP member's are, and reported only when - // checksums are verified. - Ok(b) if !budget.should_verify_checksums() || sha1_of(&b) == res.hash => b, - Ok(_) => { - if let Some(r) = visit( - Entry::unsupported( - name, - res.size_in_wim, - false, - "WIM resource did not match its recorded SHA-1 after decoding", - ), - budget, - ) { - return Ok(Some(r)); - } - continue; - } + let opened = open_resource(src, res, codec, chunk_size, max_buffer); + let reader = match opened { + Ok(r) => r, Err(reason) => { - if let Some(r) = visit( - Entry::unsupported(name, res.size_in_wim, false, reason), - budget, - ) { + if let Some(r) = report(name, res.size_in_wim, reason, budget, &mut *visit)? { return Ok(Some(r)); } continue; } }; - let cap = budget.reserve()?; - if bytes.len() as u64 > cap { - return Err(LimitHit::new(format!("wim member '{name}' exceeds budget"))); - } - budget.commit(bytes.len() as u64); - if let Some(r) = visit(Entry::new(name, bytes), budget) { + // A SHA-1 mismatch once the resource has decoded hides nothing: the + // bytes are scanned, as a ZIP member's are, and it is an error at the + // end of the member only when checksums are verified. + let mut reader: Box<dyn Read + '_> = if budget.should_verify_checksums() { + Box::new(Checked { + inner: reader, + hasher: Sha1::new(), + expected: res.hash, + }) + } else { + reader + }; + let meta = MemberMeta { + name, + comp_size: res.size_in_wim, + size: Some(if res.flags & RESOURCE_COMPRESSED == 0 { + res.size_in_wim + } else { + res.original_size + }), + ..MemberMeta::default() + }; + if let Some(r) = emit_stream(&meta, &mut *reader, budget, &mut *visit)? { return Ok(Some(r)); } } diff --git a/crates/exav-unpack/src/formats/xar.rs b/crates/exav-unpack/src/formats/xar.rs index 6daf1cd5..4cce9141 100644 --- a/crates/exav-unpack/src/formats/xar.rs +++ b/crates/exav-unpack/src/formats/xar.rs @@ -4,125 +4,119 @@ //! Layout: a 28-byte header, a zlib-compressed XML table of contents, then the //! file heap. The TOC describes each file's data block as an offset/length into //! the heap plus an encoding (commonly gzip/zlib). We parse the header, inflate -//! the TOC, and pull each data block out of the heap (inflating zlib-encoded -//! blocks), emitting members for the engine to recurse into. The TOC XML is +//! the TOC, and read each data block from the heap where it is (inflating +//! zlib-encoded blocks as they are read), emitting members for the engine to +//! recurse into. Neither the archive nor a member is held. The TOC XML is //! scanned with a small targeted parser rather than a full XML dependency. -use crate::*; +use crate::source::{ByteSource, Reader}; +use crate::stream::{MemberMeta, Visit, emit_entry, emit_stream}; +use crate::{Budget, Entry, LimitHit}; use flate2::read::ZlibDecoder; -use std::io::Read; +use std::io::{BufReader, Read}; const MAGIC: &[u8] = b"xar!"; +/// How much of what is read from the source for a member is buffered. +const BUFFER: usize = 64 * 1024; + +/// The most the table of contents inflates to. +const TOC_CAP: u64 = 64 * 1024 * 1024; + fn be_u16(d: &[u8], p: usize) -> u64 { - u16::from_be_bytes([d[p], d[p + 1]]) as u64 + crate::bytes::at(d, p, 2) + .map(|b| u64::from(u16::from_be_bytes([b[0], b[1]]))) + .unwrap_or(0) } + fn be_u64(d: &[u8], p: usize) -> u64 { - u64::from_be_bytes([ - d[p], - d[p + 1], - d[p + 2], - d[p + 3], - d[p + 4], - d[p + 5], - d[p + 6], - d[p + 7], - ]) + crate::bytes::at(d, p, 8) + .and_then(|b| <[u8; 8]>::try_from(b).ok()) + .map(u64::from_be_bytes) + .unwrap_or(0) } -pub(crate) fn extract_xar<R>( - data: &[u8], +/// Walk a XAR archive. The table of contents is read from the front of the +/// source and each member from its place in the heap. +pub(crate) fn walk<T>( + src: &dyn ByteSource, budget: &mut Budget, - visit: Sink<R>, -) -> Result<Option<R>, LimitHit> { - if data.len() < 28 || !data.starts_with(MAGIC) { - return Err(LimitHit::new("xar: bad magic".to_string())); + visit: Visit<T>, +) -> Result<Option<T>, LimitHit> { + let head = src.window(0, 28); + if head.len() < 28 || !head.starts_with(MAGIC) { + return Err(LimitHit::corrupt("xar: bad magic".to_string())); } - let header_size = be_u16(data, 4) as usize; - let toc_comp = be_u64(data, 8) as usize; - let toc_uncomp = be_u64(data, 16) as usize; + let len = src.len(); + let header_size = crate::bytes::to_usize(be_u16(&head, 4)); + let toc_comp = crate::bytes::to_usize(be_u64(&head, 8)); + let toc_uncomp = be_u64(&head, 16); let toc_start = header_size; let toc_end = toc_start.saturating_add(toc_comp); - if toc_end > data.len() { - return Err(LimitHit::new("xar: truncated TOC".to_string())); + if toc_end > len { + return Err(LimitHit::corrupt("xar: truncated TOC".to_string())); } let heap_start = toc_end; // Inflate the TOC (bounded by its declared uncompressed size, capped). - let toc_cap = toc_uncomp.min(64 * 1024 * 1024); let mut toc = Vec::new(); - ZlibDecoder::new(&data[toc_start..toc_end]) - .take(toc_cap as u64) - .read_to_end(&mut toc) - .map_err(|e| LimitHit::new(format!("xar: TOC inflate: {e}")))?; + ZlibDecoder::new(BufReader::with_capacity( + BUFFER, + Reader::range(src, toc_start, toc_end), + )) + .take(toc_uncomp.min(TOC_CAP)) + .read_to_end(&mut toc) + .map_err(|e| LimitHit::new(format!("xar: TOC inflate: {e}")))?; let toc = String::from_utf8_lossy(&toc); for (name, blk) in iter_data_blocks(&toc) { let off = heap_start.saturating_add(blk.offset); - let end = off.saturating_add(blk.length).min(data.len()); + let end = off.saturating_add(blk.length).min(len); // A zero-length block has nothing to scan; a block starting past EOF, or // one the bound above clamped short, is truncated — the declared bytes // are absent from this file rather than hidden in it, so what exists is // scanned and a clean result is honest. See docs/QUIRKS.md. - if off >= data.len() || blk.length == 0 { + if off >= len || blk.length == 0 { continue; } - let raw = &data[off..end]; budget.count_entry()?; - let cap = budget.reserve()?; - let mut why = None; - let member = if blk.gzip { - // Heap data is zlib-wrapped; inflate bounded by the declared size. - let want = blk.size.min(cap as usize) as u64; - let rest_absent = off.saturating_add(blk.length) > data.len(); - let s = match crate::inflate::zlib_reader(raw) { - Ok(Some(z)) => Some(crate::salvage(z.take(want + 1), cap)), + let raw = BufReader::with_capacity(BUFFER, Reader::range(src, off, end)); + let meta = MemberMeta { + name: name.clone(), + comp_size: end.saturating_sub(off) as u64, + size: Some(blk.size as u64), + ..MemberMeta::default() + }; + let found = if blk.gzip { + // Heap data is zlib-wrapped. + match crate::inflate::zlib_reader(raw) { + Ok(Some(mut z)) => emit_stream(&meta, &mut z, budget, &mut *visit)?, // Cut inside the zlib header: no byte of the stream is here. - Err(_) if rest_absent => Some(crate::Salvaged { - data: Vec::new(), - over_cap: false, - undecoded: false, - cut_short: true, - }), - _ => None, - }; - let Some(s) = s.filter(|s| !(s.undecoded && s.data.is_empty())) else { - // The TOC names this member and its bytes are in the heap; the - // stream just would not inflate. Dropping it would leave a file - // the archive contains with no trace in the result. - if let Some(r) = visit( - Entry::unsupported( - name, - blk.size as u64, - false, - "XAR member would not inflate", - ), - budget, - ) { - return Ok(Some(r)); + Err(_) if off.saturating_add(blk.length) > len => { + emit_stream(&meta, &mut std::io::empty(), budget, &mut *visit)? + } + _ => { + // The TOC names this member and its bytes are in the heap; + // the stream just would not inflate. Dropping it would leave + // a file the archive contains with no trace in the result. + emit_entry( + Entry::unsupported( + name, + blk.size as u64, + false, + "XAR member would not inflate", + ), + budget, + &mut *visit, + )? } - continue; - }; - if s.over_cap { - return Err(LimitHit::new(format!("xar member '{name}' exceeds budget"))); } - why = s.part_way(rest_absent).then_some( - "XAR member failed to inflate part way; the bytes before the failure were scanned", - ); - s.data } else { - if raw.len() as u64 > cap { - return Err(LimitHit::new(format!("xar member '{name}' exceeds budget"))); - } - raw.to_vec() + let mut raw = raw; + emit_stream(&meta, &mut raw, budget, &mut *visit)? }; - budget.commit(member.len() as u64); - let entry = Entry { - unsupported: why, - ..Entry::new(name, member) - }; - if let Some(r) = visit(entry, budget) { + if let Some(r) = found { return Ok(Some(r)); } } @@ -192,7 +186,8 @@ fn inner_text(hay: &str, tag: &str) -> Option<String> { #[cfg(test)] mod tests { use super::*; - use flate2::{write::ZlibEncoder, Compression}; + use crate::{Format, Limits, Member, extract}; + use flate2::{Compression, write::ZlibEncoder}; use std::io::Write; fn zlib(data: &[u8]) -> Vec<u8> { @@ -201,17 +196,28 @@ mod tests { e.finish().unwrap() } - #[test] - fn extracts_one_gzip_member() { - let payload = b"EICAR-ish payload bytes"; - let heap = zlib(payload); - let toc_xml = format!( - "<xar><toc><file><name>evil.sh</name><data><offset>0</offset>\ - <length>{}</length><size>{}</size>\ - <encoding style=\"application/x-gzip\"/></data></file></toc></xar>", - heap.len(), - payload.len() - ); + /// An archive of `members`: name, bytes, and whether they are stored + /// zlib-compressed in the heap. + fn archive(members: &[(&str, &[u8], bool)]) -> Vec<u8> { + let mut heap = Vec::new(); + let mut files = String::new(); + for (name, data, deflate) in members { + let stored = if *deflate { zlib(data) } else { data.to_vec() }; + let style = if *deflate { + "application/x-gzip" + } else { + "application/octet-stream" + }; + files.push_str(&format!( + "<file><name>{name}</name><data><offset>{}</offset><length>{}</length>\ + <size>{}</size><encoding style=\"{style}\"/></data></file>", + heap.len(), + stored.len(), + data.len() + )); + heap.extend_from_slice(&stored); + } + let toc_xml = format!("<xar><toc>{files}</toc></xar>"); let toc_comp = zlib(toc_xml.as_bytes()); let mut buf = Vec::new(); buf.extend_from_slice(MAGIC); @@ -222,11 +228,76 @@ mod tests { buf.extend_from_slice(&1u32.to_be_bytes()); // cksum alg buf.extend_from_slice(&toc_comp); buf.extend_from_slice(&heap); + buf + } + #[test] + fn extracts_one_gzip_member() { + let payload = b"EICAR-ish payload bytes"; + let buf = archive(&[("evil.sh", payload, true)]); let mut budget = Budget::new(Limits::default()); let entries = extract(Format::Xar, &buf, &mut budget).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "evil.sh"); assert_eq!(entries[0].data, payload); } + + /// What `walk` gives of `buf` read from a source that is not in memory, + /// under a buffer limit of `cap`: each member's name, bytes and whether + /// it was read as it goes. + fn off_a_source(buf: &[u8], cap: u64) -> Vec<(String, Vec<u8>, bool)> { + let cache = + crate::source::BlockCache::with_sizes(std::io::Cursor::new(buf.to_vec()), 64, 4 * 64) + .unwrap(); + let mut budget = Budget::new(Limits { + max_buffer_bytes: cap, + ..Limits::default() + }); + let mut out = Vec::new(); + crate::walk(Format::Xar, &cache, &mut budget, &mut |meta, content, _| { + let (data, streamed) = match content { + None => (Vec::new(), false), + Some(Member::Bytes(b)) => (b, false), + Some(Member::Stream(r)) => { + let mut b = Vec::new(); + r.read_to_end(&mut b).unwrap(); + (b, true) + } + }; + assert!(meta.unsupported.is_none(), "{}", meta.name); + out.push((meta.name.clone(), data, streamed)); + None::<()> + }) + .unwrap(); + out + } + + #[test] + fn members_are_read_as_they_go_from_a_source_that_is_not_in_memory() { + let big: Vec<u8> = (0..70_000u32).map(|i| (i * 31 % 251) as u8).collect(); + let buf = archive(&[ + ("deflated.bin", &big, true), + ("stored.bin", &big, false), + ("small.txt", b"small", true), + ]); + let got = off_a_source(&buf, 100); + assert_eq!( + got, + [ + ("deflated.bin".to_string(), big.clone(), true), + ("stored.bin".to_string(), big, true), + ("small.txt".to_string(), b"small".to_vec(), true), + ] + ); + } + + #[test] + fn a_member_cut_short_by_the_end_of_the_archive_comes_out_as_far_as_it_goes() { + let big: Vec<u8> = (0..70_000u32).map(|i| (i * 31 % 251) as u8).collect(); + let mut buf = archive(&[("stored.bin", &big, false)]); + buf.truncate(buf.len() - 1000); + let got = off_a_source(&buf, 100); + assert_eq!(got.len(), 1); + assert_eq!(got[0].1, big[..big.len() - 1000]); + } } diff --git a/crates/exav-unpack/src/formats/xlm.rs b/crates/exav-unpack/src/formats/xlm.rs index 03a35db1..182af38c 100644 --- a/crates/exav-unpack/src/formats/xlm.rs +++ b/crates/exav-unpack/src/formats/xlm.rs @@ -1,33 +1,29 @@ //! Excel 4.0 (XLM) macro surfacing from an OLE2 `Workbook`/`Book` BIFF stream. //! -//! XLM macros predate VBA: the macro lives in cells of a dedicated *macro sheet* -//! rather than a VBA project, so they slip past VBA-only macro detection. A -//! `BOUNDSHEET` record (`0x0085`) with sheet-type `1` marks an Excel 4.0 macro -//! sheet; the macro formulas live in `FORMULA` records whose string constants -//! (URLs, `EXEC`/`CALL` arguments, alert text) are stored as `ptgStr` tokens. +//! XLM macros predate VBA: the macro lives in cells of a dedicated *macro +//! sheet* rather than a VBA project, so they slip past VBA-only macro +//! detection. A `BOUNDSHEET` record with sheet type `1` marks an Excel 4.0 +//! macro sheet; the macro formulas live in `FORMULA` and `ARRAY` records as +//! `ptg` tokens, where the built-in functions (`EXEC`, `CALL`, `ALERT`, ...) +//! are numbers, invisible to a scan of the file's bytes. //! -//! We walk the BIFF record stream, detect any macro sheet, and — when present — -//! surface a synthetic `xlm_macro` artifact carrying the macro-sheet names, the -//! recovered string constants, and each macro formula's tokens decoded from the -//! `ptg` stream, including the **built-in function names** (`EXEC`/`CALL`/ -//! `ALERT`/…) which are stored as numeric ids and so are *invisible to a raw -//! byte scan*. So the engine's `Target:2`/`Doc.*` signatures match the real -//! function calls and `--alert-macros` fires for XLM the same way it does for -//! VBA. Every read is bounds-checked; a malformed stream yields `None`, never a -//! panic. -//! -//! The formula tokens are emitted in postfix (RPN) order — full infix -//! reconstruction with operator precedence and cell-reference formatting is a -//! follow-up, but every IOC-bearing token (function name, string, number) is -//! recovered, and the raw `Workbook` stream is scanned regardless. +//! We read the stream with the BIFF reader exav-render converts workbooks +//! with, and when a macro sheet is present surface a synthetic `xlm_macro` +//! artifact: the macro-sheet names, the defined names with what they refer to, +//! the text cells, and each formula as Excel shows it (`=EXEC("calc.exe")`), +//! so the engine's `Target:2` and `Doc.*` signatures match the function calls +//! and `--alert-macros` fires for XLM as it does for VBA. A formula the reader +//! refuses still gives the calls and constants it read before the token it +//! could not. Every read is bounds-checked; a malformed stream yields `None`, +//! never a panic. + +use super::biff::{self, Book, formula}; +use crate::bytes::{Cur, u16_at}; -/// BIFF record types we care about. -const R_BOF: u16 = 0x0809; -const R_BOUNDSHEET: u16 = 0x0085; const R_FORMULA: u16 = 0x0006; const R_LABEL: u16 = 0x0204; -const R_NAME: u16 = 0x0018; const R_ARRAY: u16 = 0x0221; +const R_EOF: u16 = 0x000A; /// Sheet-type (`dt`) byte in a `BOUNDSHEET` record marking an Excel 4.0 macro sheet. const DT_EXCEL4_MACRO: u8 = 1; @@ -35,270 +31,90 @@ const DT_EXCEL4_MACRO: u8 = 1; /// Total surfaced-string budget (defensive; the artifact only needs the IOCs). const MAX_XLM_ARTIFACT: usize = 512 * 1024; -#[inline] -fn u16le(d: &[u8], p: usize) -> Option<u16> { - Some(u16::from_le_bytes([*d.get(p)?, *d.get(p + 1)?])) -} - -/// Read a `ShortXLUnicodeString` (`cch:u8`, `grbit:u8`, chars) → `String`. -/// `grbit` bit 0 (`fHighByte`) selects 16-bit UTF-16 vs 8-bit compressed chars. -fn read_short_string(d: &[u8]) -> Option<String> { - let cch = *d.first()? as usize; - let grbit = *d.get(1)?; - let chars = d.get(2..)?; - if grbit & 1 == 0 { - // 8-bit compressed: each byte is the low half of a UTF-16 unit. - let n = cch.min(chars.len()); - Some(chars[..n].iter().map(|&b| b as char).collect()) - } else { - let n = cch.min(chars.len() / 2); - let units: Vec<u16> = (0..n) - .map(|i| u16::from_le_bytes([chars[i * 2], chars[i * 2 + 1]])) - .collect(); - Some(String::from_utf16_lossy(&units)) - } -} - /// True if `s` is worth surfacing: non-trivial length and mostly printable. fn is_useful(s: &str) -> bool { let t = s.trim(); t.len() >= 3 && t.chars().filter(|c| !c.is_control()).count() * 4 >= t.chars().count() * 3 } -/// Look up a BIFF/XLM built-in function name by its `ptgFunc`/`ptgFuncVar` id. -fn func_name(id: u16) -> Option<&'static str> { - let t = &super::xlm_functions::XLM_FUNCTIONS; - t.binary_search_by_key(&id, |&(k, _)| k) - .ok() - .map(|i| t[i].1) - .or_else(|| { - // Command-equivalent (macro) functions carry the 0x8000 bit; try both. - t.binary_search_by_key(&(id ^ 0x8000), |&(k, _)| k) - .ok() - .map(|i| t[i].1) - }) -} - -/// Read a `ptgStr` string (`cch:u8`, `grbit:u8`, chars) at `rgce[i+1..]`, -/// returning `(text, bytes_consumed_after_the_token_byte)`. -fn read_ptgstr(rgce: &[u8], i: usize) -> Option<(String, usize)> { - let cch = *rgce.get(i)? as usize; - let high = rgce.get(i + 1)? & 1 == 1; - let nbytes = if high { cch * 2 } else { cch }; - let chars = crate::bytes::at(rgce, i + 2, nbytes)?; - let s = if high { - let units: Vec<u16> = chars - .as_chunks::<2>() - .0 - .iter() - .copied() - .map(u16::from_le_bytes) - .collect(); - String::from_utf16_lossy(&units) - } else { - chars.iter().map(|&b| b as char).collect() - }; - Some((s, 2 + nbytes)) -} - -/// Walk a `FORMULA`/`ARRAY` `rgce` token array, emitting the IOC-bearing tokens -/// — built-in **function names** (`EXEC`/`CALL`/`ALERT`/…, invisible to a raw -/// byte scan because they're stored as numeric ids), **string constants**, and -/// numeric literals — as a single reconstructed formula line. Fully -/// bounds-checked; an unknown/complex token (`ptgArray`, `ptgMemArea`, …) ends -/// the walk cleanly (partial line kept). `ptg` class bits (value/ref/array, -/// `0x20`/`0x40`/`0x60`) are normalised to the base type. -fn parse_formula(rgce: &[u8], out: &mut Vec<String>) { - let mut items: Vec<String> = Vec::new(); - let mut i = 0usize; - let mut guard = 0usize; - while i < rgce.len() { - guard += 1; - if guard > 100_000 || items.len() > 4096 { - break; - } - let tok = rgce[i]; - let base = if tok >= 0x20 { - 0x20 | (tok & 0x1f) - } else { - tok - }; - i += 1; - // Operand byte-lengths for the tokens we walk through; `None` = special. - let fixed: Option<usize> = match base { - 0x01 | 0x02 => Some(4), // ptgExp / ptgTbl - 0x03..=0x16 => Some(0), // binary/unary operators, paren, missarg - 0x1c | 0x1d => Some(1), // ptgErr / ptgBool - 0x1e => Some(2), // ptgInt - 0x1f => Some(8), // ptgNum - 0x23 => Some(4), // ptgName (BIFF8) - 0x24 | 0x2a | 0x2c => Some(4), // ptgRef / ptgRefErr / ptgRefN - 0x25 | 0x2b | 0x2d => Some(8), // ptgArea / ptgAreaErr / ptgAreaN - 0x39 | 0x3a | 0x3c => Some(6), // ptgNameX / ptgRef3d / ptgRefErr3d - 0x3b | 0x3d => Some(10), // ptgArea3d / ptgAreaErr3d - _ => None, - }; - if let Some(n) = fixed { - if base == 0x1e { - if let Some(b) = crate::bytes::at(rgce, i, 2) { - items.push(u16::from_le_bytes([b[0], b[1]]).to_string()); - } - } - i += n; - continue; - } - match base { - 0x17 => match read_ptgstr(rgce, i) { - Some((s, consumed)) => { - items.push(format!("\"{}\"", s.replace('"', "\"\""))); - i += consumed; - } - None => break, - }, - 0x19 => { - // ptgAttr: grbit + 2 bytes; a `bAttrChoose` jump table is complex. - match rgce.get(i) { - Some(&g) if g & 0x04 == 0 => i += 3, - _ => break, - } - } - 0x21 => { - // ptgFunc: 2-byte function id. - match crate::bytes::at(rgce, i, 2) { - Some(b) => { - let id = u16::from_le_bytes([b[0], b[1]]); - items.push(func_name(id).unwrap_or("FUNC").to_string()); - i += 2; - } - None => break, - } - } - 0x22 => { - // ptgFuncVar: 1-byte arg count + 2-byte function id. - match rgce.get(i + 1..i + 3) { - Some(b) => { - let id = u16::from_le_bytes([b[0], b[1]]); - items.push(func_name(id).unwrap_or("FUNC").to_string()); - i += 3; - } - None => break, - } - } - _ => break, // unknown / complex token — stop cleanly - } - } - if !items.is_empty() { - let line = items.join(" "); - if is_useful(&line) { - out.push(line); - } - } -} - -/// Compute the `rgce` token slice of a `FORMULA` (0x0006) record body: a 20-byte -/// header, then `cce:u16`, then the tokens. -fn formula_rgce(body: &[u8]) -> Option<&[u8]> { - let cce = u16le(body, 20)? as usize; - crate::bytes::at(body, 22, cce) -} - /// Parse the OLE2 streams; if a `Workbook`/`Book` stream contains at least one -/// Excel 4.0 macro sheet, return a surfaced `xlm_macro` artifact (macro-sheet -/// names + recovered string constants). Returns `None` when no XLM macro sheet -/// is present or the stream can't be parsed. +/// Excel 4.0 macro sheet, return a surfaced `xlm_macro` artifact. Returns +/// `None` when no XLM macro sheet is present or the stream can't be parsed. pub(crate) fn xlm_macro_artifact(streams: &[(String, &[u8])]) -> Option<Vec<u8>> { // OLE stream names carry a path prefix (e.g. `/Workbook`); match the basename. let (_, data) = streams.iter().find(|(n, _)| { let base = n.rsplit(['/', '\\']).next().unwrap_or(n); base.eq_ignore_ascii_case("Workbook") || base.eq_ignore_ascii_case("Book") })?; - if u16le(data, 0)? != R_BOF { + let mut recs = biff::records(data); + let first = recs.next()?; + // BIFF8 and BIFF5; the older kinds are not in compound files. + if first.id != 0x0809 { + return None; + } + let mut book = Book::new(biff::bof_version(&first)); + for r in recs.by_ref().take_while(|r| r.id != R_EOF) { + book.record(&r); + } + book.finish(); + if !book.sheets.iter().any(|s| s.kind == DT_EXCEL4_MACRO) { return None; } - let mut macro_sheets: Vec<String> = Vec::new(); - let mut strings: Vec<String> = Vec::new(); + let mut lines: Vec<String> = Vec::new(); let mut total = 0usize; - let mut pos = 0usize; - // Bound the number of records walked (defensive against a crafted stream). - let mut guard = 0usize; - - while pos + 4 <= data.len() { - guard += 1; - if guard > 1_000_000 || total > MAX_XLM_ARTIFACT { + let ctx = book.ctx(&book.ext5); + for n in &book.names { + let text = match formula::decode_partial(&ctx, &n.rgce, &n.extra, 0, 0) { + p if p.text.is_empty() => n.name.clone(), + p => format!("{}={}", n.name, p.text), + }; + if is_useful(&text) { + total += text.len(); + lines.push(text); + } + } + for r in biff::records(data) { + if total > MAX_XLM_ARTIFACT { break; } - let typ = u16le(data, pos)?; - let len = u16le(data, pos + 2)? as usize; - pos += 4; - let end = (pos + len).min(data.len()); - let body = &data[pos..end]; - pos = end; - - match typ { - R_BOUNDSHEET => { - // [lbPlyPos:u32][hsState:u8][dt:u8][name: ShortXLUnicodeString] - if body.get(5).copied() == Some(DT_EXCEL4_MACRO) { - let name = body - .get(6..) - .and_then(read_short_string) - .unwrap_or_default(); - total += name.len(); - macro_sheets.push(name); - } - } - R_LABEL => { - // [rw:u16][col:u16][ixfe:u16][XLUnicodeString: cch:u16 ...] - if let Some(s) = body.get(6..).and_then(|b| { - let cch = u16le(b, 0)? as usize; - read_short_string_wide(b.get(2..)?, cch) - }) { - if is_useful(&s) { - total += s.len(); - strings.push(s); - } - } - } - R_NAME => { - // Defined names (e.g. built-in `Auto_Open`). Name text is a - // ShortXLUnicodeString at a fixed offset in the record. - if let Some(s) = body.get(15..).and_then(read_short_string) { - if is_useful(&s) { - total += s.len(); - strings.push(s); - } - } - } + let body: &[u8] = &r.data; + let text = match r.id { R_FORMULA => { - if let Some(rgce) = formula_rgce(body) { - parse_formula(rgce, &mut strings); - } + // 20 bytes of cell, value and flags, then cce:u16 and the tokens. + let cce = u16_at(body, 20) as usize; + crate::bytes::at(body, 22, cce) + .map(|rgce| macro_formula(&ctx, rgce, u16_at(body, 0), u16_at(body, 2))) } R_ARRAY => { - // ARRAY body: 12-byte header, then cce:u16, then rgce. - if let Some(cce) = u16le(body, 12) { - if let Some(rgce) = crate::bytes::at(body, 14, cce as usize) { - parse_formula(rgce, &mut strings); - } - } + // 12 bytes of range and flags, then cce:u16 and the tokens. + let cce = u16_at(body, 12) as usize; + crate::bytes::at(body, 14, cce) + .map(|rgce| macro_formula(&ctx, rgce, u16_at(body, 0), u16::from(body[4]))) + } + R_LABEL => { + // [rw:u16][col:u16][ixfe:u16][string: cch:u16 ...] + let mut c = Cur::at(body, 6); + let s = book.string(&mut c, 2); + (!c.short && is_useful(&s)).then_some(s) } - _ => {} + _ => None, + }; + if let Some(t) = text.filter(|t| !t.is_empty()) { + total += t.len(); + lines.push(t); } } - if macro_sheets.is_empty() { - return None; - } - let mut out = String::from("Excel 4.0 macro (XLM)\n"); - for m in ¯o_sheets { + for s in book.sheets.iter().filter(|s| s.kind == DT_EXCEL4_MACRO) { out.push_str("macro-sheet: "); - out.push_str(m); + out.push_str(&s.name); out.push('\n'); } - // Dedup adjacent duplicates (SST/shared strings can repeat) cheaply. - strings.dedup(); - for s in &strings { + // Adjacent duplicates (a fill-down repeats a formula) say nothing new. + lines.dedup(); + for s in &lines { if out.len() + s.len() + 1 > MAX_XLM_ARTIFACT { break; } @@ -308,30 +124,24 @@ pub(crate) fn xlm_macro_artifact(streams: &[(String, &[u8])]) -> Option<Vec<u8>> Some(out.into_bytes()) } -/// Read a wide (`cch:u16`-counted) `XLUnicodeString` body: `grbit:u8`, chars. -fn read_short_string_wide(d: &[u8], cch: usize) -> Option<String> { - let grbit = *d.first()?; - let chars = d.get(1..)?; - if grbit & 1 == 0 { - let n = cch.min(chars.len()); - Some(chars[..n].iter().map(|&b| b as char).collect()) +/// A macro formula as a line: `=` and the formula, as the cell shows it. +fn macro_formula(ctx: &formula::Ctx, rgce: &[u8], row: u16, col: u16) -> String { + let p = formula::decode_partial(ctx, rgce, &[], u32::from(row), u32::from(col)); + if p.text.is_empty() { + String::new() } else { - let n = cch.min(chars.len() / 2); - let units: Vec<u16> = (0..n) - .map(|i| u16::from_le_bytes([chars[i * 2], chars[i * 2 + 1]])) - .collect(); - Some(String::from_utf16_lossy(&units)) + format!("={}", p.text) } } #[cfg(test)] mod tests { - use crate::{extract, Budget, Format, Limits}; + use crate::{Budget, Format, Limits, extract}; /// End-to-end against a real Excel 4.0 macro `.xls` (from the `oletools` test /// corpus, cross-checked with `olevba`): the OLE path must surface an - /// `xlm_macro` member that flags the macro sheet and recovers the macro's - /// string constant (`olevba`: `ALERT("This is a sample Excel 4 macro")`). + /// `xlm_macro` member that flags the macro sheet and writes the macro as + /// `olevba` does: `ALERT("This is a sample Excel 4 macro")`. #[test] fn real_excel4_macro_is_surfaced() { let data = include_bytes!("../../tests/fixtures/xlm_excel4_sample.xls"); @@ -343,15 +153,13 @@ mod tests { .expect("an xlm_macro artifact must be surfaced"); let text = String::from_utf8_lossy(&xlm.data); assert!(text.contains("Excel 4.0 macro (XLM)"), "text: {text}"); - // The plain string constant carrying the payload. + // Function names are numbers in the file; the formulas read as in Excel. assert!( - text.contains("This is a sample Excel 4 macro"), + text.lines() + .any(|l| l == "=ALERT(\"This is a sample Excel 4 macro\")"), "recovered XLM text was: {text}" ); - // The Ptg-decoded function names (invisible to a raw byte scan — stored - // as numeric ids). olevba: ALERT("This is a sample Excel 4 macro"), HALT(). - assert!(text.contains("ALERT"), "expected ALERT in: {text}"); - assert!(text.contains("HALT"), "expected HALT in: {text}"); + assert!(text.lines().any(|l| l == "=HALT()"), "text: {text}"); } /// A benign OLE document with no macro sheet must NOT surface an xlm_macro. @@ -363,4 +171,33 @@ mod tests { let streams: Vec<(String, &[u8])> = vec![("Workbook".to_string(), &biff[..])]; assert!(super::xlm_macro_artifact(&streams).is_none()); } + + /// A formula with a token the reader refuses keeps the calls and strings + /// before it, so a writer cannot hide a function behind an odd token. + #[test] + fn a_formula_the_reader_refuses_still_gives_what_came_before() { + let mut stream = Vec::new(); + let mut rec = |id: u16, body: &[u8]| { + stream.extend(id.to_le_bytes()); + stream.extend((body.len() as u16).to_le_bytes()); + stream.extend_from_slice(body); + }; + rec(0x0809, &[0, 6, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + let mut sheet = vec![0u8; 4]; + sheet.extend([0, 1, 6, 0]); + sheet.extend(b"Macro1"); + rec(0x0085, &sheet); + rec(0x000A, &[]); + // =EXEC("calc") followed by a token that is not one (0x7F). + let mut rgce = vec![0x17, 4, 0, b'c', b'a', b'l', b'c']; + rgce.extend([0x42, 1, 110, 0, 0x7F]); + let mut f = vec![0u8; 20]; + f.extend((rgce.len() as u16).to_le_bytes()); + f.extend(rgce); + rec(0x0006, &f); + let streams: Vec<(String, &[u8])> = vec![("Workbook".to_string(), &stream[..])]; + let out = super::xlm_macro_artifact(&streams).unwrap(); + let text = String::from_utf8_lossy(&out); + assert!(text.lines().any(|l| l == "=EXEC(\"calc\")"), "text: {text}"); + } } diff --git a/crates/exav-unpack/src/formats/xlm_functions.rs b/crates/exav-unpack/src/formats/xlm_functions.rs deleted file mode 100644 index 497bfa1d..00000000 --- a/crates/exav-unpack/src/formats/xlm_functions.rs +++ /dev/null @@ -1,769 +0,0 @@ -// AUTO-GENERATED from oledump plugin_biff.py dFunctions — BIFF/XLM function table. -// (id, name) sorted by id for binary search. 0x8000 bit = command-equivalent. -pub static XLM_FUNCTIONS: &[(u16, &str)] = &[ - (0x0000, "COUNT"), - (0x0001, "IF"), - (0x0002, "ISNA"), - (0x0003, "ISERROR"), - (0x0004, "SUM"), - (0x0005, "AVERAGE"), - (0x0006, "MIN"), - (0x0007, "MAX"), - (0x0008, "ROW"), - (0x0009, "COLUMN"), - (0x000a, "NA"), - (0x000b, "NPV"), - (0x000c, "STDEV"), - (0x000d, "DOLLAR"), - (0x000e, "FIXED"), - (0x000f, "SIN"), - (0x0010, "COS"), - (0x0011, "TAN"), - (0x0012, "ATAN"), - (0x0013, "PI"), - (0x0014, "SQRT"), - (0x0015, "EXP"), - (0x0016, "LN"), - (0x0017, "LOG10"), - (0x0018, "ABS"), - (0x0019, "INT"), - (0x001a, "SIGN"), - (0x001b, "ROUND"), - (0x001c, "LOOKUP"), - (0x001d, "INDEX"), - (0x001e, "REPT"), - (0x0020, "LEN"), - (0x0021, "VALUE"), - (0x0022, "TRUE"), - (0x0023, "FALSE"), - (0x0024, "AND"), - (0x0025, "OR"), - (0x0026, "NOT"), - (0x0027, "MOD"), - (0x0028, "DCOUNT"), - (0x0029, "DSUM"), - (0x002a, "DAVERAGE"), - (0x002b, "DMIN"), - (0x002c, "DMAX"), - (0x002d, "DSTDEV"), - (0x002e, "VAR"), - (0x002f, "DVAR"), - (0x0030, "TEXT"), - (0x0031, "LINEST"), - (0x0032, "TREND"), - (0x0033, "LOGEST"), - (0x0034, "GROWTH"), - (0x0035, "GOTO"), - (0x0036, "HALT"), - (0x0037, "RETURN"), - (0x0038, "PV"), - (0x0039, "FV"), - (0x003a, "NPER"), - (0x003b, "PMT"), - (0x003c, "RATE"), - (0x003d, "MIRR"), - (0x003e, "IRR"), - (0x003f, "RAND"), - (0x0040, "MATCH"), - (0x0041, "DATE"), - (0x0042, "TIME"), - (0x0043, "DAY"), - (0x0044, "MONTH"), - (0x0045, "YEAR"), - (0x0046, "WEEKDAY"), - (0x0047, "HOUR"), - (0x0048, "MINUTE"), - (0x0049, "SECOND"), - (0x004b, "AREAS"), - (0x004c, "ROWS"), - (0x004d, "COLUMNS"), - (0x004e, "OFFSET"), - (0x004f, "ABSREF"), - (0x0050, "RELREF"), - (0x0051, "ARGUMENT"), - (0x0052, "SEARCH"), - (0x0053, "TRANSPOSE"), - (0x0054, "ERROR"), - (0x0055, "STEP"), - (0x0056, "TYPE"), - (0x0057, "ECHO"), - (0x0058, "SET.NAME"), - (0x0059, "CALLER"), - (0x005a, "DEREF"), - (0x005b, "WINDOWS"), - (0x005c, "SERIES"), - (0x005d, "DOCUMENTS"), - (0x005f, "SELECTION"), - (0x0060, "RESULT"), - (0x0061, "ATAN2"), - (0x0062, "ASIN"), - (0x0063, "ACOS"), - (0x0064, "CHOOSE"), - (0x0065, "HLOOKUP"), - (0x0066, "VLOOKUP"), - (0x0067, "LINKS"), - (0x0068, "INPUT"), - (0x0069, "ISREF"), - (0x006a, "GET.FORMULA"), - (0x006b, "GET.NAME"), - (0x006d, "LOG"), - (0x006e, "EXEC"), - (0x006f, "CHAR"), - (0x0070, "LOWER"), - (0x0071, "UPPER"), - (0x0072, "PROPER"), - (0x0073, "LEFT"), - (0x0074, "RIGHT"), - (0x0075, "EXACT"), - (0x0076, "TRIM"), - (0x0077, "REPLACE"), - (0x0078, "SUBSTITUTE"), - (0x0079, "CODE"), - (0x007a, "NAMES"), - (0x007b, "DIRECTORY"), - (0x007c, "FIND"), - (0x007d, "CELL"), - (0x007e, "ISERR"), - (0x007f, "ISTEXT"), - (0x0080, "ISNUMBER"), - (0x0081, "ISBLANK"), - (0x0082, "T"), - (0x0083, "N"), - (0x0084, "FOPEN"), - (0x0085, "FCLOSE"), - (0x0086, "FSIZE"), - (0x0087, "FREADLN"), - (0x0088, "FREAD"), - (0x0089, "FWRITELN"), - (0x008a, "FWRITE"), - (0x008b, "FPOS"), - (0x008c, "DATEVALUE"), - (0x008d, "TIMEVALUE"), - (0x008e, "SLN"), - (0x008f, "SYD"), - (0x0090, "DDB"), - (0x0091, "GET.DEF"), - (0x0092, "REFTEXT"), - (0x0093, "TEXTREF"), - (0x0094, "INDIRECT"), - (0x0095, "REGISTER"), - (0x0096, "CALL"), - (0x0097, "ADD.BAR"), - (0x0098, "ADD.MENU"), - (0x0099, "ADD.COMMAND"), - (0x009a, "ENABLE.COMMAND"), - (0x009b, "CHECK.COMMAND"), - (0x009c, "RENAME.COMMAND"), - (0x009d, "SHOW.BAR"), - (0x009e, "DELETE.MENU"), - (0x009f, "DELETE.COMMAND"), - (0x00a0, "GET.CHART.ITEM"), - (0x00a1, "DIALOG.BOX"), - (0x00a2, "CLEAN"), - (0x00a3, "MDETERM"), - (0x00a4, "MINVERSE"), - (0x00a5, "MMULT"), - (0x00a6, "FILES"), - (0x00a7, "IPMT"), - (0x00a8, "PPMT"), - (0x00a9, "COUNTA"), - (0x00aa, "CANCEL.KEY"), - (0x00ab, "FOR"), - (0x00ac, "WHILE"), - (0x00ad, "BREAK"), - (0x00af, "INITIATE"), - (0x00b0, "REQUEST"), - (0x00b1, "POKE"), - (0x00b2, "EXECUTE"), - (0x00b3, "TERMINATE"), - (0x00b4, "RESTART"), - (0x00b5, "HELP"), - (0x00b6, "GET.BAR"), - (0x00b7, "PRODUCT"), - (0x00b8, "FACT"), - (0x00b9, "GET.CELL"), - (0x00ba, "GET.WORKSPACE"), - (0x00bb, "GET.WINDOW"), - (0x00bc, "GET.DOCUMENT"), - (0x00bd, "DPRODUCT"), - (0x00be, "ISNONTEXT"), - (0x00bf, "GET.NOTE"), - (0x00c0, "NOTE"), - (0x00c1, "STDEVP"), - (0x00c2, "VARP"), - (0x00c3, "DSTDEVP"), - (0x00c4, "DVARP"), - (0x00c5, "TRUNC"), - (0x00c6, "ISLOGICAL"), - (0x00c7, "DCOUNTA"), - (0x00c8, "DELETE.BAR"), - (0x00c9, "UNREGISTER"), - (0x00cc, "USDOLLAR"), - (0x00cd, "FINDB"), - (0x00ce, "SEARCHB"), - (0x00cf, "REPLACEB"), - (0x00d0, "LEFTB"), - (0x00d1, "RIGHTB"), - (0x00d2, "MIDB"), - (0x00d3, "LENB"), - (0x00d4, "ROUNDUP"), - (0x00d5, "ROUNDDOWN"), - (0x00d6, "ASC"), - (0x00d7, "DBCS"), - (0x00d8, "RANK"), - (0x00db, "ADDRESS"), - (0x00dc, "DAYS360"), - (0x00dd, "TODAY"), - (0x00de, "VDB"), - (0x00df, "ELSE"), - (0x00e0, "ELSE.IF"), - (0x00e1, "END.IF"), - (0x00e2, "FOR.CELL"), - (0x00e3, "MEDIAN"), - (0x00e4, "SUMPRODUCT"), - (0x00e5, "SINH"), - (0x00e6, "COSH"), - (0x00e7, "TANH"), - (0x00e8, "ASINH"), - (0x00e9, "ACOSH"), - (0x00ea, "ATANH"), - (0x00eb, "DGET"), - (0x00ec, "CREATE.OBJECT"), - (0x00ed, "VOLATILE"), - (0x00ee, "LAST.ERROR"), - (0x00ef, "CUSTOM.UNDO"), - (0x00f0, "CUSTOM.REPEAT"), - (0x00f1, "FORMULA.CONVERT"), - (0x00f2, "GET.LINK.INFO"), - (0x00f3, "TEXT.BOX"), - (0x00f4, "INFO"), - (0x00f5, "GROUP"), - (0x00f6, "GET.OBJECT"), - (0x00f7, "DB"), - (0x00f8, "PAUSE"), - (0x00fb, "RESUME"), - (0x00fc, "FREQUENCY"), - (0x00fd, "ADD.TOOLBAR"), - (0x00fe, "DELETE.TOOLBAR"), - (0x00ff, "User Defined Function"), - (0x0100, "RESET.TOOLBAR"), - (0x0101, "EVALUATE"), - (0x0102, "GET.TOOLBAR"), - (0x0103, "GET.TOOL"), - (0x0104, "SPELLING.CHECK"), - (0x0105, "ERROR.TYPE"), - (0x0106, "APP.TITLE"), - (0x0107, "WINDOW.TITLE"), - (0x0108, "SAVE.TOOLBAR"), - (0x0109, "ENABLE.TOOL"), - (0x010a, "PRESS.TOOL"), - (0x010b, "REGISTER.ID"), - (0x010c, "GET.WORKBOOK"), - (0x010d, "AVEDEV"), - (0x010e, "BETADIST"), - (0x010f, "GAMMALN"), - (0x0110, "BETAINV"), - (0x0111, "BINOMDIST"), - (0x0112, "CHIDIST"), - (0x0113, "CHIINV"), - (0x0114, "COMBIN"), - (0x0115, "CONFIDENCE"), - (0x0116, "CRITBINOM"), - (0x0117, "EVEN"), - (0x0118, "EXPONDIST"), - (0x0119, "FDIST"), - (0x011a, "FINV"), - (0x011b, "FISHER"), - (0x011c, "FISHERINV"), - (0x011d, "FLOOR"), - (0x011e, "GAMMADIST"), - (0x011f, "GAMMAINV"), - (0x0120, "CEILING"), - (0x0121, "HYPGEOMDIST"), - (0x0122, "LOGNORMDIST"), - (0x0123, "LOGINV"), - (0x0124, "NEGBINOMDIST"), - (0x0125, "NORMDIST"), - (0x0126, "NORMSDIST"), - (0x0127, "NORMINV"), - (0x0128, "NORMSINV"), - (0x0129, "STANDARDIZE"), - (0x012a, "ODD"), - (0x012b, "PERMUT"), - (0x012c, "POISSON"), - (0x012d, "TDIST"), - (0x012e, "WEIBULL"), - (0x012f, "SUMXMY2"), - (0x0130, "SUMX2MY2"), - (0x0131, "SUMX2PY2"), - (0x0132, "CHITEST"), - (0x0133, "CORREL"), - (0x0134, "COVAR"), - (0x0135, "FORECAST"), - (0x0136, "FTEST"), - (0x0137, "INTERCEPT"), - (0x0138, "PEARSON"), - (0x0139, "RSQ"), - (0x013a, "STEYX"), - (0x013b, "SLOPE"), - (0x013c, "TTEST"), - (0x013d, "PROB"), - (0x013e, "DEVSQ"), - (0x013f, "GEOMEAN"), - (0x0140, "HARMEAN"), - (0x0141, "SUMSQ"), - (0x0142, "KURT"), - (0x0143, "SKEW"), - (0x0144, "ZTEST"), - (0x0145, "LARGE"), - (0x0146, "SMALL"), - (0x0147, "QUARTILE"), - (0x0148, "PERCENTILE"), - (0x0149, "PERCENTRANK"), - (0x014a, "MODE"), - (0x014b, "TRIMMEAN"), - (0x014c, "TINV"), - (0x014e, "MOVIE.COMMAND"), - (0x014f, "GET.MOVIE"), - (0x0150, "CONCATENATE"), - (0x0151, "POWER"), - (0x0152, "PIVOT.ADD.DATA"), - (0x0153, "GET.PIVOT.TABLE"), - (0x0154, "GET.PIVOT.FIELD"), - (0x0155, "GET.PIVOT.ITEM"), - (0x0156, "RADIANS"), - (0x0157, "DEGREES"), - (0x0158, "SUBTOTAL"), - (0x0159, "SUMIF"), - (0x015a, "COUNTIF"), - (0x015b, "COUNTBLANK"), - (0x015c, "SCENARIO.GET"), - (0x015d, "OPTIONS.LISTS.GET"), - (0x015e, "ISPMT"), - (0x015f, "DATEDIF"), - (0x0160, "DATESTRING"), - (0x0161, "NUMBERSTRING"), - (0x0162, "ROMAN"), - (0x0163, "OPEN.DIALOG"), - (0x0164, "SAVE.DIALOG"), - (0x0165, "VIEW.GET"), - (0x0166, "GETPIVOTDATA"), - (0x0167, "HYPERLINK"), - (0x0168, "PHONETIC"), - (0x0169, "AVERAGEA"), - (0x016a, "MAXA"), - (0x016b, "MINA"), - (0x016c, "STDEVPA"), - (0x016d, "VARPA"), - (0x016e, "STDEVA"), - (0x016f, "VARA"), - (0x0170, "BAHTTEXT"), - (0x0171, "THAIDAYOFWEEK"), - (0x0172, "THAIDIGIT"), - (0x0173, "THAIMONTHOFYEAR"), - (0x0174, "THAINUMSOUND"), - (0x0175, "THAINUMSTRING"), - (0x0176, "THAISTRINGLENGTH"), - (0x0177, "ISTHAIDIGIT"), - (0x0178, "ROUNDBAHTDOWN"), - (0x0179, "ROUNDBAHTUP"), - (0x017a, "THAIYEAR"), - (0x017b, "RTD"), - (0x01e0, "IFERROR"), - (0x8000, "BEEP"), - (0x8001, "OPEN"), - (0x8002, "OPEN.LINKS"), - (0x8003, "CLOSE.ALL"), - (0x8004, "SAVE"), - (0x8005, "SAVE.AS"), - (0x8006, "FILE.DELETE"), - (0x8007, "PAGE.SETUP"), - (0x8008, "PRINT"), - (0x8009, "PRINTER.SETUP"), - (0x800a, "QUIT"), - (0x800b, "NEW.WINDOW"), - (0x800c, "ARRANGE.ALL"), - (0x800d, "WINDOW.SIZE"), - (0x800e, "WINDOW.MOVE"), - (0x800f, "FULL"), - (0x8010, "CLOSE"), - (0x8011, "RUN"), - (0x8016, "SET.PRINT.AREA"), - (0x8017, "SET.PRINT.TITLES"), - (0x8018, "SET.PAGE.BREAK"), - (0x8019, "REMOVE.PAGE.BREAK"), - (0x801a, "FONT"), - (0x801b, "DISPLAY"), - (0x801c, "PROTECT.DOCUMENT"), - (0x801d, "PRECISION"), - (0x801e, "A1.R1C1"), - (0x801f, "CALCULATE.NOW"), - (0x8020, "CALCULATION"), - (0x8022, "DATA.FIND"), - (0x8023, "EXTRACT"), - (0x8024, "DATA.DELETE"), - (0x8025, "SET.DATABASE"), - (0x8026, "SET.CRITERIA"), - (0x8027, "SORT"), - (0x8028, "DATA.SERIES"), - (0x8029, "TABLE"), - (0x802a, "FORMAT.NUMBER"), - (0x802b, "ALIGNMENT"), - (0x802c, "STYLE"), - (0x802d, "BORDER"), - (0x802e, "CELL.PROTECTION"), - (0x802f, "COLUMN.WIDTH"), - (0x8030, "UNDO"), - (0x8031, "CUT"), - (0x8032, "COPY"), - (0x8033, "PASTE"), - (0x8034, "CLEAR"), - (0x8035, "PASTE.SPECIAL"), - (0x8036, "EDIT.DELETE"), - (0x8037, "INSERT"), - (0x8038, "FILL.RIGHT"), - (0x8039, "FILL.DOWN"), - (0x803d, "DEFINE.NAME"), - (0x803e, "CREATE.NAMES"), - (0x803f, "FORMULA.GOTO"), - (0x8040, "FORMULA.FIND"), - (0x8041, "SELECT.LAST.CELL"), - (0x8042, "SHOW.ACTIVE.CELL"), - (0x8043, "GALLERY.AREA"), - (0x8044, "GALLERY.BAR"), - (0x8045, "GALLERY.COLUMN"), - (0x8046, "GALLERY.LINE"), - (0x8047, "GALLERY.PIE"), - (0x8048, "GALLERY.SCATTER"), - (0x8049, "COMBINATION"), - (0x804a, "PREFERRED"), - (0x804b, "ADD.OVERLAY"), - (0x804c, "GRIDLINES"), - (0x804d, "SET.PREFERRED"), - (0x804e, "AXES"), - (0x804f, "LEGEND"), - (0x8050, "ATTACH.TEXT"), - (0x8051, "ADD.ARROW"), - (0x8052, "SELECT.CHART"), - (0x8053, "SELECT.PLOT.AREA"), - (0x8054, "PATTERNS"), - (0x8055, "MAIN.CHART"), - (0x8056, "OVERLAY"), - (0x8057, "SCALE"), - (0x8058, "FORMAT.LEGEND"), - (0x8059, "FORMAT.TEXT"), - (0x805a, "EDIT.REPEAT"), - (0x805b, "PARSE"), - (0x805c, "JUSTIFY"), - (0x805d, "HIDE"), - (0x805e, "UNHIDE"), - (0x805f, "WORKSPACE"), - (0x8060, "FORMULA"), - (0x8061, "FORMULA.FILL"), - (0x8062, "FORMULA.ARRAY"), - (0x8063, "DATA.FIND.NEXT"), - (0x8064, "DATA.FIND.PREV"), - (0x8065, "FORMULA.FIND.NEXT"), - (0x8066, "FORMULA.FIND.PREV"), - (0x8067, "ACTIVATE"), - (0x8068, "ACTIVATE.NEXT"), - (0x8069, "ACTIVATE.PREV"), - (0x806a, "UNLOCKED.NEXT"), - (0x806b, "UNLOCKED.PREV"), - (0x806c, "COPY.PICTURE"), - (0x806d, "SELECT"), - (0x806e, "DELETE.NAME"), - (0x806f, "DELETE.FORMAT"), - (0x8070, "VLINE"), - (0x8071, "HLINE"), - (0x8072, "VPAGE"), - (0x8073, "HPAGE"), - (0x8074, "VSCROLL"), - (0x8075, "HSCROLL"), - (0x8076, "ALERT"), - (0x8077, "NEW"), - (0x8078, "CANCEL.COPY"), - (0x8079, "SHOW.CLIPBOARD"), - (0x807a, "MESSAGE"), - (0x807c, "PASTE.LINK"), - (0x807d, "APP.ACTIVATE"), - (0x807e, "DELETE.ARROW"), - (0x807f, "ROW.HEIGHT"), - (0x8080, "FORMAT.MOVE"), - (0x8081, "FORMAT.SIZE"), - (0x8082, "FORMULA.REPLACE"), - (0x8083, "SEND.KEYS"), - (0x8084, "SELECT.SPECIAL"), - (0x8085, "APPLY.NAMES"), - (0x8086, "REPLACE.FONT"), - (0x8087, "FREEZE.PANES"), - (0x8088, "SHOW.INFO"), - (0x8089, "SPLIT"), - (0x808a, "ON.WINDOW"), - (0x808b, "ON.DATA"), - (0x808c, "DISABLE.INPUT"), - (0x808e, "OUTLINE"), - (0x808f, "LIST.NAMES"), - (0x8090, "FILE.CLOSE"), - (0x8091, "SAVE.WORKBOOK"), - (0x8092, "DATA.FORM"), - (0x8093, "COPY.CHART"), - (0x8094, "ON.TIME"), - (0x8095, "WAIT"), - (0x8096, "FORMAT.FONT"), - (0x8097, "FILL.UP"), - (0x8098, "FILL.LEFT"), - (0x8099, "DELETE.OVERLAY"), - (0x809b, "SHORT.MENUS"), - (0x809f, "SET.UPDATE.STATUS"), - (0x80a1, "COLOR.PALETTE"), - (0x80a2, "DELETE.STYLE"), - (0x80a3, "WINDOW.RESTORE"), - (0x80a4, "WINDOW.MAXIMIZE"), - (0x80a6, "CHANGE.LINK"), - (0x80a7, "CALCULATE.DOCUMENT"), - (0x80a8, "ON.KEY"), - (0x80a9, "APP.RESTORE"), - (0x80aa, "APP.MOVE"), - (0x80ab, "APP.SIZE"), - (0x80ac, "APP.MINIMIZE"), - (0x80ad, "APP.MAXIMIZE"), - (0x80ae, "BRING.TO.FRONT"), - (0x80af, "SEND.TO.BACK"), - (0x80b9, "MAIN.CHART.TYPE"), - (0x80ba, "OVERLAY.CHART.TYPE"), - (0x80bb, "SELECT.END"), - (0x80bc, "OPEN.MAIL"), - (0x80bd, "SEND.MAIL"), - (0x80be, "STANDARD.FONT"), - (0x80bf, "CONSOLIDATE"), - (0x80c0, "SORT.SPECIAL"), - (0x80c1, "GALLERY.3D.AREA"), - (0x80c2, "GALLERY.3D.COLUMN"), - (0x80c3, "GALLERY.3D.LINE"), - (0x80c4, "GALLERY.3D.PIE"), - (0x80c5, "VIEW.3D"), - (0x80c6, "GOAL.SEEK"), - (0x80c7, "WORKGROUP"), - (0x80c8, "FILL.GROUP"), - (0x80c9, "UPDATE.LINK"), - (0x80ca, "PROMOTE"), - (0x80cb, "DEMOTE"), - (0x80cc, "SHOW.DETAIL"), - (0x80ce, "UNGROUP"), - (0x80cf, "OBJECT.PROPERTIES"), - (0x80d0, "SAVE.NEW.OBJECT"), - (0x80d1, "SHARE"), - (0x80d2, "SHARE.NAME"), - (0x80d3, "DUPLICATE"), - (0x80d4, "APPLY.STYLE"), - (0x80d5, "ASSIGN.TO.OBJECT"), - (0x80d6, "OBJECT.PROTECTION"), - (0x80d7, "HIDE.OBJECT"), - (0x80d8, "SET.EXTRACT"), - (0x80d9, "CREATE.PUBLISHER"), - (0x80da, "SUBSCRIBE.TO"), - (0x80db, "ATTRIBUTES"), - (0x80dc, "SHOW.TOOLBAR"), - (0x80de, "PRINT.PREVIEW"), - (0x80df, "EDIT.COLOR"), - (0x80e0, "SHOW.LEVELS"), - (0x80e1, "FORMAT.MAIN"), - (0x80e2, "FORMAT.OVERLAY"), - (0x80e3, "ON.RECALC"), - (0x80e4, "EDIT.SERIES"), - (0x80e5, "DEFINE.STYLE"), - (0x80f0, "LINE.PRINT"), - (0x80f3, "ENTER.DATA"), - (0x80f9, "GALLERY.RADAR"), - (0x80fa, "MERGE.STYLES"), - (0x80fb, "EDITION.OPTIONS"), - (0x80fc, "PASTE.PICTURE"), - (0x80fd, "PASTE.PICTURE.LINK"), - (0x80fe, "SPELLING"), - (0x8100, "ZOOM"), - (0x8103, "INSERT.OBJECT"), - (0x8104, "WINDOW.MINIMIZE"), - (0x8109, "SOUND.NOTE"), - (0x810a, "SOUND.PLAY"), - (0x810b, "FORMAT.SHAPE"), - (0x810c, "EXTEND.POLYGON"), - (0x810d, "FORMAT.AUTO"), - (0x8110, "GALLERY.3D.BAR"), - (0x8111, "GALLERY.3D.SURFACE"), - (0x8112, "FILL.AUTO"), - (0x8114, "CUSTOMIZE.TOOLBAR"), - (0x8115, "ADD.TOOL"), - (0x8116, "EDIT.OBJECT"), - (0x8117, "ON.DOUBLECLICK"), - (0x8118, "ON.ENTRY"), - (0x8119, "WORKBOOK.ADD"), - (0x811a, "WORKBOOK.MOVE"), - (0x811b, "WORKBOOK.COPY"), - (0x811c, "WORKBOOK.OPTIONS"), - (0x811d, "SAVE.WORKSPACE"), - (0x8120, "CHART.WIZARD"), - (0x8121, "DELETE.TOOL"), - (0x8122, "MOVE.TOOL"), - (0x8123, "WORKBOOK.SELECT"), - (0x8124, "WORKBOOK.ACTIVATE"), - (0x8125, "ASSIGN.TO.TOOL"), - (0x8127, "COPY.TOOL"), - (0x8128, "RESET.TOOL"), - (0x8129, "CONSTRAIN.NUMERIC"), - (0x812a, "PASTE.TOOL"), - (0x812e, "WORKBOOK.NEW"), - (0x8131, "SCENARIO.CELLS"), - (0x8132, "SCENARIO.DELETE"), - (0x8133, "SCENARIO.ADD"), - (0x8134, "SCENARIO.EDIT"), - (0x8135, "SCENARIO.SHOW"), - (0x8136, "SCENARIO.SHOW.NEXT"), - (0x8137, "SCENARIO.SUMMARY"), - (0x8138, "PIVOT.TABLE.WIZARD"), - (0x8139, "PIVOT.FIELD.PROPERTIES"), - (0x813a, "PIVOT.FIELD"), - (0x813b, "PIVOT.ITEM"), - (0x813c, "PIVOT.ADD.FIELDS"), - (0x813e, "OPTIONS.CALCULATION"), - (0x813f, "OPTIONS.EDIT"), - (0x8140, "OPTIONS.VIEW"), - (0x8141, "ADDIN.MANAGER"), - (0x8142, "MENU.EDITOR"), - (0x8143, "ATTACH.TOOLBARS"), - (0x8144, "VBAActivate"), - (0x8145, "OPTIONS.CHART"), - (0x8148, "VBA.INSERT.FILE"), - (0x814a, "VBA.PROCEDURE.DEFINITION"), - (0x8150, "ROUTING.SLIP"), - (0x8152, "ROUTE.DOCUMENT"), - (0x8153, "MAIL.LOGON"), - (0x8156, "INSERT.PICTURE"), - (0x8157, "EDIT.TOOL"), - (0x8158, "GALLERY.DOUGHNUT"), - (0x815e, "CHART.TREND"), - (0x8160, "PIVOT.ITEM.PROPERTIES"), - (0x8162, "WORKBOOK.INSERT"), - (0x8163, "OPTIONS.TRANSITION"), - (0x8164, "OPTIONS.GENERAL"), - (0x8172, "FILTER.ADVANCED"), - (0x8175, "MAIL.ADD.MAILER"), - (0x8176, "MAIL.DELETE.MAILER"), - (0x8177, "MAIL.REPLY"), - (0x8178, "MAIL.REPLY.ALL"), - (0x8179, "MAIL.FORWARD"), - (0x817a, "MAIL.NEXT.LETTER"), - (0x817b, "DATA.LABEL"), - (0x817c, "INSERT.TITLE"), - (0x817d, "FONT.PROPERTIES"), - (0x817e, "MACRO.OPTIONS"), - (0x817f, "WORKBOOK.HIDE"), - (0x8180, "WORKBOOK.UNHIDE"), - (0x8181, "WORKBOOK.DELETE"), - (0x8182, "WORKBOOK.NAME"), - (0x8184, "GALLERY.CUSTOM"), - (0x8186, "ADD.CHART.AUTOFORMAT"), - (0x8187, "DELETE.CHART.AUTOFORMAT"), - (0x8188, "CHART.ADD.DATA"), - (0x8189, "AUTO.OUTLINE"), - (0x818a, "TAB.ORDER"), - (0x818b, "SHOW.DIALOG"), - (0x818c, "SELECT.ALL"), - (0x818d, "UNGROUP.SHEETS"), - (0x818e, "SUBTOTAL.CREATE"), - (0x818f, "SUBTOTAL.REMOVE"), - (0x8190, "RENAME.OBJECT"), - (0x819c, "WORKBOOK.SCROLL"), - (0x819d, "WORKBOOK.NEXT"), - (0x819e, "WORKBOOK.PREV"), - (0x819f, "WORKBOOK.TAB.SPLIT"), - (0x81a0, "FULL.SCREEN"), - (0x81a1, "WORKBOOK.PROTECT"), - (0x81a4, "SCROLLBAR.PROPERTIES"), - (0x81a5, "PIVOT.SHOW.PAGES"), - (0x81a6, "TEXT.TO.COLUMNS"), - (0x81a7, "FORMAT.CHARTTYPE"), - (0x81a8, "LINK.FORMAT"), - (0x81a9, "TRACER.DISPLAY"), - (0x81ae, "TRACER.NAVIGATE"), - (0x81af, "TRACER.CLEAR"), - (0x81b0, "TRACER.ERROR"), - (0x81b1, "PIVOT.FIELD.GROUP"), - (0x81b2, "PIVOT.FIELD.UNGROUP"), - (0x81b3, "CHECKBOX.PROPERTIES"), - (0x81b4, "LABEL.PROPERTIES"), - (0x81b5, "LISTBOX.PROPERTIES"), - (0x81b6, "EDITBOX.PROPERTIES"), - (0x81b7, "PIVOT.REFRESH"), - (0x81b8, "LINK.COMBO"), - (0x81b9, "OPEN.TEXT"), - (0x81ba, "HIDE.DIALOG"), - (0x81bb, "SET.DIALOG.FOCUS"), - (0x81bc, "ENABLE.OBJECT"), - (0x81bd, "PUSHBUTTON.PROPERTIES"), - (0x81be, "SET.DIALOG.DEFAULT"), - (0x81bf, "FILTER"), - (0x81c0, "FILTER.SHOW.ALL"), - (0x81c1, "CLEAR.OUTLINE"), - (0x81c2, "FUNCTION.WIZARD"), - (0x81c3, "ADD.LIST.ITEM"), - (0x81c4, "SET.LIST.ITEM"), - (0x81c5, "REMOVE.LIST.ITEM"), - (0x81c6, "SELECT.LIST.ITEM"), - (0x81c7, "SET.CONTROL.VALUE"), - (0x81c8, "SAVE.COPY.AS"), - (0x81ca, "OPTIONS.LISTS.ADD"), - (0x81cb, "OPTIONS.LISTS.DELETE"), - (0x81cc, "SERIES.AXES"), - (0x81cd, "SERIES.X"), - (0x81ce, "SERIES.Y"), - (0x81cf, "ERRORBAR.X"), - (0x81d0, "ERRORBAR.Y"), - (0x81d1, "FORMAT.CHART"), - (0x81d2, "SERIES.ORDER"), - (0x81d3, "MAIL.LOGOFF"), - (0x81d4, "CLEAR.ROUTING.SLIP"), - (0x81d5, "APP.ACTIVATE.MICROSOFT"), - (0x81d6, "MAIL.EDIT.MAILER"), - (0x81d7, "ON.SHEET"), - (0x81d8, "STANDARD.WIDTH"), - (0x81d9, "SCENARIO.MERGE"), - (0x81da, "SUMMARY.INFO"), - (0x81db, "FIND.FILE"), - (0x81dc, "ACTIVE.CELL.FONT"), - (0x81dd, "ENABLE.TIPWIZARD"), - (0x81de, "VBA.MAKE.ADDIN"), - (0x81e0, "INSERTDATATABLE"), - (0x81e1, "WORKGROUP.OPTIONS"), - (0x81e2, "MAIL.SEND.MAILER"), - (0x81e5, "AUTOCORRECT"), - (0x81e9, "POST.DOCUMENT"), - (0x81eb, "PICKLIST"), - (0x81ed, "VIEW.SHOW"), - (0x81ee, "VIEW.DEFINE"), - (0x81ef, "VIEW.DELETE"), - (0x81fd, "SHEET.BACKGROUND"), - (0x81fe, "INSERT.MAP.OBJECT"), - (0x81ff, "OPTIONS.MENONO"), - (0x8205, "MSOCHECKS"), - (0x8206, "NORMAL"), - (0x8207, "LAYOUT"), - (0x8208, "RM.PRINT.AREA"), - (0x8209, "CLEAR.PRINT.AREA"), - (0x820a, "ADD.PRINT.AREA"), - (0x820b, "MOVE.BRK"), - (0x8221, "HIDECURR.NOTE"), - (0x8222, "HIDEALL.NOTES"), - (0x8223, "DELETE.NOTE"), - (0x8224, "TRAVERSE.NOTES"), - (0x8225, "ACTIVATE.NOTES"), - (0x826c, "PROTECT.REVISIONS"), - (0x826d, "UNPROTECT.REVISIONS"), - (0x8287, "OPTIONS.ME"), - (0x828d, "WEB.PUBLISH"), - (0x829b, "NEWWEBQUERY"), - (0x82a1, "PIVOT.TABLE.CHART"), - (0x82f1, "OPTIONS.SAVE"), - (0x82f3, "OPTIONS.SPELL"), - (0x8328, "HIDEALL.INKANNOTS"), -]; diff --git a/crates/exav-unpack/src/formats/xz.rs b/crates/exav-unpack/src/formats/xz.rs index 65caa828..aa990f5b 100644 --- a/crates/exav-unpack/src/formats/xz.rs +++ b/crates/exav-unpack/src/formats/xz.rs @@ -118,13 +118,18 @@ pub(crate) fn content_reader( /// Every stream of an `.xz` input read from `input`, decoded as it is read. pub(crate) struct XzReader<R> { - input: R, + /// The input, with a check the decoder does not read taken out + /// ([`super::xz_nocheck`]). + input: super::xz_nocheck::NoCheck<R>, inbuf: Vec<u8>, in_pos: usize, in_len: usize, /// The decoder owns its dictionary allocation, so its lifetime is /// independent of the input; `'static` keeps it out of the caller's way. - decoder: xz4rust::XzDecoder<'static>, + /// Boxed: its tables are about 30 KB, which the frame of whatever holds + /// the reader would take for as long as a member is scanned, once per + /// level of xz inside xz. + decoder: Box<xz4rust::XzDecoder<'static>>, /// Decoded bytes not yet handed to the caller. A single `decode` call can /// produce more than the caller asked for, and those bytes cannot be /// re-derived, so they wait here. @@ -133,20 +138,50 @@ pub(crate) struct XzReader<R> { done: bool, /// Input was fed to the decoder since the last end of stream. mid_stream: bool, + /// The input ended inside a stream, and that is yet to be reported. + cut: bool, } impl<R: std::io::Read> XzReader<R> { pub(crate) fn new(input: R) -> Self { XzReader { - input, + input: super::xz_nocheck::NoCheck::new(input), inbuf: vec![0; 8192], in_pos: 0, in_len: 0, - decoder: xz4rust::XzDecoder::with_alloc_dict_size(8192, XZ_MAX_DICT as usize), + decoder: Box::new(xz4rust::XzDecoder::with_alloc_dict_size( + 8192, + XZ_MAX_DICT as usize, + )), staged: Vec::new(), taken: 0, done: false, mid_stream: false, + cut: false, + } + } + + /// Stage what the decoder holds when the input ends inside a stream. It + /// reads ahead of what it writes, so the last bytes it could decode from + /// what it was given stay in it until it is given more; `xz -dc` writes + /// them. One zero byte stands for the input that is missing, and the + /// decoder, having its lookahead, writes them all at once. Output that + /// comes out byte for byte with the one zero given is not the stream's: + /// an uncompressed chunk copies its input, so the zero is all it makes of + /// it. Nothing is read from the decoder after that call, the rest of what + /// it writes being made of the padding. + fn drain(&mut self) { + self.cut = true; + let mut buf = [0u8; OUT_STEP]; + if let Ok(result) = self.decoder.decode(&[0u8], &mut buf) { + let n = result.output_produced(); + if matches!(result, xz4rust::XzNextBlockResult::EndOfStream(_, _)) { + // It ended after all: nothing was cut. + self.staged.extend_from_slice(&buf[..n]); + self.cut = false; + } else if n > result.input_consumed() { + self.staged.extend_from_slice(&buf[..n]); + } } } @@ -176,20 +211,23 @@ impl<R: std::io::Read> std::io::Read for XzReader<R> { return Ok(n); } if self.done { + // The input ended inside a stream: the rest of it is not here. + if std::mem::take(&mut self.cut) { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "xz: stream cut short", + )); + } return Ok(0); } self.staged.clear(); self.taken = 0; if !self.fill()? { self.done = true; - // The input ended inside a stream: the rest of it is not here. if self.mid_stream { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "xz: stream cut short", - )); + self.drain(); } - return Ok(0); + continue; } self.mid_stream = true; let mut buf = [0u8; OUT_STEP]; diff --git a/crates/exav-unpack/src/formats/xz_nocheck.rs b/crates/exav-unpack/src/formats/xz_nocheck.rs new file mode 100644 index 00000000..4cba6718 --- /dev/null +++ b/crates/exav-unpack/src/formats/xz_nocheck.rs @@ -0,0 +1,405 @@ +//! xz streams whose integrity check `xz4rust` does not read, decoded without it. +//! +//! The format has sixteen check types: none, CRC32, CRC64 and SHA-256 are +//! defined, and the rest reserved. The decoder reads none, CRC32 and CRC64 and +//! refuses the others, but liblzma and the `xz` tool decode a stream with a +//! check they do not support and warn that its integrity is unchecked. A scanner +//! should look at the data either way, so a stream with such a check is +//! rewritten as it is read: the same blocks with no check fields, an index +//! without the check sizes in its records, and a header and footer saying "no +//! check". The data are not touched. +//! +//! Only a stream that opens with an unsupported check is rewritten, and the +//! streams after it that do the same. A stream with a check the decoder reads +//! passes through byte for byte, as the rest of the input after it does. + +use std::io::{self, Read}; + +const MAGIC: [u8; 6] = [0xFD, b'7', b'z', b'X', b'Z', 0]; +/// The largest index read: a record is a few bytes. +const MAX_INDEX: usize = 64 << 20; + +/// Bytes of the check field for a check type (xz file format, 2.1.1.2). +fn check_size(id: u8) -> usize { + if id == 0 { 0 } else { 4 << ((id - 1) / 3) } +} + +/// Whether `xz4rust` reads the check. +fn supported(id: u8) -> bool { + matches!(id, 0 | 1 | 4) +} + +fn bad(what: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, format!("xz: {what}")) +} + +/// A variable-length integer, as xz codes sizes: 7 bits a byte, low first. +fn vli_bytes(mut v: u64) -> Vec<u8> { + let mut out = Vec::new(); + while v >= 0x80 { + out.push(v as u8 | 0x80); + v >>= 7; + } + out.push(v as u8); + out +} + +/// The VLI at the start of `b`, and the bytes it takes. +fn vli_at(b: &[u8]) -> Option<(u64, usize)> { + let mut v = 0u64; + for (i, &byte) in b.iter().take(9).enumerate() { + v |= u64::from(byte & 0x7F) << (7 * i); + if byte & 0x80 == 0 { + return Some((v, i + 1)); + } + } + None +} + +enum Mode { + /// Nothing read yet, or between streams. + Header, + /// Handing the input on unchanged. + Pass, + /// In a stream being rewritten: at the start of a block or the index. + Block, + /// In the LZMA2 data of a block, at the start of a chunk. + Chunks { + header: u64, + data: u64, + }, + /// After a block's data, `header + data` bytes into it. + Tail { + header: u64, + data: u64, + }, + /// In a block whose compressed size its header gives, `left` bytes to go. + Sized { + left: u64, + header: u64, + data: u64, + }, + Done, +} + +pub(crate) struct NoCheck<R> { + inner: R, + mode: Mode, + /// Bytes of check field to leave out of each block. + strip: usize, + out: Vec<u8>, + at: usize, +} + +impl<R: Read> NoCheck<R> { + pub(crate) fn new(inner: R) -> Self { + NoCheck { + inner, + mode: Mode::Header, + strip: 0, + out: Vec::new(), + at: 0, + } + } + + /// Up to `n` bytes of the input; fewer at its end. + fn take(&mut self, n: usize) -> io::Result<Vec<u8>> { + let mut buf = vec![0u8; n]; + let mut got = 0; + while got < n { + match self.inner.read(&mut buf[got..]) { + Ok(0) => break, + Ok(k) => got += k, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + buf.truncate(got); + Ok(buf) + } + + /// Hands on what `take` got of `n` bytes; `None` and the end of the input + /// when it got fewer. + fn copy(&mut self, n: usize) -> io::Result<Option<()>> { + let b = self.take(n)?; + let whole = b.len() == n; + self.out.extend_from_slice(&b); + if !whole { + self.mode = Mode::Done; + } + Ok(whole.then_some(())) + } + + /// A VLI read from the input, byte by byte, and its bytes. + fn read_vli(&mut self) -> io::Result<(u64, Vec<u8>)> { + let mut raw = Vec::new(); + loop { + let b = self.take(1)?; + let Some(&byte) = b.first() else { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "xz: index cut short", + )); + }; + raw.push(byte); + if byte & 0x80 == 0 || raw.len() == 9 { + break; + } + } + vli_at(&raw) + .map(|(v, _)| (v, raw)) + .ok_or_else(|| bad("bad index number")) + } + + /// The 12 bytes that open a stream, `first` of them already read. + fn start(&mut self, mut header: Vec<u8>) -> io::Result<()> { + header.extend(self.take(12 - header.len())?); + let id = header.get(7).map(|b| b & 0x0F); + match id { + Some(id) + if header.len() == 12 + && header[..6] == MAGIC + && header[6] == 0 + && header[7] >> 4 == 0 + && !supported(id) => + { + self.strip = check_size(id); + self.out.extend_from_slice(&MAGIC); + self.out.extend_from_slice(&[0, 0]); + self.out + .extend_from_slice(&crc32fast::hash(&[0, 0]).to_le_bytes()); + self.mode = Mode::Block; + } + _ => { + self.out.extend_from_slice(&header); + self.mode = Mode::Pass; + } + } + Ok(()) + } + + /// One piece of the stream, rewritten into `out`. + fn step(&mut self) -> io::Result<()> { + match std::mem::replace(&mut self.mode, Mode::Done) { + Mode::Header => { + let first = self.take(4)?; + if first.is_empty() { + return Ok(()); + } + // Zero padding between streams goes on as it is. + if first == [0; 4] { + self.out.extend_from_slice(&first); + self.mode = Mode::Header; + return Ok(()); + } + self.start(first) + } + Mode::Block => { + let b = self.take(1)?; + match b.first() { + None => Ok(()), + Some(0) => self.index(), + Some(&size) => self.block_header(size), + } + } + Mode::Sized { left, header, data } => { + let n = left.min(64 << 10) as usize; + if self.copy(n)?.is_some() { + self.mode = if left > n as u64 { + Mode::Sized { + left: left - n as u64, + header, + data, + } + } else { + Mode::Tail { header, data } + }; + } + Ok(()) + } + Mode::Chunks { header, data } => self.chunk(header, data), + Mode::Tail { header, data } => { + let pad = (4 - (header + data) % 4) % 4; + if self.copy(pad as usize)?.is_some() { + // The check field is left out. + let skipped = self.take(self.strip)?; + self.mode = if skipped.len() == self.strip { + Mode::Block + } else { + Mode::Done + }; + } + Ok(()) + } + Mode::Pass | Mode::Done => Ok(()), + } + } + + /// A block header of `(size + 1) * 4` bytes, `size` being its first byte. + fn block_header(&mut self, size: u8) -> io::Result<()> { + let len = (usize::from(size) + 1) * 4; + let rest = self.take(len - 1)?; + self.out.push(size); + self.out.extend_from_slice(&rest); + if rest.len() < len - 1 { + return Ok(()); + } + let flags = rest[0]; + // The compressed size, when the header says it; the uncompressed size + // follows it and is not needed. + let compressed = if flags & 0x40 != 0 { + Some(vli_at(&rest[1..]).ok_or_else(|| bad("bad block header"))?.0) + } else { + None + }; + let header = len as u64; + self.mode = match compressed { + Some(left) => Mode::Sized { + left, + header, + data: left, + }, + None => Mode::Chunks { header, data: 0 }, + }; + Ok(()) + } + + /// One chunk of LZMA2 data: a control byte, its sizes, its bytes. + fn chunk(&mut self, header: u64, data: u64) -> io::Result<()> { + let Some(&control) = self.take(1)?.first() else { + return Ok(()); + }; + self.out.push(control); + let (fields, props) = match control { + 0 => { + self.mode = Mode::Tail { + header, + data: data + 1, + }; + return Ok(()); + } + 1 | 2 => (2, 0), + 0x80..=0xFF => (4, usize::from(control >= 0xC0)), + _ => return Err(bad("bad LZMA2 chunk")), + }; + let sizes = self.take(fields + props)?; + self.out.extend_from_slice(&sizes); + if sizes.len() < fields + props { + return Ok(()); + } + let packed = if control < 3 { + usize::from(u16::from_be_bytes([sizes[0], sizes[1]])) + 1 + } else { + usize::from(u16::from_be_bytes([sizes[2], sizes[3]])) + 1 + }; + let total = 1 + (fields + props) as u64 + packed as u64; + if self.copy(packed)?.is_some() { + self.mode = Mode::Chunks { + header, + data: data + total, + }; + } + Ok(()) + } + + /// The index and the footer, rewritten for a stream with no check fields. + fn index(&mut self) -> io::Result<()> { + let (count, count_raw) = self.read_vli()?; + let mut taken = 1 + count_raw.len(); + let mut index = vec![0u8]; + index.extend_from_slice(&vli_bytes(count)); + for _ in 0..count { + let (unpadded, a) = self.read_vli()?; + let (uncompressed, b) = self.read_vli()?; + taken += a.len() + b.len(); + let unpadded = unpadded + .checked_sub(self.strip as u64) + .filter(|&n| n > 0) + .ok_or_else(|| bad("bad index record"))?; + index.extend_from_slice(&vli_bytes(unpadded)); + index.extend_from_slice(&vli_bytes(uncompressed)); + if taken > MAX_INDEX { + return Err(bad("index too large")); + } + } + // The padding and the CRC-32 of the input's index: ours are made anew. + let padding = (4 - taken % 4) % 4; + if self.take(padding + 4)?.len() < padding + 4 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "xz: index cut short", + )); + } + index.resize(index.len().next_multiple_of(4), 0); + let crc = crc32fast::hash(&index); + let backward = ((index.len() + 4) / 4 - 1) as u32; + let footer = self.take(12)?; + if footer.len() < 12 || footer[10..] != *b"YZ" { + return Err(bad("bad stream footer")); + } + self.out.extend_from_slice(&index); + self.out.extend_from_slice(&crc.to_le_bytes()); + let mut tail = backward.to_le_bytes().to_vec(); + tail.extend_from_slice(&[0, 0]); + self.out + .extend_from_slice(&crc32fast::hash(&tail).to_le_bytes()); + self.out.extend_from_slice(&tail); + self.out.extend_from_slice(b"YZ"); + self.mode = Mode::Header; + Ok(()) + } +} + +impl<R: Read> Read for NoCheck<R> { + fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { + loop { + if self.at < self.out.len() { + let n = (self.out.len() - self.at).min(buf.len()); + buf[..n].copy_from_slice(&self.out[self.at..self.at + n]); + self.at += n; + return Ok(n); + } + self.out.clear(); + self.at = 0; + match self.mode { + Mode::Pass => return self.inner.read(buf), + Mode::Done => return Ok(0), + _ => self.step()?, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Whatever the bytes, reading ends: with data or an error, never a panic + /// or a loop. Streams with a byte changed, or cut. + #[test] + fn damaged_streams_end() { + let seeds = [ + &include_bytes!("../../tests/fixtures/xz_checks/reserved_12.xz")[..], + &include_bytes!("../../tests/fixtures/xz_checks/sha256.xz")[..], + ]; + let mut state = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for seed in seeds { + for _ in 0..3000 { + let mut blob = seed.to_vec(); + for _ in 0..1 + next() % 3 { + let at = next() as usize % blob.len(); + blob[at] = next() as u8; + } + blob.truncate(1 + next() as usize % blob.len()); + let mut out = Vec::new(); + let _ = NoCheck::new(&blob[..]).read_to_end(&mut out); + } + } + } +} diff --git a/crates/exav-unpack/src/formats/zip.rs b/crates/exav-unpack/src/formats/zip.rs index d134ca3c..dfc77e9a 100644 --- a/crates/exav-unpack/src/formats/zip.rs +++ b/crates/exav-unpack/src/formats/zip.rs @@ -285,17 +285,24 @@ pub(crate) fn raw_decoder<'a, R: Read + 'a>( } /// [`raw_decoder`] over bytes in hand, read whole up to `cap`. `None` when -/// there is no decoder or decoding failed; the flag is set when the member -/// is larger than `cap`. +/// there is no decoder or the codec's header is unreadable. A stream that +/// fails part way gives the bytes decoded before the failure, with the +/// damage in the result ([`crate::Salvaged`]); one larger than `cap` has +/// `over_cap` set. pub(crate) fn decode_zip_raw( method: u16, raw: &[u8], usz: Option<u64>, cap: u64, -) -> Option<(Vec<u8>, bool)> { +) -> Option<crate::Salvaged> { match raw_decoder(method, raw, usz, cap) { - Ok(r) => bounded_read(r, cap).ok(), - Err(RawRefusal::TooBig) => Some((Vec::new(), true)), + Ok(r) => bounded_read_salvage(r, cap, true).ok(), + Err(RawRefusal::TooBig) => Some(crate::Salvaged { + data: Vec::new(), + over_cap: true, + undecoded: false, + cut_short: false, + }), Err(RawRefusal::Unsupported(_) | RawRefusal::Truncated) => None, } } @@ -507,6 +514,21 @@ fn open_archive<R: Read + Seek + Clone>( ::zip::ZipArchive::new(Patched::new(reader)) } +/// Where a ZIP begins when it is appended to something else: the bytes before +/// it count in no offset of its, and `unzip` finds it by its end record, which +/// is at the end of the file. `None` for a file that is not one, or that starts +/// where its offsets say. +pub fn appended_start(src: &dyn crate::source::ByteSource) -> Option<u64> { + let mut r = crate::source::Reader::new(src); + // The longest comment an end record may carry, and the record. + match directory_shift_within(&mut r, 2 * 65_536)? { + Shift::Prepended(n) if n > 0 && src.window(n as usize, 4).starts_with(b"PK\x03\x04") => { + Some(n) + } + _ => None, + } +} + /// Where a ZIP's central directory is, against where its end record says. enum Shift { /// `n` bytes precede the archive, and every offset counts from its start. @@ -565,6 +587,14 @@ impl<R: Seek> Seek for Patched<R> { /// crate tries first. `None` for ZIP64, an empty archive, or no central header /// where that record puts the directory. fn directory_shift<R: Read + Seek>(r: &mut R) -> Option<Shift> { + directory_shift_within(r, u64::MAX) +} + +/// [`directory_shift`] looking at the last `reach` bytes of the file and no +/// further back. An end record that closes an archive stapled to the end of a +/// file is within the longest comment of it, so a search for one that reads +/// the whole of a large file is a pass over it for nothing. +fn directory_shift_within<R: Read + Seek>(r: &mut R, reach: u64) -> Option<Shift> { use std::io::SeekFrom; const EOCD_LEN: usize = 22; const CHUNK: u64 = 1 << 16; @@ -590,7 +620,7 @@ fn directory_shift<R: Read + Seek>(r: &mut R) -> Option<Shift> { break 'found at; } } - if start == 0 { + if start == 0 || len - start >= reach { return None; } end = start + 3; @@ -663,9 +693,32 @@ fn scan_orphan_locals<R>( // Local-header offsets the central directory already covered. let mut known = std::collections::HashSet::new(); if let Ok(mut zip) = open_archive(Cursor::new(data)) { + let read = |at: u64, len: usize| { + usize::try_from(at) + .ok() + .and_then(|at| crate::bytes::at(data, at, len)) + .map_or_else( + || { + let from = usize::try_from(at).unwrap_or(usize::MAX).min(data.len()); + data[from..].iter().take(len).copied().collect() + }, + <[u8]>::to_vec, + ) + }; for i in 0..zip.len() { if let Ok(f) = zip.by_index_raw(i) { - known.insert(f.header_start() as usize); + let start = f.header_start(); + // A member whose local header says otherwise than the + // directory is read once more, as the local header says. + if local_header_agrees( + &read, + start, + f.name_raw(), + zip_method_code(&f.compression()), + f.compressed_size(), + ) { + known.insert(start as usize); + } } } } @@ -691,10 +744,10 @@ fn scan_orphan_locals<R>( if !plausible_local_header(data, off) { continue; } - if let Some(entry) = parse_local_member(data, off, true, budget)? { - if let Some(r) = visit(entry, budget) { - return Ok(Some(r)); - } + if let Some(entry) = parse_local_member(data, off, true, budget)? + && let Some(r) = visit(entry, budget) + { + return Ok(Some(r)); } } Ok(None) @@ -1127,7 +1180,7 @@ fn parse_local_member( "streaming zip member with unrecoverable size (orphan local header)", false, budget, - ) + ); } }, } @@ -1224,7 +1277,7 @@ fn parse_local_member( comp, false, "unsupported zip compression method (orphan local header)", - ))) + ))); } }, }; @@ -1262,6 +1315,7 @@ const PAST_END: &str = /// declaring 0 bytes compressed and uncompressed is empty (Civil 3D writes /// them; Python's zipfile reads them so), not a stream cut before its first /// block. +#[inline(never)] pub(crate) fn member_reader<'a, R: Read + Seek>( zip: &'a mut ::zip::ZipArchive<R>, i: usize, @@ -1559,11 +1613,25 @@ pub fn extract_zip_from<Rd: Read + Seek + Clone, R>( // metadata-only unsupported member and the archive keeps going — one bad // member never aborts the whole ZIP. let (buf, truncated, part_way) = if !crate_decodes(method) { - let (raw, _) = bounded_read(&mut file, budget.limits.max_buffer_bytes) + let (raw, raw_over) = bounded_read(&mut file, budget.limits.max_buffer_bytes) .map_err(|e| LimitHit::new(format!("zip raw read: {e}")))?; drop(file); + // The compressed bytes themselves past the buffer limit: what is + // here is a prefix of the member, not the member. + if raw_over { + if let Some(r) = oversized(budget, "archive member exceeds size budget") { + return Ok(Some(r)); + } + continue; + } match decode_zip_raw(method, &raw, Some(usz), cap) { - Some((out, truncated)) => (out, truncated, false), + // All of the member's compressed bytes were here, so a stream + // that ran out of them hides nothing, but it is not the + // member the directory declares, whose size it does not reach. + Some(s) => { + let part_way = s.undecoded || s.cut_short || s.data.len() as u64 != usz; + (s.data, s.over_cap, part_way) + } None => { if let Some(r) = oversized(budget, "archive member: unsupported ZIP codec") { return Ok(Some(r)); @@ -1628,6 +1696,7 @@ pub fn extract_zip_from<Rd: Read + Seek + Clone, R>( /// members are laid out end to end, this is empty and costs nothing. fn unclaimed_spans<R: Read + Seek>( zip: &mut ::zip::ZipArchive<R>, + read: &dyn Fn(u64, usize) -> Vec<u8>, ) -> (Vec<(u64, u64)>, std::collections::HashSet<u64>) { let cd = zip.central_directory_start(); let mut claimed: Vec<(u64, u64)> = Vec::with_capacity(zip.len()); @@ -1635,6 +1704,18 @@ fn unclaimed_spans<R: Read + Seek>( for i in 0..zip.len() { if let Ok(f) = zip.by_index_raw(i) { let start = f.header_start(); + // A member the local header describes otherwise is read as the + // local header says too, by the orphan scan: it is not known, and + // it claims no space, least of all the bytes of another member. + if !local_header_agrees( + read, + start, + f.name_raw(), + zip_method_code(&f.compression()), + f.compressed_size(), + ) { + continue; + } known.insert(start); // A member using a trailing data descriptor declares no data start, // so its extent is not knowable here. Claiming only the header @@ -1666,6 +1747,41 @@ fn unclaimed_spans<R: Read + Seek>( (gaps, known) } +/// Whether the local header at `start` says of a member what the central +/// directory says: its name, its method, and its compressed size when the +/// header carries one. +/// +/// Readers differ in which they believe. The `zip` crate decodes with the +/// directory's method and size, and Info-ZIP `unzip` with the local header's, so +/// an archive whose two disagree holds one thing for one and another for the +/// other. `read(offset, len)` returns what is there, short at the end of the +/// input. A header that cannot be read as one is left to the errors the member's +/// own open gives. +fn local_header_agrees( + read: &dyn Fn(u64, usize) -> Vec<u8>, + start: u64, + name_raw: &[u8], + method: u16, + comp: u64, +) -> bool { + let h = read(start, LFH_LEN); + if h.len() < LFH_LEN || h[..4] != *b"PK\x03\x04" { + return true; + } + let flags = u16::from_le_bytes([h[6], h[7]]); + if u16::from_le_bytes([h[8], h[9]]) != method { + return false; + } + // The sizes follow the data (bit 3), or are in a ZIP64 field (all ones). + let local_comp = u32::from_le_bytes([h[18], h[19], h[20], h[21]]); + if flags & 0x08 == 0 && local_comp != u32::MAX && u64::from(local_comp) != comp { + return false; + } + let name_len = usize::from(u16::from_le_bytes([h[26], h[27]])); + let name = read(start + LFH_LEN as u64, name_len); + name.len() < name_len || name == name_raw +} + /// Whether the local header at `off` names a member with something to scan: /// not a directory entry (no data, a name ending in '/'), and readable. /// Parses the header only; [`parse_local_member`] reads the member. @@ -1805,12 +1921,63 @@ fn zip_mtime<'a>( /// crate's decompressing reader, so a member decompressing to any size is /// never held. An encrypted member is decrypted whole (decryption needs the /// full ciphertext), bounded by the buffer limit. +/// What `walk` takes from the directory entry of a member. +struct RawEntry { + name: String, + is_dir: bool, + encrypted: bool, + comp: u64, + size: u64, + method: u16, + mtime: Option<crate::Mtime>, + mode: Option<u32>, + local: Option<u16>, +} + +// The three below open a `ZipFile`, which holds the decoders of every codec +// inline: tens of kilobytes. Opened in `walk` itself, one would be in its frame +// for as long as a member is scanned, and a ZIP inside a ZIP inside a ZIP +// takes that once for each level. + +#[inline(never)] +fn raw_entry<R: Read + Seek>( + zip: &mut ::zip::ZipArchive<R>, + src: &dyn crate::source::ByteSource, + i: usize, +) -> ::zip::result::ZipResult<RawEntry> { + let f = zip.by_index_raw(i)?; + Ok(RawEntry { + name: member_name(&f), + is_dir: f.is_dir(), + encrypted: f.encrypted(), + comp: f.compressed_size(), + size: f.size(), + method: zip_method_code(&f.compression()), + mtime: zip_mtime(f.extra_data_fields(), f.last_modified()), + mode: f.unix_mode(), + local: local_flags(&src.window(f.header_start() as usize, 8)), + }) +} + +#[inline(never)] +fn header_parses<R: Read + Seek>( + zip: &mut ::zip::ZipArchive<R>, + i: usize, +) -> ::zip::result::ZipResult<()> { + zip.by_index_raw(i).map(|_| ()) +} + +#[inline(never)] +fn member_decodes<R: Read + Seek>(zip: &mut ::zip::ZipArchive<R>, i: usize) -> bool { + zip.by_index(i).is_ok() +} + pub(crate) fn walk<T>( src: &dyn crate::source::ByteSource, budget: &mut Budget, visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { - use crate::stream::{emit_entry, emit_stream, whole, MemberMeta}; + use crate::stream::{MemberMeta, emit_entry, emit_stream, whole}; // A forged or corrupt central directory (a bad CDFH offset in the EOCD, // say) makes the seekable walk impossible, but the members' local file // headers are still in the file. Fall back to reading the archive whole @@ -1831,7 +1998,7 @@ pub(crate) fn walk<T>( // strand every later member: the salvage pass skips them as already // covered, so a payload behind one bad directory pointer would never // be scanned. - if let Err(e) = zip.by_index_raw(i).map(|_| ()) { + if let Err(e) = header_parses(&mut zip, i) { let hit = zip_entry_error(i, &e); if !hit.is_corrupt() { return Err(hit); @@ -1846,20 +2013,17 @@ pub(crate) fn walk<T>( } continue; } - let (name, is_dir, encrypted, comp, size, method, mtime, mode, local) = { - let f = zip.by_index_raw(i).map_err(|e| zip_entry_error(i, &e))?; - ( - member_name(&f), - f.is_dir(), - f.encrypted(), - f.compressed_size(), - f.size(), - zip_method_code(&f.compression()), - zip_mtime(f.extra_data_fields(), f.last_modified()), - f.unix_mode(), - local_flags(&src.window(f.header_start() as usize, 8)), - ) - }; + let RawEntry { + name, + is_dir, + encrypted, + comp, + size, + method, + mtime, + mode, + local, + } = raw_entry(&mut zip, src, i).map_err(|e| zip_entry_error(i, &e))?; // `is_dir()` is true purely because the name ends in '/'. A JAR packer // buys exactly that: `kingDavid/9.class/` holds a real deflate-compressed // class that the JVM loads by name, while every ZIP tool discards it as a @@ -1913,8 +2077,7 @@ pub(crate) fn walk<T>( // decoder, so fall back to the raw bytes and decode them ourselves, // as `extract_zip_from` does. Probe first so the borrow of `zip` ends // before the fallback needs it. - let decodable = zip.by_index(i).is_ok(); - let mut file = match decodable { + let mut file = match member_decodes(&mut zip, i) { true => member_reader(&mut zip, i).map_err(|e| zip_entry_error(i, &e))?, false => { let out = walk_raw_decode(&mut zip, src, i, meta, budget, visit, &mut deferred)?; @@ -1930,7 +2093,7 @@ pub(crate) fn walk<T>( } // Members left out of the central directory while their local headers and // data stay in the file, which is what the target extracts. - for entry in hidden_members(zip, budget)? { + for entry in hidden_members(zip, src, budget)? { if let Some(t) = emit_entry(entry, budget, visit)? { return Ok(Some(t)); } @@ -1938,11 +2101,41 @@ pub(crate) fn walk<T>( deferred.map_or(Ok(None), Err) } +/// A member's raw bytes behind the decoder for its method. +struct RawMember<'a> { + method: u16, + usz: u64, + data_start: Option<u64>, + decoder: Result<Box<dyn Read + 'a>, RawRefusal>, +} + +/// Opens member `i` raw and puts its decoder on it. Kept out of +/// `walk_raw_decode`: a `ZipFile` is tens of kilobytes, and a frame that held +/// one would stay on the stack while the member is scanned. +#[inline(never)] +fn raw_member<'a, R: Read + Seek>( + zip: &'a mut ::zip::ZipArchive<R>, + i: usize, + cap: u64, +) -> Option<RawMember<'a>> { + let f = zip.by_index_raw(i).ok()?; + let method = zip_method_code(&f.compression()); + let usz = f.size(); + let data_start = f.data_start(); + Some(RawMember { + method, + usz, + data_start, + decoder: raw_decoder(method, f, Some(usz), cap), + }) +} + /// Decode a member the `zip` crate refused, from its RAW bytes, using exav's own /// codec set, as it is read. The member is visited once: decoded, or as a /// marker saying why it could not be. A decoder needing more memory than the /// buffer limit leaves the member unread, and the limit goes in `deferred` /// for the walk to report once the other members are scanned. +#[inline(never)] fn walk_raw_decode<R: Read + Seek, T>( zip: &mut ::zip::ZipArchive<R>, src: &dyn crate::source::ByteSource, @@ -1953,17 +2146,17 @@ fn walk_raw_decode<R: Read + Seek, T>( deferred: &mut Option<LimitHit>, ) -> Result<Option<T>, LimitHit> { let cap = budget.limits.max_buffer_bytes; - let f = match zip.by_index_raw(i) { - Ok(f) => f, - Err(_) => { - meta.unsupported = Some("ZIP member header will not parse"); - return Ok(visit(&meta, None, budget)); - } + let Some(RawMember { + method, + usz, + data_start, + decoder, + }) = raw_member(zip, i, cap) + else { + meta.unsupported = Some("ZIP member header will not parse"); + return Ok(visit(&meta, None, budget)); }; - let method = zip_method_code(&f.compression()); - let usz = f.size(); - let data_start = f.data_start(); - match raw_decoder(method, f, Some(usz), cap) { + match decoder { Ok(mut r) => crate::stream::emit_stream(&meta, &mut r, budget, visit), Err(RawRefusal::Unsupported(_)) if !defined_method(method) && data_start.is_some() => { // A method no ZIP specification defines is no codec at all. @@ -2006,6 +2199,7 @@ fn walk_raw_decode<R: Read + Seek, T>( /// One encrypted ZIP member: try the password pool and hand over the /// plaintext on success, else a metadata-only unsupported member, so the /// caller reports it without masking later members. +#[inline(never)] fn walk_encrypted<R: Read + Seek, T>( zip: &mut ::zip::ZipArchive<R>, i: usize, @@ -2013,7 +2207,7 @@ fn walk_encrypted<R: Read + Seek, T>( budget: &mut Budget, visit: crate::stream::Visit<T>, ) -> Result<Option<T>, LimitHit> { - use crate::stream::{emit_bytes, MemberMeta}; + use crate::stream::{MemberMeta, emit_bytes}; let unsupported_meta = MemberMeta { encrypted: true, unsupported: Some("encrypted ZIP member"), @@ -2067,11 +2261,15 @@ fn walk_encrypted<R: Read + Seek, T>( /// bounded search [`ZipMembers`] uses: only the space the directory leaves /// unaccounted for is read, up to [`MAX_ORPHAN_SCAN`], and a search that stops /// there yields a member saying so. +#[inline(never)] pub(crate) fn hidden_members<Rd: Read + Seek>( mut zip: ::zip::ZipArchive<Rd>, + src: &dyn crate::source::ByteSource, budget: &mut Budget, ) -> Result<Vec<Entry>, LimitHit> { - let (gaps, known) = unclaimed_spans(&mut zip); + let (gaps, known) = unclaimed_spans(&mut zip, &|at, len| { + usize::try_from(at).map_or_else(|_| Vec::new(), |at| src.window(at, len).into_owned()) + }); if gaps.is_empty() { return Ok(Vec::new()); } @@ -2100,6 +2298,43 @@ pub(crate) fn hidden_members<Rd: Read + Seek>( /// The member that says the search for hidden members stopped at its limit. const HIDDEN_SEARCH_TRUNCATED: &str = "<hidden-member search stopped at its limit>"; +/// The search for a ZIP stapled to the end of a file reads the end of it. +#[cfg(test)] +mod appended_start_tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct Tally(std::io::Cursor<Vec<u8>>, Arc<AtomicU64>); + impl Read for Tally { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + let n = self.0.read(buf)?; + self.1.fetch_add(n as u64, Ordering::Relaxed); + Ok(n) + } + } + impl Seek for Tally { + fn seek(&mut self, p: std::io::SeekFrom) -> std::io::Result<u64> { + self.0.seek(p) + } + } + + #[test] + fn a_file_with_no_end_record_is_not_read_through_for_one() { + let read = Arc::new(AtomicU64::new(0)); + let cache = crate::source::BlockCache::with_sizes( + Tally(std::io::Cursor::new(vec![b'x'; 8 << 20]), read.clone()), + 64 * 1024, + 1 << 20, + ) + .unwrap(); + let before = read.load(Ordering::Relaxed); + assert_eq!(appended_start(&cache), None); + let used = read.load(Ordering::Relaxed) - before; + assert!(used <= 1 << 20, "{used} bytes read for an 8 MiB file"); + } +} + #[cfg(test)] mod orphan_scan_tests { use super::*; @@ -2196,6 +2431,118 @@ mod directory_tests { } } +/// The buffered path (`extract_zip_from`) over a member of a codec the `zip` +/// crate does not decode. +#[cfg(all(test, feature = "bzip2"))] +mod buffered_raw_tests { + use super::*; + use std::io::Write; + + /// A one-member ZIP, central directory included. + fn zip_of(method: u16, raw: &[u8], crc: u32, usz: u32) -> Vec<u8> { + let fields = |v: &mut Vec<u8>| { + v.extend_from_slice(&20u16.to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); + v.extend_from_slice(&method.to_le_bytes()); + v.extend_from_slice(&[0; 4]); + v.extend_from_slice(&crc.to_le_bytes()); + v.extend_from_slice(&(raw.len() as u32).to_le_bytes()); + v.extend_from_slice(&usz.to_le_bytes()); + v.extend_from_slice(&5u16.to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); + }; + let mut z = b"PK\x03\x04".to_vec(); + fields(&mut z); + z.extend_from_slice(b"m.bin"); + z.extend_from_slice(raw); + let mut cd = b"PK\x01\x02".to_vec(); + cd.extend_from_slice(&20u16.to_le_bytes()); + fields(&mut cd); + cd.extend_from_slice(&[0; 6]); + cd.extend_from_slice(&0u32.to_le_bytes()); + cd.extend_from_slice(&0u32.to_le_bytes()); + cd.extend_from_slice(b"m.bin"); + let cd_at = z.len() as u32; + z.extend_from_slice(&cd); + z.extend_from_slice(b"PK\x05\x06\0\0\0\0\x01\0\x01\0"); + z.extend_from_slice(&(cd.len() as u32).to_le_bytes()); + z.extend_from_slice(&cd_at.to_le_bytes()); + z.extend_from_slice(&0u16.to_le_bytes()); + z + } + + fn noise(n: usize) -> Vec<u8> { + let mut x: u32 = 0x1234_5678; + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + (x >> 24) as u8 + }) + .collect() + } + + fn bzip2(data: &[u8]) -> Vec<u8> { + let mut w = ::bzip2::write::BzEncoder::new(Vec::new(), ::bzip2::Compression::new(1)); + w.write_all(data).unwrap(); + w.finish().unwrap() + } + + fn entries(zip: &[u8]) -> Vec<Entry> { + let mut out = Vec::new(); + let mut budget = Budget::new(crate::Limits::default()); + extract_zip_from( + Cursor::new(zip), + &mut budget, + &mut |e: Entry, _: &mut Budget| { + out.push(e); + None::<()> + }, + ) + .unwrap(); + out + } + + /// Three 100 kB blocks, the last damaged: the first two are scanned and the + /// member is reported. + #[test] + fn a_member_damaged_part_way_keeps_its_prefix_and_is_reported() { + let data = noise(270_000); + let mut raw = bzip2(&data); + let at = raw.len() * 4 / 5; + raw[at..].fill(0xff); + let e = entries(&zip_of(12, &raw, 0, data.len() as u32)); + assert_eq!(e.len(), 1); + assert!( + e[0].data.len() >= 100_000 && data.starts_with(&e[0].data), + "the blocks before the damage are lost: {} bytes", + e[0].data.len() + ); + assert!( + e[0].unsupported.is_some(), + "a member damaged part way came out whole" + ); + } + + #[test] + fn a_whole_member_is_not_reported() { + let data = noise(270_000); + let e = entries(&zip_of(12, &bzip2(&data), 0, data.len() as u32)); + assert!(e[0].unsupported.is_none() && e[0].data == data); + } + + /// A stream that ends whole but short of the size the directory declares is + /// not that member. + #[test] + fn a_member_shorter_than_declared_is_reported() { + let data = noise(1000); + let e = entries(&zip_of(12, &bzip2(&data), 0, 5000)); + assert_eq!(e[0].data, data); + assert!(e[0].unsupported.is_some()); + } +} + #[cfg(test)] mod zip_ppmd_tests { use super::*; @@ -2239,10 +2586,8 @@ mod zip_ppmd_tests { fn ppmd_model_over_the_buffer_limit_is_too_big() { // order 6, 256 MB. let raw = [0xf5u8, 0x0f, 0xde, 0xad, 0xbe, 0xef]; - assert_eq!( - decode_zip_raw(98, &raw, Some(64), 1 << 20), - Some((Vec::new(), true)) - ); + let s = decode_zip_raw(98, &raw, Some(64), 1 << 20).unwrap(); + assert!(s.over_cap && s.data.is_empty() && !s.undecoded); assert!(matches!( raw_decoder(98, &raw[..], Some(64), 1 << 20), Err(RawRefusal::TooBig) @@ -2256,9 +2601,10 @@ mod zip_ppmd_tests { let cap = budget.reserve().unwrap(); // Valid parameter header, garbage stream. let raw = [0x07u8, 0x01, 0xde, 0xad, 0xbe, 0xef]; + let s = decode_zip_raw(98, &raw, Some(64), cap).expect("a decoder is there"); assert!( - decode_zip_raw(98, &raw, Some(64), cap).is_none(), - "a corrupt PPMd stream must decode to nothing, so the caller reports it" + s.undecoded || s.cut_short, + "a corrupt PPMd stream must say so, for the caller to report it" ); } @@ -2270,7 +2616,7 @@ mod zip_ppmd_tests { env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/zip/7z_ppmd_large_then_small.zip" ); - let zip = std::fs::read(path).unwrap(); + let zip = crate::read_fixture(path).unwrap(); let u32_at = |o: usize| u32::from_le_bytes(zip[o..o + 4].try_into().unwrap()); let u16_at = |o: usize| u16::from_le_bytes([zip[o], zip[o + 1]]) as usize; let (comp, usz) = (u32_at(18) as usize, Some(u32_at(22) as u64)); @@ -2278,8 +2624,15 @@ mod zip_ppmd_tests { let member = &zip[start..start + comp]; assert_eq!(member[..2], [0x0b, 0x10]); let cap = 1 << 20; - let (whole, over) = decode_zip_raw(98, member, usz, cap).expect("intact member decodes"); - assert_eq!((Some(whole.len() as u64), over), (usz, false)); + let whole = decode_zip_raw(98, member, usz, cap).expect("intact member decodes"); + assert_eq!( + ( + Some(whole.data.len() as u64), + whole.over_cap, + whole.undecoded + ), + (usz, false, false) + ); let flips = (2..18) .flat_map(|p| (0..8).map(move |b| (p, b))) @@ -2287,15 +2640,20 @@ mod zip_ppmd_tests { for (pos, bit) in flips { let mut bad = member.to_vec(); bad[pos] ^= 1 << bit; - if let Some((out, _)) = decode_zip_raw(98, &bad, usz, cap) { - assert!(Some(out.len() as u64) <= usz); + if let Some(s) = decode_zip_raw(98, &bad, usz, cap) { + assert!(Some(s.data.len() as u64) <= usz); } } for cut in (0..member.len()).step_by(97) { - assert!( - decode_zip_raw(98, &member[..cut], usz, cap).is_none(), - "cut at {cut}: a truncated member is not a decoded one" - ); + // The header's two bytes cut, nothing decodes; later, a prefix does + // and the cut is the stream's running out, never a whole member. + match decode_zip_raw(98, &member[..cut], usz, cap) { + None => assert!(cut < 2, "cut at {cut}: no decoder"), + Some(s) => assert!( + Some(s.data.len() as u64) < usz && (s.cut_short || s.undecoded), + "cut at {cut}: a truncated member is not a decoded one" + ), + } } } } diff --git a/crates/exav-unpack/src/formats/zip_crypto.rs b/crates/exav-unpack/src/formats/zip_crypto.rs index fe7fe98c..1ddcdc2a 100644 --- a/crates/exav-unpack/src/formats/zip_crypto.rs +++ b/crates/exav-unpack/src/formats/zip_crypto.rs @@ -54,7 +54,9 @@ pub(crate) fn decrypt_aes(body: &[u8], strength: u8, password: &[u8]) -> Option< let mac_key = &derived[key_len..key_len * 2]; let verifier = &derived[key_len * 2..]; - if !constant_time_eq::constant_time_eq(verifier, pw_verify) { + // Plain comparisons: the password is the caller's own and the file is the + // attacker's, so there is no secret for timing to leak. + if verifier != pw_verify { return None; } @@ -62,7 +64,7 @@ pub(crate) fn decrypt_aes(body: &[u8], strength: u8, password: &[u8]) -> Option< let mut mac = HmacSha1::new_from_slice(mac_key).ok()?; mac.update(ct); let full = mac.finalize().into_bytes(); - if !constant_time_eq::constant_time_eq(&full[..10], stored_mac) { + if full[..10] != *stored_mac { return None; } diff --git a/crates/exav-unpack/src/formats/zoo.rs b/crates/exav-unpack/src/formats/zoo.rs index 8537a225..c7f38906 100644 --- a/crates/exav-unpack/src/formats/zoo.rs +++ b/crates/exav-unpack/src/formats/zoo.rs @@ -20,7 +20,7 @@ //! With checksums verified (`Budget::set_verify_checksums`) the entry also //! carries the mismatch. -use super::zoo_parse::{crc16_arc, DirEntry, Header, Method, DIRENT_HEADER_SIZE, ZOO_HEADER_SIZE}; +use super::zoo_parse::{DIRENT_HEADER_SIZE, DirEntry, Header, Method, ZOO_HEADER_SIZE, crc16_arc}; use crate::{Budget, Entry, LimitHit, Sink}; /// Bound on the directory walk; hitting it is reported, never a quiet stop. diff --git a/crates/exav-unpack/src/formats/zstd.rs b/crates/exav-unpack/src/formats/zstd.rs index 3c6ca8a9..6c6b2c36 100644 --- a/crates/exav-unpack/src/formats/zstd.rs +++ b/crates/exav-unpack/src/formats/zstd.rs @@ -6,7 +6,7 @@ use ruzstd::decoding::errors::{FrameDecoderError, ReadFrameHeaderError}; use ruzstd::decoding::{BlockDecodingStrategy, FrameDecoder}; use crate::source::{ByteSource, Reader}; -use crate::stream::{emit_stream, single_meta, Visit}; +use crate::stream::{Visit, emit_stream, single_meta}; use crate::{Budget, LimitHit}; pub(crate) fn walk<T>( @@ -136,8 +136,8 @@ impl<R: Read> Read for ZstdReader<R> { #[cfg(test)] mod tests { - use crate::{extract, Budget, Format, Limits}; - use ruzstd::encoding::{compress_to_vec, CompressionLevel}; + use crate::{Budget, Format, Limits, extract}; + use ruzstd::encoding::{CompressionLevel, compress_to_vec}; #[test] fn every_frame_is_decoded() { @@ -155,4 +155,31 @@ mod tests { assert_eq!(entries.len(), 1); assert_eq!(entries[0].data, b"first frame PAYLOAD in frame two"); } + + /// RUSTSEC-2026-0344: a block may not decode to more than 128 KiB. Here + /// 37 bytes of block decode to 640 KiB; with 40,000 sequences instead of + /// 10, a block under 128 KiB would decode to 2.6 GB. + #[test] + fn a_block_decoding_past_its_maximum_is_refused() { + const SEQS: usize = 10; + let mut block = vec![(SEQS as u8) << 3]; // raw literals, 5-bit size + block.extend_from_slice(&[b'A'; SEQS]); + block.push(SEQS as u8); + // All three codes RLE: literal length 1, repeat offset 1, and match + // length code 52 (65,539 plus 16 extra bits, all zero here). + block.extend_from_slice(&[0b0101_0100, 1, 0, 52]); + block.extend_from_slice(&[0; 2 * SEQS]); + block.push(1); // end of the backward bitstream + let header = 1 | (2 << 1) | ((block.len() as u32) << 3); // last, compressed + let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x50]; // window 1 MiB + frame.extend_from_slice(&header.to_le_bytes()[..3]); + frame.extend(block); + + let mut out = Vec::new(); + let r = std::io::Read::read_to_end(&mut super::ZstdReader::new(&frame[..]), &mut out); + assert!(r.is_err(), "decoded {} bytes from one block", out.len()); + // ruzstd stops at the sequence that crosses the bound: at most one + // match (131,074 bytes) past it. + assert!(out.len() <= 128 * 1024 + 131_074, "{} bytes", out.len()); + } } diff --git a/crates/exav-unpack/src/html.rs b/crates/exav-unpack/src/html.rs new file mode 100644 index 00000000..16e21ec3 --- /dev/null +++ b/crates/exav-unpack/src/html.rs @@ -0,0 +1,393 @@ +//! HTML as a stream of tokens, through html5gum's tokenizer alone: its states, +//! entities and quirks are the specification's, and no tree is built. The one +//! HTML reader of the scanner's text view and of the converters that read +//! Word's and Excel's web pages. +//! +//! What this adds is the reading of the tokenizer's output: element and +//! attribute names in lower case, the content of `script`, `style`, `title`, +//! `textarea`, `iframe`, `noembed`, `noframes` and `xmp` read as the +//! specification reads it (text up to the end tag), and the text of a run of +//! characters in one token. What a reader does with an element is its own +//! policy. +//! +//! [`decode`] is the other half: the bytes of a page as text, in the encoding +//! its byte order mark or `<meta>` names. + +use html5gum::{HtmlString, State, StringReader, Token as Gum, Tokenizer}; +use std::borrow::Cow; + +/// Attributes kept of one tag. +pub const MAX_ATTRS: usize = 256; + +#[derive(Debug, PartialEq)] +pub enum Token { + Start { + name: String, + attrs: Vec<(String, String)>, + /// Written `<name ... />`. + closed: bool, + }, + End(String), + Text(String), + Comment(String), +} + +impl Token { + /// What the token takes in memory: its own size and its strings'. + pub fn size(&self) -> u64 { + const ALLOC: u64 = 16; + let heap = match self { + Token::Start { name, attrs, .. } => { + name.len() as u64 + + ALLOC + + attrs.capacity() as u64 * std::mem::size_of::<(String, String)>() as u64 + + attrs + .iter() + .map(|(k, v)| (k.len() + v.len()) as u64 + 2 * ALLOC) + .sum::<u64>() + } + Token::End(s) | Token::Text(s) | Token::Comment(s) => s.len() as u64 + ALLOC, + }; + std::mem::size_of::<Token>() as u64 + heap + } +} + +/// The tokens of `src`, one at a time. +pub fn tokens(src: &str) -> Tokens<'_> { + Tokens { + gum: Tokenizer::new(src), + ahead: None, + } +} + +pub struct Tokens<'a> { + gum: Tokenizer<StringReader<'a>>, + /// A token read past the end of a text. + ahead: Option<Token>, +} + +fn lower(b: &HtmlString) -> String { + String::from_utf8_lossy(b).to_ascii_lowercase() +} + +fn text(b: &HtmlString) -> String { + String::from_utf8_lossy(b).into_owned() +} + +impl Tokens<'_> { + /// The next token as html5gum has it, converted. + fn one(&mut self) -> Option<Token> { + loop { + match self.gum.next()?.ok()? { + Gum::String(s) => return Some(Token::Text(text(&s.value))), + Gum::Comment(c) => return Some(Token::Comment(text(&c.value))), + Gum::EndTag(t) => return Some(Token::End(lower(&t.name))), + Gum::StartTag(t) => { + let name = lower(&t.name); + // The tokenizer reads what is inside as the specification has it. + match name.as_str() { + "script" => self.gum.set_state(State::ScriptData), + "style" | "iframe" | "noembed" | "noframes" | "xmp" => { + self.gum.set_state(State::RawText) + } + "textarea" | "title" => self.gum.set_state(State::RcData), + "plaintext" => self.gum.set_state(State::PlainText), + _ => {} + } + let attrs = t + .attributes + .iter() + .take(MAX_ATTRS) + .map(|(k, v)| (lower(k), text(&v.value))) + .collect(); + return Some(Token::Start { + name, + attrs, + closed: t.self_closing, + }); + } + Gum::Doctype(_) | Gum::Error(_) => {} + } + } + } +} + +impl Iterator for Tokens<'_> { + type Item = Token; + + fn next(&mut self) -> Option<Token> { + let first = match self.ahead.take() { + Some(t) => t, + None => self.one()?, + }; + let Token::Text(mut joined) = first else { + return Some(first); + }; + loop { + match self.one() { + Some(Token::Text(more)) => joined.push_str(&more), + other => { + self.ahead = other; + return Some(Token::Text(joined)); + } + } + } + } +} + +/// The tokens of `src` did not fit what the caller allows. +#[derive(Debug, PartialEq, Eq)] +pub struct TooLarge; + +/// All the tokens of `src`. `charge` is told what each takes in memory as it +/// is made, and answers whether the caller allows it: a page of `<a>` takes +/// many times its size. +pub fn read(src: &str, charge: &mut dyn FnMut(u64) -> bool) -> Result<Vec<Token>, TooLarge> { + let mut out = Vec::new(); + for t in tokens(src) { + if !charge(t.size()) { + return Err(TooLarge); + } + out.push(t); + } + Ok(out) +} + +/// The text of `s` with its character references resolved, as the tokenizer +/// resolves them; markup in `s` is dropped. +pub fn unescape(s: &str) -> String { + tokens(s) + .filter_map(|t| match t { + Token::Text(t) => Some(t), + _ => None, + }) + .collect() +} + +/// The tokens of a page Office wrote, without what it writes for browsers: +/// the content of a downlevel-revealed conditional (`<![if !vml]> ... <![endif]>`, +/// and the ones that name `support`) is not what Office reads, and the markers +/// go. They are comments to the tokenizer. +pub fn without_downlevel(tokens: Vec<Token>) -> Vec<Token> { + // `Some(Some(hide))` opens a conditional, `Some(None)` ends one. + let marker = |t: &Token| match t { + Token::Comment(c) => { + let c = c.trim().to_ascii_lowercase(); + if c == "[endif]" { + Some(None) + } else if c.starts_with("[if") && c.ends_with(']') && !c.contains('>') { + Some(Some(c.contains('!') || c.contains("support"))) + } else { + None + } + } + _ => None, + }; + let mut out = Vec::with_capacity(tokens.len()); + // For each conditional open, whether it hides its content. + let mut open: Vec<bool> = vec![]; + for t in tokens { + match marker(&t) { + Some(Some(hide)) => open.push(hide), + Some(None) => { + open.pop(); + } + None if open.iter().any(|&h| h) => {} + None => out.push(t), + } + } + out +} + +/// The value of the attribute `key` of a start tag. +pub fn attr<'a>(attrs: &'a [(String, String)], key: &str) -> Option<&'a str> { + attrs.iter().find(|a| a.0 == key).map(|a| a.1.as_str()) +} + +/// How far into a page its encoding is looked for. +const HEAD: usize = 4096; + +/// UTF-16 by its byte order mark, or by the `<` of a page without one. +fn utf16(b: &[u8]) -> Option<&'static encoding_rs::Encoding> { + match b { + [0xFF, 0xFE, ..] | [b'<', 0, b'?' | b'!' | b'h' | b'H', 0, ..] => { + Some(encoding_rs::UTF_16LE) + } + [0xFE, 0xFF, ..] | [0, b'<', 0, b'?' | b'!' | b'h' | b'H', ..] => { + Some(encoding_rs::UTF_16BE) + } + _ => None, + } +} + +/// The value of `name="..."` (or `'...'`, or bare) in `head`, any case. +fn attr_value(head: &str, name: &str) -> Option<String> { + let lower = head.to_ascii_lowercase(); + let mut from = 0; + while let Some(p) = lower[from..].find(name) { + let mut i = from + p + name.len(); + from = i; + let b = lower.as_bytes(); + while i < b.len() && b[i] == b' ' { + i += 1; + } + if b.get(i) != Some(&b'=') { + continue; + } + i += 1; + while i < b.len() && b[i] == b' ' { + i += 1; + } + let quote = b.get(i).copied().filter(|q| matches!(q, b'"' | b'\'')); + if quote.is_some() { + i += 1; + } + let start = i; + while i < b.len() && !matches!(b[i], b'"' | b'\'' | b' ' | b'>' | b';' | b'/') { + i += 1; + } + if i > start { + return Some(lower[start..i].to_string()); + } + } + None +} + +/// A page as text: its byte order mark's encoding, else the charset a +/// `<meta>` in its first kilobytes names, else UTF-8 when it is valid UTF-8, +/// else Windows-1252, in which no byte is invalid. +pub fn decode(b: &[u8]) -> Cow<'_, str> { + if let Some(enc) = utf16(b) { + // UTF-16 cut after an odd byte: the half unit dropped. + let even = b.len() - b.len() % 2; + return enc.decode_with_bom_removal(&b[..even]).0; + } + if let Some(rest) = b.strip_prefix(b"\xEF\xBB\xBF") { + return String::from_utf8_lossy(rest); + } + let head = String::from_utf8_lossy(&b[..b.len().min(HEAD)]); + let named = + attr_value(&head, "charset").and_then(|l| encoding_rs::Encoding::for_label(l.as_bytes())); + let enc = match named { + // A page declaring UTF-16 that is not is read as ASCII-compatible. + Some(e) if e == encoding_rs::UTF_16LE || e == encoding_rs::UTF_16BE => encoding_rs::UTF_8, + Some(e) => e, + None if std::str::from_utf8(b).is_ok() => encoding_rs::UTF_8, + None => encoding_rs::WINDOWS_1252, + }; + enc.decode_without_bom_handling(b).0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all(src: &str) -> Vec<Token> { + tokens(src).collect() + } + + fn start(name: &str, attrs: &[(&str, &str)], closed: bool) -> Token { + Token::Start { + name: name.into(), + attrs: attrs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + closed, + } + } + + #[test] + fn names_are_lower_case_and_text_is_one_token() { + assert_eq!( + all("<TD Class=xl65 X:Num='3'>a & b <c</TD><br/>"), + [ + start("td", &[("class", "xl65"), ("x:num", "3")], false), + Token::Text("a & b <c".into()), + Token::End("td".into()), + start("br", &[], true), + ] + ); + } + + #[test] + fn the_content_of_a_raw_element_is_text_up_to_its_end_tag() { + assert_eq!( + all( + "<style>td { x: 1 } <b></style><script>if (a<b) {}</script><title>A & B" + ), + [ + start("style", &[], false), + Token::Text("td { x: 1 } ".into()), + Token::End("style".into()), + start("script", &[], false), + Token::Text("if (ax"); + assert_eq!( + t[0], + Token::Comment("[if gte mso 9]>bcdef\ + ", + )); + assert_eq!( + t, + [ + Token::Text("a".into()), + Token::Text("e".into()), + Token::Text("f".into()), + Token::Comment("[if gte mso 9]>".repeat(1000); + let mut told = 0u64; + let r = read(&page, &mut |n| { + told += n; + true + }); + assert_eq!(r.unwrap().len(), 1000); + assert!(told > 1000 * 3 * 20, "told {told} for 3000 bytes"); + assert_eq!(read(&page, &mut |_| false), Err(TooLarge)); + } + + #[test] + fn the_encoding_a_page_names_is_the_one_it_is_read_in() { + assert_eq!( + decode(b"\xC0"), + "\u{410}" + ); + assert_eq!( + decode(b"\xE9"), + "\u{E9}" + ); + assert_eq!(decode("é".as_bytes()), "é"); + assert_eq!(decode(b"\xE9"), "\u{E9}"); + let utf16: Vec = [0xFF, 0xFE] + .into_iter() + .chain("é".encode_utf16().flat_map(u16::to_le_bytes)) + .collect(); + assert_eq!(decode(&utf16), "é"); + } +} diff --git a/crates/exav-unpack/src/inflate.rs b/crates/exav-unpack/src/inflate.rs index 0027bcc0..285e853d 100644 --- a/crates/exav-unpack/src/inflate.rs +++ b/crates/exav-unpack/src/inflate.rs @@ -93,7 +93,7 @@ impl Inflate { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, "deflate stream ends before its last block", - )) + )); } Ok(_) => self.out.resize(OUT, 0), Err(e) => { @@ -189,7 +189,7 @@ impl Gunzip { return Err(io::Error::new( io::ErrorKind::InvalidInput, "not a gzip stream", - )) + )); } } } diff --git a/crates/exav-unpack/src/kdf.rs b/crates/exav-unpack/src/kdf.rs new file mode 100644 index 00000000..b6c1a667 --- /dev/null +++ b/crates/exav-unpack/src/kdf.rs @@ -0,0 +1,138 @@ +//! What a scan spends on password key derivations, and the keys it made. +//! +//! Every derivation costs time the file chooses: 7z's runs 2^`numCyclesPower` +//! SHA-256 rounds (up to 2^24) for every password tried over every folder, and +//! a disk image's PBKDF2 asks for any count a `u32` holds. None of that is +//! members, bytes or entries, which the [`Budget`](crate::Budget) counts, so it +//! is counted here, over the whole scan however many archives it opens, as +//! RAR5's was (`rar5_crypt`). A derivation past the bound is not made, and the +//! member is reported encrypted like one no password opens. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// SHA-256 rounds all the 7z derivations of one scan may add up to: about +/// 1.3 s (a round is about 20 ns), 128 derivations at 7-Zip's default of 2^19. +pub(crate) const SEVENZ_MAX_ROUNDS: u64 = 1 << 26; + +/// PBKDF2 iterations all the derivations of one scan may add up to, for RAR5 +/// and for disk images alike: about 0.7 s of HMACs. See `rar5_crypt`. +pub(crate) const PBKDF2_MAX_ROUNDS: u64 = 1 << 22; + +/// The least a derivation is charged, so that many with a tiny count still +/// add up. +const MIN_CHARGE: u64 = 256; + +/// The most keys kept, of one scan. +const MAX_KEYS: usize = 4096; + +/// Adds `rounds` to `spent` unless that passes `max`; a refused derivation is +/// not charged, so one member asking for too much leaves the rest of the scan +/// its share. +pub(crate) fn charge(spent: &AtomicU64, rounds: u64, max: u64) -> bool { + let rounds = rounds.max(MIN_CHARGE); + let mut now = spent.load(Ordering::Relaxed); + loop { + let Some(next) = now.checked_add(rounds).filter(|&t| t <= max) else { + return false; + }; + match spent.compare_exchange_weak(now, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => return true, + Err(seen) => now = seen, + } + } +} + +/// A 7z key: the password as UTF-16LE, the salt, `numCyclesPower`. +type SevenZKey = (Vec, Vec, u32); + +/// The derivations of one scan. +#[derive(Default)] +pub(crate) struct Kdf { + sevenz_rounds: AtomicU64, + sevenz_keys: Mutex>, +} + +impl Kdf { + /// The 7z key for a password, a salt and a count, made by `derive` (which is + /// charged `rounds`) unless this scan made it before. `None` when the scan has + /// spent its share, or `derive` refuses. + pub(crate) fn sevenz_key( + &self, + password: &[u8], + salt: &[u8], + cycles: u32, + rounds: u64, + derive: impl FnOnce() -> Option<[u8; 32]>, + ) -> Option<[u8; 32]> { + let id = (password.to_vec(), salt.to_vec(), cycles); + if let Some(k) = self.sevenz_keys.lock().ok()?.get(&id) { + return Some(*k); + } + if !charge(&self.sevenz_rounds, rounds, SEVENZ_MAX_ROUNDS) { + return None; + } + let key = derive()?; + if let Ok(mut keys) = self.sevenz_keys.lock() + && keys.len() < MAX_KEYS + { + keys.insert(id, key); + } + Some(key) + } + + #[cfg(test)] + pub(crate) fn sevenz_spent(&self) -> u64 { + self.sevenz_rounds.load(Ordering::Relaxed) + } +} + +/// A password to try, with what its derivations are counted against. +#[derive(Clone, Copy)] +pub(crate) struct Pass<'a> { + pub word: &'a str, + pub kdf: &'a Kdf, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_key_is_made_once_and_the_scan_has_a_share() { + let kdf = Kdf::default(); + let mut made = 0; + for _ in 0..5 { + let k = kdf.sevenz_key(b"pw", b"", 19, 1 << 19, || { + made += 1; + Some([1; 32]) + }); + assert_eq!(k, Some([1; 32])); + } + assert_eq!(made, 1); + assert_eq!(kdf.sevenz_spent(), 1 << 19); + // Other salts are other keys, until the scan has spent its share: + // 2^26 rounds is 128 keys of 2^19. + let mut keys = 1; + for salt in 0u32..1000 { + if kdf + .sevenz_key(b"pw", &salt.to_le_bytes(), 19, 1 << 19, || Some([2; 32])) + .is_some() + { + keys += 1; + } + } + assert_eq!(keys, 128); + } + + #[test] + fn a_tiny_count_is_charged_something() { + let spent = AtomicU64::new(0); + let mut n = 0; + while charge(&spent, 1, 10_000) { + n += 1; + } + assert_eq!(n, 10_000 / MIN_CHARGE); + } +} diff --git a/crates/exav-unpack/src/lib.rs b/crates/exav-unpack/src/lib.rs index 5e582887..5f59734d 100644 --- a/crates/exav-unpack/src/lib.rs +++ b/crates/exav-unpack/src/lib.rs @@ -53,7 +53,9 @@ pub(crate) mod formats; #[allow(unused_imports)] use formats::*; -mod bytes; +/// The bounds-checked little-endian cursor of the record readers. +pub mod bytes; +// What a build uses of it depends on the formats with a password it has. #[cfg(any( feature = "gzip", feature = "zip", @@ -68,14 +70,42 @@ mod bytes; feature = "xar", feature = "diskimage", feature = "upx", - feature = "dmg" + feature = "dmg", + feature = "squashfs", + feature = "cramfs" ))] mod inflate; +// Each item is read by a different mix of the `decrypt`, `sevenz`, `dmg` and +// `rar` features, so any build leaves some of them unused. +/// HTML as tokens, and the bytes of a page as text: the one HTML reader of +/// the scanner's text view and the converters. Feature `html`. +#[cfg(feature = "html")] +pub mod html; +#[allow(dead_code)] +mod kdf; +#[cfg(all(test, any(feature = "dmg", feature = "ishieldz")))] +mod oracle; pub mod profile; pub mod source; pub mod span; mod stream; +/// The text a reader sees in HTML, Office and OpenDocument XML parts, and +/// PDF. Feature `text`. +#[cfg(feature = "text")] +pub mod text; pub mod volume; +/// What BIFF (Excel up to 2003) shares: the built-in function table that +/// formula tokens name by number. Feature `ole`. +#[cfg(feature = "ole")] +pub use formats::biff; +/// OLE2 compound files: the one reader of Word, Excel and PowerPoint 97 to +/// 2003 files and the like, lenient and bounded. Feature `ole`. +#[cfg(feature = "ole")] +pub use formats::cfb; +/// Text in a Windows code page number, as BIFF records and Office fonts name +/// them. Feature `ole`. +#[cfg(feature = "ole")] +pub use formats::charset; /// AutoCAD DWG's bit codes, sections and object records, for drawing /// readers. Feature `dwg`. #[cfg(feature = "dwg")] @@ -86,9 +116,17 @@ pub use formats::dwg; pub use formats::dxf; #[cfg(feature = "pdf")] pub use formats::has_obfuscated_name_object; +/// Excel workbooks saved with a password (RC4, CryptoAPI, XOR), opened with +/// the default password or those given. Feature `offcrypto`. +#[cfg(feature = "offcrypto")] +pub use formats::offcrypto; +/// RTF's tokenizer, which the scanner's extractor of embedded objects and +/// exav-render's converter both read with. Feature `rtf`. +#[cfg(feature = "rtf")] +pub use formats::rtf; #[allow(unused_imports)] pub(crate) use stream::Region; -pub use stream::{is_budget_overflow, walk, Member, MemberMeta, Mtime, Visit}; +pub use stream::{Member, MemberMeta, Mtime, Visit, is_budget_overflow, walk}; /// Count of overlapping ZIP local file records, the signal behind ClamAV's /// `Heuristics.Zip.OverlappingFiles`. Zero for any well-formed archive. @@ -105,6 +143,17 @@ pub fn overlapping_local_records(_data: &dyn ByteSource) -> usize { #[cfg(feature = "zip")] pub use formats::zip::directory_is_consistent as zip_directory_is_consistent; +/// Where a ZIP appended to another file begins, by its end record at the end of +/// the file. +#[cfg(feature = "zip")] +pub use formats::zip::appended_start as appended_zip_start; + +/// Without the `zip` feature no ZIP is found. +#[cfg(not(feature = "zip"))] +pub fn appended_zip_start(_src: &dyn ByteSource) -> Option { + None +} + /// Without the `zip` feature no ZIP is ever confirmed. #[cfg(not(feature = "zip"))] pub fn zip_directory_is_consistent(_data: &[u8]) -> bool { @@ -116,7 +165,7 @@ pub fn zip_directory_is_consistent(_data: &[u8]) -> bool { /// `Heuristics.XZ.DicSizeLimit`: memory a decoder must commit before producing /// a single byte. #[cfg(feature = "xz")] -pub use formats::xz::{declared_dict_size as xz_declared_dict_size, XZ_MAX_DICT}; +pub use formats::xz::{XZ_MAX_DICT, declared_dict_size as xz_declared_dict_size}; #[cfg(not(feature = "xz"))] pub fn xz_declared_dict_size(_data: &[u8]) -> Option { @@ -151,6 +200,11 @@ pub fn has_obfuscated_name_object(_data: &[u8]) -> bool { #[cfg(feature = "rar")] #[doc(hidden)] pub use formats::{unpack29, unpack50, window_size_from_comp_info}; +// The LZO decoder of SquashFS, exposed for the fuzz target that drives it. +// Diagnostic surface, not part of the stable API: may change in any release. +#[cfg(feature = "squashfs")] +#[doc(hidden)] +pub use formats::lzo::decompress as lzo_decompress; /// Limits governing recursive extraction. /// @@ -217,6 +271,11 @@ pub struct Limits { /// large file. Deterministic, so it trips identically on every machine /// (unlike a wall-clock deadline). pub max_scanned_bytes: u64, + /// What one run of a bytecode signature may hold in all: its `malloc`s + /// (a program frees nothing), the frames of its calls in progress, and the + /// globals it has read. A run that reaches it stops as incomplete. Default + /// 256 MiB. + pub max_bytecode_memory_bytes: u64, /// Cap on the instructions the PE stub emulator may run across the whole /// recursive analysis of one top-level file. /// @@ -265,6 +324,7 @@ impl Default for Limits { // and runaway scan time, and lets a multi-gigabyte member be fully // scanned. max_scanned_bytes: 10 * 1024 * 1024 * 1024, + max_bytecode_memory_bytes: 256 * 1024 * 1024, // A few seconds to tens of seconds of emulation per top-level file, // against the per-stub budget of `formats::pepack`. max_pe_emulation_steps: 1_000_000_000, @@ -293,6 +353,18 @@ pub struct Budget { pub(crate) scanned: u64, /// Emulator instructions spent so far (see [`Limits::max_pe_emulation_steps`]). pub(crate) pe_emulation_steps: u64, + /// PBKDF2 iterations spent so far on RAR5 and disk image passwords, across + /// every archive of the scan. Atomic because the readers hold the budget by `&`. + #[cfg_attr(not(feature = "decrypt"), allow(dead_code))] + pub(crate) kdf_rounds: std::sync::atomic::AtomicU64, + /// What the scan has spent on 7z key derivations, and the keys it made + /// ([`kdf`]). A handle, so that a reader can hold it while the budget is + /// borrowed for something else. + #[cfg_attr(not(feature = "decrypt"), allow(dead_code))] + pub(crate) kdf: std::sync::Arc, + /// RAR3 key derivations made so far, across every archive of the scan. + #[cfg_attr(not(feature = "decrypt"), allow(dead_code))] + pub(crate) rar3_derivations: std::sync::atomic::AtomicUsize, /// Candidate passwords tried, in order, when decrypting an encrypted ZIP, /// 7z, RAR, ARJ, PDF, DMG or Office member, before the built-in ones a /// format has (a PDF's empty user password goes first). Empty by default: @@ -328,6 +400,9 @@ impl Budget { total_out: 0, scanned: 0, pe_emulation_steps: 0, + kdf_rounds: std::sync::atomic::AtomicU64::new(0), + kdf: std::sync::Arc::default(), + rar3_derivations: std::sync::atomic::AtomicUsize::new(0), passwords: Vec::new(), verify_checksums: false, visit_directories: false, @@ -702,6 +777,10 @@ pub enum Format { Zstd, /// Lzip compressed stream. Lzip, + /// LZMA-alone (`.lzma`) compressed stream, as LZMA Utils wrote it. + Lzma, + /// RPM package: its install scripts and its payload (a compressed cpio). + Rpm, /// uuencode / Base64-uuencode (`begin`…`end`) wrapped file. Uuencode, /// Adobe XDP (XML-wrapped, base64-encoded PDF). @@ -748,6 +827,16 @@ pub enum Format { /// the objects its OLE2FRAMEs embed. A drawing of an older release is /// one unsupported entry. See [`dwg`] (feature `dwg`). Dwg, + /// exFAT filesystem, walked, so fragmented files come back whole. + Exfat, + /// SquashFS 4 image: walked, each block decompressed as it is read. + Squashfs, + /// cramfs image: walked, each block inflated as it is read. + Cramfs, + /// romfs image: walked, its files being stored as they are. + Romfs, + /// EROFS image: walked, files stored plain or compressed with LZ4. + Erofs, } impl Format { @@ -800,6 +889,8 @@ impl Format { Format::Ntfs, Format::Zstd, Format::Lzip, + Format::Lzma, + Format::Rpm, Format::Uuencode, Format::Xdp, Format::Szdd, @@ -821,6 +912,11 @@ impl Format { Format::Screnc, Format::Dxf, Format::Dwg, + Format::Exfat, + Format::Squashfs, + Format::Cramfs, + Format::Romfs, + Format::Erofs, ]; } @@ -904,6 +1000,13 @@ mod format_all_tests { Format::Screnc => 44, Format::Dxf => 66, Format::Dwg => 67, + Format::Lzma => 68, + Format::Rpm => 69, + Format::Exfat => 70, + Format::Squashfs => 71, + Format::Cramfs => 72, + Format::Romfs => 73, + Format::Erofs => 74, } } let mut seen: Vec = Format::ALL.iter().map(|f| tag(*f)).collect(); @@ -911,7 +1014,7 @@ mod format_all_tests { seen.dedup(); assert_eq!( seen.len(), - 67, + 74, "Format::ALL is missing a variant (or lists one twice)" ); } @@ -933,14 +1036,65 @@ fn is_bzip2_magic(data: &[u8]) -> bool { || data[4..10] == [0x17, 0x72, 0x45, 0x38, 0x50, 0x90]) } +/// A second format the object also is, when its first bytes were claimed by +/// `first`, for the formats that leave their own start free: an ISO 9660 image +/// keeps its first 32 KiB for the system area, and an ext volume its first +/// kilobyte for boot code, so either can start with an archive's magic and be +/// mounted all the same. +pub fn also_reads_as(src: &dyn ByteSource, first: Format) -> Option { + #[cfg(feature = "iso")] + if first != Format::Iso && src.window(32769, 5).starts_with(b"CD001") { + return Some(Format::Iso); + } + #[cfg(feature = "ext")] + if first != Format::Ext && formats::ext::is_volume(src) { + return Some(Format::Ext); + } + let _ = (src, first); + None +} + +/// Whether `data` is a PDF: `%PDF-` and a version digit within its first +/// kilobyte, which is where Acrobat, qpdf, pdfium and pdf.js look. A file with a +/// newline, a byte-order mark or a mail's leftover headers before it is a PDF +/// to every one of them, and offsets in the file count from the header. +fn pdf_header_near_start(data: &[u8]) -> bool { + // At the start the magic alone has always been enough. + if data.starts_with(b"%PDF-") { + return true; + } + let head = &data[..data.len().min(1024)]; + memchr::memmem::find_iter(head, b"%PDF-") + .any(|at| head.get(at + 5).is_some_and(u8::is_ascii_digit)) +} + /// Whether `data` begins with a real CAB (Microsoft Cabinet) header: `MSCF` /// followed by the `reserved1` field, which the MS-CAB spec fixes at zero in /// every genuine cabinet. The bare 4-byte `MSCF` magic collides with binary data /// (e.g. inside an ISO), and a false hit routed to the cabinet parser fails deep /// in string parsing and reports the object `LIMITS-EXCEEDED`; requiring the zero /// `reserved1` rejects those without dropping any real cabinet. +/// +/// Readers do not check `reserved1` (`cabextract` opens a cabinet whose field is +/// anything), so a cabinet is also recognised by what follows it: a size, the +/// offset of the first file entry inside it, and version 1.3, none of which a +/// chance `MSCF` has. fn is_cab_magic(data: &[u8]) -> bool { - data.len() >= 8 && data.starts_with(b"MSCF") && data[4..8] == [0, 0, 0, 0] + if !data.starts_with(b"MSCF") || data.len() < 8 { + return false; + } + if data[4..8] == [0, 0, 0, 0] { + return true; + } + let u32_at = |o: usize| { + data.get(o..o + 4) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + }; + let (Some(size), Some(files)) = (u32_at(8), u32_at(16)) else { + return false; + }; + // `versionMinor`, `versionMajor`. + data.get(24..26) == Some(&[3, 1]) && size >= 36 && (36..=size).contains(&files) } /// Whether `data` begins with an ARJ main header: `60 EA`, a size of at most @@ -986,6 +1140,30 @@ fn starts_executable_magic(d: &[u8]) -> bool { || d.starts_with(&[0xd0, 0xcf, 0x11, 0xe0]) } +/// Whether `d` starts with the magic of an archive a script or page may carry +/// in base64 (HTML smuggling puts a ZIP in a `data:` URI or an `atob` string): +/// the magics long enough not to turn up by chance, and ZIP's and gzip's, which +/// [`is_archive_payload`] confirms from what follows. +#[cfg(feature = "base64scan")] +fn starts_archive_magic(d: &[u8]) -> bool { + d.starts_with(b"PK\x03\x04") + || is_gzip_magic(d) + || d.starts_with(b"7z\xBC\xAF\x27\x1C") + || d.starts_with(b"Rar!\x1a\x07") + || d.starts_with(&[0xFD, b'7', b'z', b'X', b'Z', 0x00]) +} + +/// Whether a fully-decoded blob is an archive worth scanning as one: a magic of +/// [`starts_archive_magic`], and for a ZIP a directory that holds together, so +/// four bytes of coincidence are not enough. +#[cfg(feature = "base64scan")] +fn is_archive_payload(d: &[u8]) -> bool { + if d.len() < 22 || !starts_archive_magic(d) { + return false; + } + !d.starts_with(b"PK\x03\x04") || zip_directory_is_consistent(d) +} + /// Whether a fully-decoded blob is a real, re-scannable executable: one of /// [`starts_executable_magic`] and, for a PE, with a *valid* PE header at /// `e_lfanew` (rejecting a bare `MZ`). See [`base64_payloads`]. @@ -1019,6 +1197,13 @@ fn is_executable_payload(d: &[u8]) -> bool { /// nothing more. Bounded by `cap` per payload and by a cap on how many return. #[cfg(feature = "base64scan")] pub fn markup_embedded_payloads(src: &dyn ByteSource, cap: u64) -> Vec> { + markup_embedded_payloads_capped(src, cap).0 +} + +/// [`markup_embedded_payloads`], and whether it stopped at the number it +/// returns with another run behind them that it would have decoded. +#[cfg(feature = "base64scan")] +pub fn markup_embedded_payloads_capped(src: &dyn ByteSource, cap: u64) -> (Vec>, bool) { match src.as_slice() { Some(data) => markup_payloads_on(&mut Indexed(data), cap), None => markup_payloads_on(&mut Stepper::new(src), cap), @@ -1026,7 +1211,7 @@ pub fn markup_embedded_payloads(src: &dyn ByteSource, cap: u64) -> Vec> } #[cfg(feature = "base64scan")] -fn markup_payloads_on(data: &mut B, cap: u64) -> Vec> { +fn markup_payloads_on(data: &mut B, cap: u64) -> (Vec>, bool) { use base64::Engine; /// Shortest base64 run worth decoding; below this it cannot be an asset. const MIN_RUN: usize = 64; @@ -1036,9 +1221,10 @@ fn markup_payloads_on(data: &mut B, cap: u64) -> Vec> { let plain = base64::engine::general_purpose::STANDARD_NO_PAD; let is_b64 = |b: u8| b.is_ascii_alphanumeric() || b == b'+' || b == b'/'; let mut out = Vec::new(); + let mut capped = false; let mut i = 0usize; let n = data.len(); - while i < n && out.len() < MAX_PAYLOADS { + while i < n { if !is_b64(data.at(i)) { i += 1; continue; @@ -1059,9 +1245,15 @@ fn markup_payloads_on(data: &mut B, cap: u64) -> Vec> { // Cheap gate first: 8 base64 chars decode to 6 bytes, enough for every // magic we care about, and costs no allocation on a miss. match plain.decode(data.range(start, start + 8)) { - Ok(head) if starts_asset_magic(&head) => {} + Ok(head) if starts_asset_magic(&head) || starts_archive_magic(&head) => {} _ => continue, } + // Another run worth decoding, with the number returned already at its + // limit: the rest of the document is not read. + if out.len() >= MAX_PAYLOADS { + capped = true; + break; + } // Absorb the padding the run scan stopped at, so a well-formed URI // decodes whole. Without it the tail falls off the last 4-char group and // the payload comes back one or two bytes short: invisible on an image, @@ -1081,13 +1273,15 @@ fn markup_payloads_on(data: &mut B, cap: u64) -> Vec> { let decoded = engine .decode(&run) .or_else(|_| plain.decode(&run[..bare - usize::from(bare % 4 == 1)])); - if let Ok(dec) = decoded { - if !dec.is_empty() && dec.len() as u64 <= cap { - out.push(dec); - } + if let Ok(dec) = decoded + && !dec.is_empty() + && dec.len() as u64 <= cap + && (!starts_archive_magic(&dec) || is_archive_payload(&dec)) + { + out.push(dec); } } - out + (out, capped) } /// Magics of things worth pulling out of a document: raster images (what @@ -1118,6 +1312,13 @@ fn starts_asset_magic(d: &[u8]) -> bool { /// `cap` (per-payload size), `MAX_PAYLOADS`, and `MAX_ATTEMPTS` (decode tries). #[cfg(feature = "base64scan")] pub fn base64_payloads(src: &dyn ByteSource, cap: u64) -> Vec> { + base64_payloads_capped(src, cap).0 +} + +/// [`base64_payloads`], and whether it stopped at its limits with another run +/// behind them that it would have decoded. +#[cfg(feature = "base64scan")] +pub fn base64_payloads_capped(src: &dyn ByteSource, cap: u64) -> (Vec>, bool) { match src.as_slice() { Some(data) => base64_payloads_on(&mut Indexed(data), cap), None => base64_payloads_on(&mut Stepper::new(src), cap), @@ -1125,7 +1326,7 @@ pub fn base64_payloads(src: &dyn ByteSource, cap: u64) -> Vec> { } #[cfg(feature = "base64scan")] -fn base64_payloads_on(data: &mut B, cap: u64) -> Vec> { +fn base64_payloads_on(data: &mut B, cap: u64) -> (Vec>, bool) { use base64::Engine; // A base64-encoded PE is at minimum a few hundred bytes; require a run that // decodes to ≥ ~1 KB so we never trial-decode short incidental runs. @@ -1137,10 +1338,11 @@ fn base64_payloads_on(data: &mut B, cap: u64) -> Vec> { let engine = base64::engine::general_purpose::STANDARD_NO_PAD; let mut out = Vec::new(); + let mut capped = false; let mut attempts = 0; let n = data.len(); let mut i = 0; - while i < n && out.len() < MAX_PAYLOADS && attempts < MAX_ATTEMPTS { + while i < n { if !is_b64(data.at(i)) { i += 1; continue; @@ -1175,9 +1377,15 @@ fn base64_payloads_on(data: &mut B, cap: u64) -> Vec> { // NOT executables (RTF `\objdata` hex, benign base64 text) cost nothing // beyond the byte count above: no full decode, no payload allocation. match engine.decode(head) { - Ok(prefix) if starts_executable_magic(&prefix) => {} + Ok(prefix) if starts_executable_magic(&prefix) || starts_archive_magic(&prefix) => {} _ => continue, } + // Another run worth decoding, with the limits already reached: the + // rest of the text is not read. + if out.len() >= MAX_PAYLOADS || attempts >= MAX_ATTEMPTS { + capped = true; + break; + } attempts += 1; // Whole 4-char groups decode to exactly 3 bytes each, so a run too // large to keep is known before it is read. @@ -1203,13 +1411,14 @@ fn base64_payloads_on(data: &mut B, cap: u64) -> Vec> { if run.len() % 4 == 1 { run.pop(); } - if let Ok(dec) = engine.decode(&run) { - if dec.len() as u64 <= cap && is_executable_payload(&dec) { - out.push(dec); - } + if let Ok(dec) = engine.decode(&run) + && dec.len() as u64 <= cap + && (is_executable_payload(&dec) || is_archive_payload(&dec)) + { + out.push(dec); } } - out + (out, capped) } /// What [`detect`] reads of an object: its start (all of it when it is held in @@ -1477,7 +1686,7 @@ fn detect_with(src: &dyn ByteSource, prescan: Option<&dyn Fn() -> Prescan>) -> O // read `DETECT_HEAD` deep: over a network or browser reader that is most // of a small archive. Only those `detect_probe` makes first: a later one, // such as RAR's, could be overruled by an earlier check that needs more. - if let Some(fmt) = archive_magic(&src.window(0, 16)) { + if let Some(fmt) = archive_magic(&src.window(0, 32)) { return Some(udif_or(fmt, src.len(), |off| { src.window(off, 12).into_owned() })); @@ -1500,7 +1709,7 @@ fn detect_with(src: &dyn ByteSource, prescan: Option<&dyn Fn() -> Prescan>) -> O /// RAR), as [`detect`] would type it: what an archive carved out of another /// object at its magic needs confirmed, without a search through the rest. pub fn detect_archive_start(src: &dyn ByteSource) -> Option { - let head = src.window(0, 16); + let head = src.window(0, 32); archive_magic(&head).or_else(|| is_rar_magic(&head).then_some(Format::Rar)) } @@ -1545,6 +1754,51 @@ fn udif_or(fmt: Format, len: usize, read: impl FnOnce(usize) -> Vec) -> Form fmt } +/// The first header of a tar from before `ustar` (V7), which has no magic: +/// a name, octal mode and size, and a checksum of the header that is right. +fn is_v7_tar(data: &[u8]) -> bool { + let Some(h) = data.get(..512) else { + return false; + }; + let octal = |f: &[u8]| { + let t = f + .iter() + .take_while(|&&b| b != 0) + .copied() + .collect::>(); + let t = t.trim_ascii(); + (!t.is_empty() && t.iter().all(|b| (b'0'..=b'7').contains(b))).then(|| { + t.iter().fold(0u64, |a, b| { + a.wrapping_mul(8).wrapping_add(u64::from(b - b'0')) + }) + }) + }; + if h[0] == 0 || octal(&h[100..108]).is_none() || octal(&h[124..136]).is_none() { + return false; + } + let Some(sum) = octal(&h[148..156]) else { + return false; + }; + let field = 148..156; + let unsigned: u64 = h + .iter() + .enumerate() + .map(|(i, &b)| if field.contains(&i) { 32 } else { u64::from(b) }) + .sum(); + let signed: i64 = h + .iter() + .enumerate() + .map(|(i, &b)| { + if field.contains(&i) { + 32 + } else { + i64::from(b as i8) + } + }) + .sum(); + sum == unsigned || i64::try_from(sum).is_ok_and(|s| s == signed) +} + fn is_rar_magic(data: &[u8]) -> bool { data.starts_with(b"Rar!\x1a\x07\x00") || data.starts_with(b"Rar!\x1a\x07\x01\x00") } @@ -1570,6 +1824,9 @@ fn detect_probe(p: &Probe) -> Option { if data.len() >= 263 && &data[257..262] == b"ustar" { return Some(Format::Tar); } + if is_v7_tar(data) { + return Some(Format::Tar); + } if data.len() >= 7 && data[2] == b'-' && data[6] == b'-' && matches!(data[3], b'l' | b'p') { return Some(Format::Lha); } @@ -1637,6 +1894,18 @@ fn detect_probe(p: &Probe) -> Option { if formats::sniff::is_in(p, Format::AppleSingle) { return Some(Format::AppleSingle); } + if formats::sniff::is_in(p, Format::Squashfs) { + return Some(Format::Squashfs); + } + if formats::sniff::is_in(p, Format::Cramfs) { + return Some(Format::Cramfs); + } + if formats::sniff::is_in(p, Format::Romfs) { + return Some(Format::Romfs); + } + if formats::sniff::is_in(p, Format::Erofs) { + return Some(Format::Erofs); + } // ext last of the filesystem sniffs: its magic is 1080 bytes in, so a // partition table or boot sector at offset 0 is the more specific answer. if formats::sniff::is_in(p, Format::Ext) { @@ -1648,6 +1917,10 @@ fn detect_probe(p: &Probe) -> Option { if formats::fat::is_fat(data) { return Some(Format::Fat); } + #[cfg(feature = "exfat")] + if formats::exfat::is_exfat(data) { + return Some(Format::Exfat); + } if formats::sniff::is_in(p, Format::Ntfs) { return Some(Format::Ntfs); } @@ -1674,7 +1947,7 @@ fn detect_probe(p: &Probe) -> Option { if formats::sniff::is_in(p, Format::Vhdx) { return Some(Format::Vhdx); } - if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) { + if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) || formats::sniff::zstd_after_skippable(data) { return Some(Format::Zstd); } if data.starts_with(b"!\n") { @@ -1683,6 +1956,12 @@ fn detect_probe(p: &Probe) -> Option { if data.starts_with(b"LZIP") { return Some(Format::Lzip); } + if formats::sniff::is_in(p, Format::Lzma) { + return Some(Format::Lzma); + } + if formats::sniff::is_in(p, Format::Rpm) { + return Some(Format::Rpm); + } if data.starts_with(b"070701") || data.starts_with(b"070702") || data.starts_with(b"070707") @@ -1821,6 +2100,17 @@ fn detect_probe(p: &Probe) -> Option { if is_partition(p) { return Some(Format::Partition); } + // After every structural check: a tar or a disk image holds a PDF in its + // first kilobyte as a member, which is not the file's own header. + if pdf_header_near_start(data) { + return Some(Format::Pdf); + } + // Last: a ZIP behind a script line or padding, found by its end record. + // The walker reads such an archive (`open_archive`'s prepended case). + #[cfg(feature = "zip")] + if formats::sniff::prefixed_zip(p) { + return Some(Format::Zip); + } None } @@ -1845,10 +2135,10 @@ mod detect_source_tests { let path = e.path(); if path.is_dir() { fixtures(&path, out); - } else if let Ok(data) = std::fs::read(&path) { - if data.len() < 8 << 20 { - out.push((path.display().to_string(), data)); - } + } else if let Ok(data) = std::fs::read(&path) + && data.len() < 8 << 20 + { + out.push((path.display().to_string(), data)); } } } @@ -2131,18 +2421,6 @@ pub(crate) fn dispatch_extract( Format::Rar => emit_collected(extract_rar(data, budget)?, budget, visit), #[cfg(feature = "upx")] Format::Upx => extract_upx(data, budget, visit), - #[cfg(feature = "xar")] - Format::Xar => extract_xar(data, budget, visit), - #[cfg(feature = "vhd")] - Format::Vhd => formats::vhd::extract_vhd(data, budget, visit), - #[cfg(feature = "diskimage")] - Format::Qcow2 => formats::qcow2::extract_qcow2(data, budget, visit), - #[cfg(feature = "diskimage")] - Format::Vmdk => formats::vmdk::extract_vmdk(data, budget, visit), - #[cfg(feature = "diskimage")] - Format::Vhdx => formats::vhdx::extract_vhdx(data, budget, visit), - #[cfg(feature = "wim")] - Format::Wim => formats::wim::extract_wim(data, budget, visit), #[cfg(feature = "arc")] Format::Arc => formats::arc::extract_arc(data, budget, visit), #[cfg(feature = "ace")] @@ -2159,20 +2437,14 @@ pub(crate) fn dispatch_extract( | Format::CryptFf | Format::Lrzip | Format::AppleSingle => formats::reported::extract_reported(fmt, data, budget, visit), - #[cfg(feature = "ext")] - Format::Ext => formats::ext::extract_ext(data, budget, visit), #[cfg(feature = "zoo")] Format::Zoo => formats::zoo::extract_zoo(data, budget, visit), #[cfg(feature = "ishieldz")] Format::IshieldZ => formats::ishield_z::extract_ishield_z(data, budget, visit), #[cfg(feature = "stuffit")] Format::StuffIt => formats::stuffit::extract_stuffit(data, budget, visit), - #[cfg(feature = "fat")] - Format::Fat => formats::fat::extract_fat(data, budget, visit), #[cfg(feature = "inno")] Format::Inno => formats::inno::extract_inno(data, budget, visit), - #[cfg(feature = "ntfs")] - Format::Ntfs => formats::ntfs::extract_ntfs(data, budget, visit), #[cfg(feature = "uuencode")] Format::Uuencode => extract_uuencode(data, budget, visit), #[cfg(feature = "xdp")] @@ -2322,6 +2594,28 @@ pub fn join_rar_volumes(volumes: &[&[u8]]) -> Result, String> { formats::join_rar_volumes(volumes) } +/// What a cabinet's header says of its place in a set of them: the cabinet +/// before it and the one after it, by the names the header gives (labels, for +/// a caller to match against the names it was given: nothing here opens +/// one), its number in the set and the set's. `None` for a cabinet that is not +/// one of several. +#[cfg(feature = "cab")] +pub fn cab_links(cabinet: &[u8]) -> Option { + formats::cab_join::links(cabinet) +} + +/// The cabinets of one set, in order, as the one cabinet they were cut from: +/// what [`walk`] reads as [`Format::Cab`]. A folder or a file that runs from +/// one cabinet into the next is read whole that way, where one cabinet at a +/// time it is cut short. `Err` says what does not fit the set. +#[cfg(feature = "cab")] +pub fn join_cab_volumes(cabinets: &[&[u8]]) -> Result, String> { + formats::cab_join::join(cabinets) +} + +#[cfg(feature = "cab")] +pub use formats::cab_join::Links as CabLinks; + /// True if `data` looks like a UPX-packed executable (a valid `PackHeader` is /// present). Used by callers whose file-type classifier already recognises /// PE/ELF/Mach-O and wants to additionally unpack UPX. @@ -2823,8 +3117,8 @@ mod markup_payload_tests { #[cfg(all(test, feature = "all-formats"))] mod tests { use super::*; - use flate2::write::GzEncoder; use flate2::Compression; + use flate2::write::GzEncoder; use std::io::{Cursor, Write}; fn gz(data: &[u8]) -> Vec { @@ -2939,6 +3233,29 @@ mod tests { buf } + #[test] + fn a_tar_from_before_ustar_has_no_magic_and_is_found_by_its_checksum() { + let mut buf = Vec::new(); + { + let mut b = ::tar::Builder::new(&mut buf); + let mut h = ::tar::Header::new_old(); + h.set_size(5); + h.set_mode(0o644); + h.set_cksum(); + b.append_data(&mut h, "input.txt", &b"hello"[..]).unwrap(); + b.finish().unwrap(); + } + assert_eq!(&buf[257..262], &[0u8; 5], "no magic"); + assert_eq!(detect(&buf), Some(Format::Tar)); + let entries = extract(Format::Tar, &buf, &mut Budget::new(Limits::default())).unwrap(); + assert_eq!(entries[0].data, b"hello"); + // One byte of the header changed: the checksum no longer fits. + buf[1] ^= 1; + assert_eq!(detect(&buf), None); + // Text that happens to be octal is not one. + assert_eq!(detect(&[b'7'; 2048]), None); + } + fn cab_of(members: &[(&str, &[u8])]) -> Vec { let mut b = ::cab::CabinetBuilder::new(); { @@ -3114,6 +3431,31 @@ mod tests { assert_eq!(detect(b"MSCF"), None); } + /// `reserved1` is reserved: `cabextract` opens a cabinet whatever it holds, + /// so a cabinet with a nonzero field is a cabinet, not data. + #[cfg(feature = "cab")] + #[test] + fn a_cabinet_with_a_nonzero_reserved_field_is_still_a_cabinet() { + let real = include_bytes!("../tests/fixtures/cab/mszip_lzx_qtm.cab"); + let mut odd = real.to_vec(); + odd[4..8].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]); + assert_eq!(detect(&odd), Some(Format::Cab)); + let names = |blob: &[u8]| { + let mut budget = Budget::new(Limits::default()); + extract(Format::Cab, &blob, &mut budget) + .unwrap() + .into_iter() + .map(|e| (e.name, e.data)) + .collect::>() + }; + assert!(!names(real).is_empty()); + assert_eq!(names(&odd), names(real)); + // And a header that only starts like one is still not. + let mut chance = odd.clone(); + chance[24] = 9; + assert_eq!(detect(&chance), None); + } + /// `60 EA` is two bytes, so about one object in 65,536 starts with it by /// chance, and reading such an object as a damaged ARJ reports it /// `UNSCANNABLE`. The main header carries a CRC-32 that settles it. @@ -3207,8 +3549,8 @@ mod tests { detect(&[0x1f, 0x8b, 0x08, 0x08, 0, 0, 0, 0]), Some(Format::Gzip) ); // FNAME set - // Coincidental `1f 8b` in binary data with a non-deflate CM or reserved - // flag bits set → not gzip (would otherwise fail inflate → UNSCANNABLE). + // Coincidental `1f 8b` in binary data with a non-deflate CM or reserved + // flag bits set → not gzip (would otherwise fail inflate → UNSCANNABLE). assert_eq!(detect(&[0x1f, 0x8b, 0x99, 0xff]), None); // CM != 8 assert_eq!(detect(&[0x1f, 0x8b, 0x08, 0xe0]), None); // reserved flag bits set assert_eq!(detect(&[0x1f, 0x8b]), None); // too short @@ -3276,6 +3618,23 @@ mod tests { assert_eq!(entries[0].data, payload); } + /// ClamAV's `skip_prefix.txt.zst`: a skippable frame (which LZ4 uses as + /// well) ahead of the Zstandard one was taken for an LZ4 file. + #[test] + fn zstd_after_a_skippable_frame_is_zstd() { + let payload = b"hello exav inside zstd"; + let mut blob = Vec::new(); + blob.extend_from_slice(&0x184D_2A50u32.to_le_bytes()); + blob.extend_from_slice(&5u32.to_le_bytes()); + blob.extend_from_slice(b"PADDY"); + blob.extend(zstd_of(payload)); + assert_eq!(detect(&blob), Some(Format::Zstd)); + let mut budget = Budget::new(Limits::default()); + let entries = extract(Format::Zstd, &blob, &mut budget).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].data, payload); + } + #[test] fn zstd_bomb_trips_ratio() { let mut w = @@ -3330,7 +3689,7 @@ mod tests { // Build an OLE2/CFB with a stream holding EICAR, then extract it. let mut buf = Cursor::new(Vec::new()); { - let mut comp = cfb::CompoundFile::create(&mut buf).unwrap(); + let mut comp = ::cfb::CompoundFile::create(&mut buf).unwrap(); comp.create_storage("Macros").unwrap(); let mut s = comp.create_stream("Macros/Module1").unwrap(); std::io::Write::write_all(&mut s, eicar()).unwrap(); @@ -3443,7 +3802,7 @@ mod tests { img[pvd] = 1; // type = primary img[pvd + 1..pvd + 6].copy_from_slice(b"CD001"); img[pvd + 6] = 1; // version - // Root directory record at PVD+156 (length 34). + // Root directory record at PVD+156 (length 34). let rd = pvd + 156; img[rd] = 34; // record length img[rd + 2..rd + 6].copy_from_slice(&17u32.to_le_bytes()); // extent LBA (LE) @@ -3451,7 +3810,7 @@ mod tests { img[rd + 25] = 0x02; // flags: directory img[rd + 32] = 1; // name len img[rd + 33] = 0; // name "\0" (self) - // --- Root directory at sector 17 --- + // --- Root directory at sector 17 --- let dir = 17 * S; let mut p = dir; // "." record @@ -3534,6 +3893,82 @@ mod tests { assert_eq!(entries.len(), 1); assert_eq!(entries[0].data, payload); } + + /// A ZIP behind a script line (a zipapp), with a marker of a spanned archive + /// in front, or with padding before and after is told by its end record, and + /// opens as it does without them; text that merely ends like a ZIP is not. + #[cfg(feature = "zip")] + #[test] + fn a_zip_behind_other_bytes_is_a_zip() { + let zip = include_bytes!("../tests/fixtures/zip/legacy_implode.zip").to_vec(); + let plain = extract( + Format::Zip, + &zip.as_slice(), + &mut Budget::new(Limits::default()), + ) + .unwrap(); + assert!(!plain.is_empty()); + let names = |blob: &[u8]| -> Vec<(String, Vec)> { + extract(Format::Zip, &blob, &mut Budget::new(Limits::default())) + .unwrap() + .into_iter() + .map(|e| (e.name, e.data)) + .collect() + }; + let want = names(&zip); + for (what, blob) in [ + ( + "a zipapp", + [&b"#!/usr/bin/env python3\n"[..], &zip].concat(), + ), + ( + "padding around", + [&[7u8; 300][..], &zip, &[9u8; 100]].concat(), + ), + ] { + assert_eq!(detect(&blob.as_slice()), Some(Format::Zip), "{what}"); + assert_eq!(names(&blob), want, "{what}"); + } + // A directory that points at no local header, and an end record cut off. + let mut broken = [&b"#!/bin/sh\n"[..], &zip].concat(); + let at = broken.len() - zip.len(); + broken[at..at + 4].copy_from_slice(b"XXXX"); + assert_eq!(detect(&broken.as_slice()), None); + let cut = [&b"#!/bin/sh\n"[..], &zip[..zip.len() - 30]].concat(); + assert_eq!(detect(&cut.as_slice()), None); + } + + /// `xz --format=lzma`: a stream that ends with a marker, and the same with + /// its size in the header (what LZMA Utils wrote). + #[cfg(feature = "lzma")] + #[test] + fn lzma_alone_roundtrip() { + let payload = b"hello exav inside lzma"; + for blob in [ + &include_bytes!("../tests/fixtures/lzma_simple.lzma")[..], + &include_bytes!("../tests/fixtures/lzma_sized.lzma")[..], + ] { + assert_eq!(detect(&blob), Some(Format::Lzma)); + let mut budget = Budget::new(Limits::default()); + let entries = extract(Format::Lzma, &blob, &mut budget).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "lzma-content"); + assert_eq!(entries[0].data, payload); + } + } + + /// The header chooses the dictionary the decoder allocates: one of 2 GiB is + /// refused, not allocated. + #[cfg(feature = "lzma")] + #[test] + fn lzma_alone_with_a_huge_dictionary_is_refused() { + let mut blob = include_bytes!("../tests/fixtures/lzma_simple.lzma").to_vec(); + blob[1..5].copy_from_slice(&(1u32 << 31).to_le_bytes()); + assert_eq!(detect(&blob.as_slice()), Some(Format::Lzma)); + let mut budget = Budget::new(Limits::default()); + let entries = extract(Format::Lzma, &blob.as_slice(), &mut budget).unwrap(); + assert!(entries[0].data.is_empty() && entries[0].unsupported.is_some()); + } } /// An LZMA dictionary size clamped to what the caller's budget allows. diff --git a/crates/exav-unpack/src/main.rs b/crates/exav-unpack/src/main.rs index 69fd6d4a..fe453ca8 100644 --- a/crates/exav-unpack/src/main.rs +++ b/crates/exav-unpack/src/main.rs @@ -20,9 +20,34 @@ #![forbid(unsafe_code)] +/// `println!`, except that a closed standard output ends the run quietly. +/// +/// Rust's runtime ignores `SIGPIPE`, so `exav-unpack -l archive | head` made +/// `println!` panic on the `EPIPE` and print a backtrace at the user. Every +/// other Unix filter just stops. +macro_rules! println { + ($($arg:tt)*) => {{ + use std::io::Write as _; + if let Err(e) = writeln!(std::io::stdout(), $($arg)*) { + stdout_closed(&e); + } + }}; +} + +/// The end of a run whose standard output cannot be written to: 141, what a +/// shell reports for a process killed by `SIGPIPE`, when the reader went away, +/// and 2 for any other write error. +fn stdout_closed(e: &std::io::Error) -> ! { + if e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(141); + } + eprintln!("exav-unpack: cannot write to standard output: {e}"); + std::process::exit(2); +} + use chrono::{Local, LocalResult, TimeZone}; use exav_unpack::source::BlockCache; -use exav_unpack::{detect, walk, Budget, ByteSource, Format, Limits, Member, MemberMeta, Mtime}; +use exav_unpack::{Budget, ByteSource, Format, Limits, Member, MemberMeta, Mtime, detect, walk}; use std::fs::{self, File, OpenOptions}; use std::io::{self, BufRead, Read, Seek, SeekFrom, Write}; use std::path::{Component, Path, PathBuf}; @@ -282,7 +307,7 @@ fn parse(args: &[String]) -> Result { format!("-{c} ({what}) is an unzip option exav-unpack does not take") } None => format!("unknown option -{c}"), - }) + }); } } k += 1; @@ -589,11 +614,11 @@ impl<'a> Archive<'a> { println!("--------- ---------- ----- ----"); } let root = o.dir.clone().unwrap_or_else(|| PathBuf::from(".")); - if o.mode == Mode::Extract { - if let Err(e) = fs::create_dir_all(&root) { - eprintln!("exav-unpack: cannot create {}: {e}", root.display()); - return status::ERROR; - } + if o.mode == Mode::Extract + && let Err(e) = fs::create_dir_all(&root) + { + eprintln!("exav-unpack: cannot create {}: {e}", root.display()); + return status::ERROR; } let mut budget = Budget::with_passwords(o.limits.clone(), o.passwords.clone()); budget.set_visit_directories(true); @@ -1160,12 +1185,70 @@ fn escapes(root: &Path, path: &Path, target: &str) -> bool { false } +/// Whether a link at `path` to `target` leads out of `root` once the links +/// already made are followed. [`escapes`] reads the words of the target alone, +/// and counts a `..` against the link's own depth: `a -> .` and `b -> a/..` each +/// pass it, and `b` is the directory above the root. +fn resolves_outside(root: &Path, path: &Path, target: &str) -> bool { + let Ok(root) = fs::canonicalize(root) else { + return true; + }; + let Some(mut at) = path.parent().and_then(|p| fs::canonicalize(p).ok()) else { + return true; + }; + if !at.starts_with(&root) { + return true; + } + // The words still to be read, the next one last. + let words = |t: &str| -> Option> { + let mut out = Vec::new(); + for c in Path::new(t).components().rev() { + match c { + Component::RootDir | Component::Prefix(_) => return None, + Component::CurDir => {} + Component::ParentDir => out.push("..".into()), + Component::Normal(n) => out.push(n.to_os_string()), + } + } + Some(out) + }; + let Some(mut todo) = words(target) else { + return true; + }; + let mut hops = 0; + while let Some(w) = todo.pop() { + if w == ".." { + if !at.pop() || !at.starts_with(&root) { + return true; + } + continue; + } + at.push(&w); + if fs::symlink_metadata(&at).is_ok_and(|m| m.file_type().is_symlink()) { + hops += 1; + let Some(next) = (hops <= 40) + .then(|| fs::read_link(&at).ok()) + .flatten() + .and_then(|l| words(&l.to_string_lossy())) + else { + return true; + }; + at.pop(); + todo.extend(next); + } + } + !at.starts_with(&root) +} + /// Make the link at `path` to `target`, if the target stays under `root`. fn make_link(root: &Path, path: &Path, target: &str) -> Result<(), String> { if escapes(root, path, target) { return Err("symbolic link points outside the extraction directory".to_string()); } directories(root, path, false)?; + if resolves_outside(root, path, target) { + return Err("symbolic link points outside the extraction directory".to_string()); + } if fs::symlink_metadata(path).is_ok() { fs::remove_file(path).map_err(|e| e.to_string())?; } @@ -1240,7 +1323,7 @@ fn directories(root: &Path, path: &Path, last: bool) -> Result<(), String> { at.push(c); match fs::symlink_metadata(&at) { Ok(m) if m.file_type().is_symlink() => { - return Err(format!("{} is a link, not followed", at.display())) + return Err(format!("{} is a link, not followed", at.display())); } Ok(m) if m.is_dir() => {} Ok(_) => return Err(format!("{} is a file, not a directory", at.display())), diff --git a/crates/exav-unpack/src/oracle.rs b/crates/exav-unpack/src/oracle.rs new file mode 100644 index 00000000..c727d4c6 --- /dev/null +++ b/crates/exav-unpack/src/oracle.rs @@ -0,0 +1,151 @@ +//! Test support for the differential tests against a reference decoder built +//! from its own sources (`tests/oracle/build.sh`). +//! +//! Those tests are `#[ignore]`d: a run that did not ask for them lists them as +//! ignored, never as passed. Run on request, one whose oracle is not built +//! fails instead of skipping. + +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; + +/// Runs the oracle named by the environment variable `var`, with `args`, over +/// `jobs` (a limit and an input each), and returns its status byte and output +/// for each. Panics when the variable is unset or the oracle fails. +pub(crate) fn run(var: &str, args: &[&str], jobs: &[(u32, Vec)]) -> Vec<(u8, Vec)> { + let path = std::env::var(var).unwrap_or_else(|_| { + panic!("{var} is not set: build the oracle with tests/oracle/build.sh and set it") + }); + let mut child = Command::new(&path) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| panic!("cannot run {path}: {e}")); + let mut stdin = child.stdin.take().unwrap(); + let mut request = Vec::new(); + for (limit, input) in jobs { + request.extend_from_slice(&limit.to_le_bytes()); + request.extend_from_slice(&(input.len() as u32).to_le_bytes()); + request.extend_from_slice(input); + } + let writer = std::thread::spawn(move || stdin.write_all(&request)); + let mut reply = Vec::new(); + child + .stdout + .take() + .unwrap() + .read_to_end(&mut reply) + .unwrap(); + let wrote = writer.join().unwrap(); + let status = child.wait().unwrap(); + assert!(status.success(), "{path} failed: {status} ({wrote:?})"); + let mut out = Vec::new(); + let mut at = 0; + while at < reply.len() { + let n = u32::from_le_bytes(reply[at + 1..at + 5].try_into().unwrap()) as usize; + out.push((reply[at], reply[at + 5..at + 5 + n].to_vec())); + at += 5 + n; + } + assert_eq!(out.len(), jobs.len(), "{path} answered fewer inputs"); + out +} + +/// With `EXAV_DEBUG_ORACLE_DUMP` set to a directory, writes a stream that differs, +/// the reference's output for it and ours, to inspect it. +pub(crate) fn dump(name: &str, n: usize, input: &[u8], reference: &[u8], ours: &[u8]) { + if let Ok(dir) = std::env::var("EXAV_DEBUG_ORACLE_DUMP") { + for (ext, bytes) in [("in", input), ("ref", reference), ("ours", ours)] { + let _ = std::fs::write(format!("{dir}/{name}{n}.{ext}"), bytes); + } + } +} + +/// A xorshift generator, so a failure repeats from its seed. +pub(crate) struct Rng(pub(crate) u64); + +impl Rng { + pub(crate) fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + pub(crate) fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } + + /// `base` damaged one way: a bit flipped, a byte or a four-byte field + /// set (to an extreme half the time), a cut, bytes inserted or removed, a + /// piece repeated. `hot` is the length of the header region the + /// fields are chosen from three times in four. + pub(crate) fn damage(&mut self, base: &[u8], hot: usize) -> Vec { + let mut v = base.to_vec(); + if v.is_empty() { + return v; + } + let at = |r: &mut Rng, len: usize| match r.below(4) { + 0 => r.below(len), + _ => r.below(len.min(hot.max(1))), + }; + match self.below(8) { + 0 | 1 => { + let i = at(self, v.len()); + v[i] ^= 1 << self.below(8); + } + 2 => { + let i = at(self, v.len()); + v[i] = self.next() as u8; + } + 3 => { + let i = at(self, v.len()); + let w = [0, 1, 0x7f, 0x80, 0xff, 0xfe][self.below(6)]; + v[i] = w; + } + 4 => { + let i = at(self, v.len()); + let w: u32 = match self.below(3) { + 0 => [0, 1, 0xffff, 0x10000, 0x7fff_ffff, 0x8000_0000, u32::MAX][self.below(7)], + 1 => self.next() as u32 & 0xfffff, + _ => self.next() as u32, + }; + for (k, b) in w.to_le_bytes().iter().enumerate() { + if let Some(x) = v.get_mut(i + k) { + *x = *b; + } + } + } + 5 => { + let cut = self.below(v.len()); + v.truncate(cut); + } + 6 => { + let i = at(self, v.len()); + let n = 1 + self.below(8); + match self.below(2) { + 0 => { + let end = (i + n).min(v.len()); + v.drain(i..end); + } + _ => { + for _ in 0..n { + v.insert(i, self.next() as u8); + } + } + } + } + _ => { + let i = at(self, v.len()); + let n = 1 + self.below(32); + let piece: Vec = v[i..(i + n).min(v.len())].to_vec(); + let j = self.below(v.len()); + v.splice(j..j, piece); + } + } + if !v.is_empty() && self.below(5) == 0 { + let i = at(self, v.len()); + v[i] ^= 1 << self.below(8); + } + v + } +} diff --git a/crates/exav-unpack/src/source.rs b/crates/exav-unpack/src/source.rs index dd58d216..7159eb3f 100644 --- a/crates/exav-unpack/src/source.rs +++ b/crates/exav-unpack/src/source.rs @@ -310,14 +310,13 @@ impl BlockCache { out.extend_from_slice(&data[skip..end]); } let whole = end == skip + want; - if blocks.len() >= self.max_blocks { - if let Some(&old) = blocks + if blocks.len() >= self.max_blocks + && let Some(&old) = blocks .iter() .min_by_key(|(_, (_, used))| *used) .map(|(k, _)| k) - { - blocks.remove(&old); - } + { + blocks.remove(&old); } blocks.insert(idx, (data.into_boxed_slice(), tick)); whole @@ -631,7 +630,7 @@ pub fn bytes<'a, B: ByteSource + ?Sized>( /// A source that claims `claimed` bytes and delivers only `data`, for tests of /// what a failed read does. #[cfg(test)] -pub(crate) fn short_source(data: &[u8], claimed: u64) -> BlockCache { +pub(crate) fn short_source(data: &[u8], claimed: u64) -> BlockCache> { struct Short(std::io::Cursor>, u64); impl Read for Short { fn read(&mut self, b: &mut [u8]) -> std::io::Result { diff --git a/crates/exav-unpack/src/stream.rs b/crates/exav-unpack/src/stream.rs index 51353a00..63a5ebd6 100644 --- a/crates/exav-unpack/src/stream.rs +++ b/crates/exav-unpack/src/stream.rs @@ -232,6 +232,12 @@ pub fn walk( Some(e) => Err(LimitHit::corrupt(format!("{fmt:?}: {e}"))), None => Ok(None), }, + // A container that is found damaged where the source failed to give it + // is damaged by the failure: that is the reason to report. + Ok(Err(hit)) if hit.is_corrupt() => match src.read_error() { + Some(e) => Err(LimitHit::corrupt(format!("{fmt:?}: {e}"))), + None => Err(hit), + }, Ok(r) => r, Err(_) => Err(LimitHit::corrupt(format!( "{fmt:?} decoder panicked on malformed input" @@ -278,6 +284,10 @@ fn dispatch( Format::Zstd => f::zstd::walk, #[cfg(feature = "lzip")] Format::Lzip => f::lzip::walk, + #[cfg(feature = "lzma")] + Format::Lzma => f::lzmaalone::walk, + #[cfg(feature = "rpm")] + Format::Rpm => f::rpm::walk, #[cfg(feature = "sevenz")] Format::SevenZip => f::sevenz::walk, #[cfg(feature = "cab")] @@ -310,8 +320,36 @@ fn dispatch( Format::Swf => f::swf::walk, #[cfg(feature = "dmg")] Format::Dmg => f::dmg::walk, + #[cfg(feature = "vhd")] + Format::Vhd => f::vhd::walk, + #[cfg(feature = "diskimage")] + Format::Qcow2 => f::qcow2::walk, + #[cfg(feature = "diskimage")] + Format::Vmdk => f::vmdk::walk, + #[cfg(feature = "diskimage")] + Format::Vhdx => f::vhdx::walk, + #[cfg(feature = "ntfs")] + Format::Ntfs => f::ntfs::walk, + #[cfg(feature = "fat")] + Format::Fat => f::fat::walk, + #[cfg(feature = "ext")] + Format::Ext => f::ext::walk, + #[cfg(feature = "exfat")] + Format::Exfat => f::exfat::walk, + #[cfg(feature = "squashfs")] + Format::Squashfs => f::squashfs::walk, + #[cfg(feature = "cramfs")] + Format::Cramfs => f::cramfs::walk, + #[cfg(feature = "romfs")] + Format::Romfs => f::romfs::walk, + #[cfg(feature = "erofs")] + Format::Erofs => f::erofs::walk, #[cfg(feature = "szdd")] Format::Szdd => f::szdd::walk, + #[cfg(feature = "wim")] + Format::Wim => f::wim::walk, + #[cfg(feature = "xar")] + Format::Xar => f::xar::walk, _ => return whole(fmt, src, budget, visit), }; walk(src, budget, visit) @@ -1028,7 +1066,7 @@ mod tests { "{path}" ); } - assert_eq!(seen, 7); + assert_eq!(seen, 8); } // Every codec decodes, including the solid multi-file case, where each file @@ -1084,11 +1122,11 @@ mod tests { out.extend_from_slice(&0u16.to_le_bytes()); // flags out.extend_from_slice(&0u16.to_le_bytes()); // setID out.extend_from_slice(&0u16.to_le_bytes()); // iCabinet - // CFFOLDER + // CFFOLDER out.extend_from_slice(&cfdata_start.to_le_bytes()); // coffCabStart out.extend_from_slice(&1u16.to_le_bytes()); // cCFData out.extend_from_slice(&0u16.to_le_bytes()); // typeCompress = None (stored) - // CFFILE table + // CFFILE table let mut off = 0u32; for (n, d) in files { out.extend_from_slice(&(d.len() as u32).to_le_bytes()); // cbFile diff --git a/crates/exav-unpack/src/text/html.rs b/crates/exav-unpack/src/text/html.rs new file mode 100644 index 00000000..3d8d4c41 --- /dev/null +++ b/crates/exav-unpack/src/text/html.rs @@ -0,0 +1,151 @@ +//! HTML into its text, from the tokens of `crate::html`, pulled one at a time. +//! +//! What this adds is the policy: left out are comments, `script`, `style`, +//! `template` and `iframe` (code, markup and content nobody reads as text); +//! kept is every text node, including a `noscript` and what a CSS rule would +//! hide (a reader of the source cannot tell), an image's `alt`, and each +//! link's target after its label. Blocks, rows and breaks end the line. + +use crate::html::{Token, attr, tokens}; + +use super::Out; + +/// Elements whose start and end end the line. +const BLOCKS: &[&str] = &[ + "address", + "article", + "aside", + "blockquote", + "body", + "br", + "caption", + "center", + "dd", + "details", + "dialog", + "dir", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "html", + "legend", + "li", + "main", + "menu", + "nav", + "noscript", + "ol", + "optgroup", + "option", + "p", + "pre", + "section", + "select", + "summary", + "table", + "tbody", + "textarea", + "tfoot", + "thead", + "title", + "tr", + "ul", +]; + +/// Elements whose content is skipped to their end tag. +const SKIPPED: &[&str] = &[ + "script", "style", "template", "iframe", "noembed", "noframes", "xmp", +]; + +struct State<'a> { + out: &'a mut Out, + /// The element being skipped. + skipping: Option, + /// The link being read: where its label starts in the text, and its target. + anchor: Option<(usize, String)>, +} + +pub(super) fn read(src: &str, out: &mut Out) { + let mut st = State { + out, + skipping: None, + anchor: None, + }; + for token in tokens(src) { + match token { + Token::Text(text) => { + if st.skipping.is_none() { + st.out.text(&text); + } + } + Token::Start { name, attrs, .. } => st.start(&name, &attrs), + Token::End(name) => st.end(&name), + Token::Comment(_) => {} + } + if st.out.full() { + return; + } + } + st.close_anchor(); +} + +impl State<'_> { + fn close_anchor(&mut self) { + if let Some((from, href)) = self.anchor.take() { + self.out.target(from, &href); + } + } + + fn end(&mut self, name: &str) { + if self.skipping.as_deref() == Some(name) { + self.skipping = None; + } else if self.skipping.is_none() { + match name { + "a" => self.close_anchor(), + "td" | "th" => self.out.tab(), + _ if BLOCKS.contains(&name) => self.out.newline(), + _ => {} + } + } + } + + fn start(&mut self, name: &str, attrs: &[(String, String)]) { + if self.skipping.is_some() { + return; + } + match name { + "a" => { + self.close_anchor(); + if let Some(href) = attr(attrs, "href") { + self.anchor = Some((self.out.len(), href.to_string())); + } + } + "img" | "area" => { + if let Some(alt) = attr(attrs, "alt") { + self.out.space(); + self.out.text(alt); + self.out.space(); + } + } + "td" | "th" => self.out.tab(), + _ if BLOCKS.contains(&name) => self.out.newline(), + _ => {} + } + if SKIPPED.contains(&name) { + self.skipping = Some(name.to_string()); + } + } +} diff --git a/crates/exav-unpack/src/text/mod.rs b/crates/exav-unpack/src/text/mod.rs new file mode 100644 index 00000000..07dae7cb --- /dev/null +++ b/crates/exav-unpack/src/text/mod.rs @@ -0,0 +1,352 @@ +//! The text a reader sees in a document, for a scanner or a search: HTML, the +//! XML parts of Office and OpenDocument files, and (feature `text-pdf`) PDF. +//! +//! A signature written against what a document says (a phrase, a link's +//! address) cannot match it in the bytes when markup, escapes, encodings or +//! compression sit between the words. These readers take the words out, one +//! block to a line, and nothing else: what a reader never sees (a script, a +//! style, a comment, a field code) is left out, and where a link leads is +//! written after its label, `label (target)`, whatever the target is. +//! +//! The parsing is other people's: html5gum's tokenizer for HTML, quick-xml +//! for XML, hayro for PDF. Each is read in a single pass that builds no tree, +//! except hayro's page interpretation, and the text made is cut at the `cap` +//! given, which [`Extracted`] says. Nothing here fetches, runs or follows +//! anything. + +mod html; +#[cfg(feature = "text-pdf")] +mod pdf; +#[cfg(feature = "text-pdf")] +mod pdf_screen; +mod xml; + +use std::borrow::Cow; + +/// What a document is, for reading its text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// An HTML document or fragment. + Html, + /// A PDF document. Feature `text-pdf`: without it, [`kind`] never says so. + Pdf, + /// One XML part of an Office or OpenDocument file. + Xml(XmlPart), +} + +/// Which part of an Office or OpenDocument file an XML member is, which says +/// where its text is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XmlPart { + /// WordprocessingML: the document, its headers, footers, notes and comments. + Word, + /// DrawingML slides and their notes. + Slide, + /// SpreadsheetML's shared strings. + SharedStrings, + /// A SpreadsheetML sheet: inline strings and formulas. + Sheet, + /// OpenDocument's content and styles. + Odf, +} + +/// What [`extract`] made. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Extracted { + /// The text, in UTF-8, one block to a line. + pub text: String, + /// The text reached the cap, or a PDF stream decompressed past it: the + /// rest of the document is not here. + pub truncated: bool, + /// Each link whose label is not its target, as `(label, target)`, for a + /// check of what a link says against where it goes. At most [`MAX_LINKS`]. + pub links: Vec<(String, String)>, +} + +/// Links kept in [`Extracted::links`]. +pub const MAX_LINKS: usize = 4096; + +/// What `data` is, going by its member `name` and its first bytes `head` +/// (a kilobyte is enough), when its text can be read. +/// +/// A PDF is known by its signature (with the `text-pdf` feature), HTML by its extension or an ``, +/// `` or doctype near the start, an XML part by its path in the package. +pub fn kind(name: &str, head: &[u8]) -> Option { + let head = &head[..head.len().min(1024)]; + #[cfg(feature = "text-pdf")] + if find(head, b"%PDF-").is_some() { + return Some(Kind::Pdf); + } + // The rest is markup: a binary file called `x.html` is not. + if !looks_text(head) { + return None; + } + let lower = name.to_ascii_lowercase(); + if let Some(part) = xml_part(&lower) { + return Some(Kind::Xml(part)); + } + let by_name = [".html", ".htm", ".xhtml", ".shtml"] + .iter() + .any(|e| lower.ends_with(e)); + let by_head = { + let h = head.to_ascii_lowercase(); + HTML_MARKERS.iter().any(|n| find(&h, n).is_some()) + }; + (by_name || by_head).then_some(Kind::Html) +} + +/// What near the start of a text says it is HTML: a document's tags, or the +/// ones a fragment (an email's body, say) is made of. +const HTML_MARKERS: &[&[u8]] = &[ + b"", + b"", + b"

", + b"

  • ", +]; + +/// Whether `head` reads as text: no NUL, and few other control bytes. +pub fn looks_text(head: &[u8]) -> bool { + let controls = head + .iter() + .filter(|&&b| b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r' | 0x0c)) + .count(); + !head.is_empty() && !head.contains(&0) && controls * 20 <= head.len() +} + +/// The text of `data`, a document of `kind`, cut at `cap` bytes. `limits` bound +/// what a PDF's streams decompress to. +pub fn extract(kind: Kind, data: &[u8], cap: usize, limits: &crate::Limits) -> Extracted { + let mut out = Out::new(cap); + match kind { + Kind::Html => html::read(&crate::html::decode(data), &mut out), + #[cfg(feature = "text-pdf")] + Kind::Pdf => pdf::read(data, limits, &mut out), + #[cfg(not(feature = "text-pdf"))] + Kind::Pdf => { + let _ = limits; + } + Kind::Xml(part) => xml::read(&decode(data), part, &mut out), + } + out.finish() +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + memchr::memmem::find(hay, needle) +} + +/// Whether the package path `lower` (lowercased) is a part that holds text. +fn xml_part(lower: &str) -> Option { + let path = lower.trim_start_matches('/'); + let numbered = |dir: &str, stem: &str| { + path.strip_prefix(dir) + .and_then(|r| r.strip_prefix(stem)) + .and_then(|r| r.strip_suffix(".xml")) + .is_some_and(|n| n.bytes().all(|b| b.is_ascii_digit())) + }; + if matches!( + path, + "word/document.xml" | "word/comments.xml" | "word/footnotes.xml" | "word/endnotes.xml" + ) || numbered("word/", "header") + || numbered("word/", "footer") + { + Some(XmlPart::Word) + } else if numbered("ppt/slides/", "slide") || numbered("ppt/notesslides/", "notesslide") { + Some(XmlPart::Slide) + } else if path == "xl/sharedstrings.xml" { + Some(XmlPart::SharedStrings) + } else if numbered("xl/worksheets/", "sheet") { + Some(XmlPart::Sheet) + } else if path == "content.xml" || path == "styles.xml" { + Some(XmlPart::Odf) + } else { + None + } +} + +/// `data` as text: UTF-8, UTF-16 by its byte-order mark, else windows-1252, +/// which no byte sequence is invalid in. +fn decode(data: &[u8]) -> Cow<'_, str> { + if data.starts_with(&[0xff, 0xfe]) || data.starts_with(&[0xfe, 0xff]) { + let (text, _, _) = encoding_rs::UTF_16LE.decode(data); + let (text, _, _) = if data[0] == 0xfe { + encoding_rs::UTF_16BE.decode(data) + } else { + (text, encoding_rs::UTF_16LE, false) + }; + return Cow::Owned(text.into_owned()); + } + let data = data.strip_prefix(b"\xef\xbb\xbf").unwrap_or(data); + match std::str::from_utf8(data) { + Ok(s) => Cow::Borrowed(s), + Err(_) => Cow::Owned(encoding_rs::WINDOWS_1252.decode(data).0.into_owned()), + } +} + +/// The text being made: whitespace collapsed to one space, blocks on their +/// own lines, and a cap. +pub(crate) struct Out { + s: String, + cap: usize, + pub(crate) truncated: bool, + space: bool, + links: Vec<(String, String)>, +} + +impl Out { + pub(crate) fn new(cap: usize) -> Self { + Out { + s: String::new(), + cap, + truncated: false, + space: false, + links: Vec::new(), + } + } + + /// The cap was reached: nothing more is wanted. + pub(crate) fn full(&self) -> bool { + self.truncated + } + + pub(crate) fn len(&self) -> usize { + self.s.len() + } + + /// The text made from byte offset `from`. + pub(crate) fn since(&self, from: usize) -> &str { + self.s.get(from..).unwrap_or("") + } + + fn at_line_start(&self) -> bool { + self.s.is_empty() || self.s.ends_with('\n') + } + + fn put(&mut self, c: char) { + if self.s.len() + c.len_utf8() > self.cap { + self.truncated = true; + } else { + self.s.push(c); + } + } + + /// Words: any white space is one space, none at the start of a line. + pub(crate) fn text(&mut self, t: &str) { + for c in t.chars() { + if self.truncated { + return; + } + if c.is_whitespace() || c == '\u{200b}' { + self.space = true; + } else { + if self.space && !self.at_line_start() { + self.put(' '); + } + self.space = false; + self.put(c); + } + } + } + + /// A separator inside a line. + pub(crate) fn space(&mut self) { + self.space = true; + } + + /// The end of a block: the next text starts a line. + pub(crate) fn newline(&mut self) { + // A row ends its last cell's tab. + if self.s.ends_with('\t') { + self.s.pop(); + } + if !self.at_line_start() { + self.put('\n'); + } + self.space = false; + } + + /// A tab between cells. + pub(crate) fn tab(&mut self) { + if !self.at_line_start() && !self.s.ends_with('\t') { + self.put('\t'); + } + self.space = false; + } + + /// Mark the text cut short, for a reader that stopped for another reason. + #[cfg(feature = "text-pdf")] + pub(crate) fn cut(&mut self) { + self.truncated = true; + } + + /// ` (target)` after a link's label, unless the label is the target (or + /// there is no label to follow). + pub(crate) fn target(&mut self, label_from: usize, target: &str) { + let target = target.trim(); + if target.is_empty() || target.starts_with('#') { + return; + } + let label = self.since(label_from).trim(); + let bare = target.strip_prefix("mailto:").unwrap_or(target); + if label == target + || label == bare + || label.trim_end_matches('/') == target.trim_end_matches('/') + { + return; + } + if self.links.len() < MAX_LINKS { + self.links.push(( + label.chars().take(2048).collect(), + target.chars().take(2048).collect(), + )); + } + self.space(); + self.text(&format!("({target})")); + } + + fn finish(mut self) -> Extracted { + while self.s.ends_with(['\n', ' ', '\t']) { + self.s.pop(); + } + if !self.s.is_empty() { + self.s.push('\n'); + } + Extracted { + text: self.s, + truncated: self.truncated, + links: self.links, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn what_a_part_of_a_package_is() { + assert_eq!(xml_part("word/document.xml"), Some(XmlPart::Word)); + assert_eq!(xml_part("word/header2.xml"), Some(XmlPart::Word)); + assert_eq!(xml_part("word/headers.xml"), None); + assert_eq!(xml_part("ppt/slides/slide12.xml"), Some(XmlPart::Slide)); + assert_eq!(xml_part("ppt/slides/_rels/slide1.xml.rels"), None); + assert_eq!(xml_part("xl/worksheets/sheet1.xml"), Some(XmlPart::Sheet)); + assert_eq!( + xml_part("xl/sharedstrings.xml"), + Some(XmlPart::SharedStrings) + ); + assert_eq!(xml_part("content.xml"), Some(XmlPart::Odf)); + assert_eq!(xml_part("docprops/core.xml"), None); + } +} diff --git a/crates/exav-unpack/src/text/pdf.rs b/crates/exav-unpack/src/text/pdf.rs new file mode 100644 index 00000000..ae10df4c --- /dev/null +++ b/crates/exav-unpack/src/text/pdf.rs @@ -0,0 +1,156 @@ +//! The text of a PDF, read by hayro: it interprets each page and hands over +//! its glyphs with their Unicode (the font's `ToUnicode`, else the glyph's name), +//! and this puts them back in lines and words from where they sit. +//! +//! hayro puts no bound on what a stream decompresses to, nor on the size of the +//! image a dictionary or a codec's header declares (a 123 KB PDF with JBIG2 +//! data for 100000 by 100000 pixels took it 8.9 GB), so it is given a PDF only +//! after this crate's own reader has taken every stream of it within the budget +//! (what the scanner does anyway) and `pdf_screen` has read no image size past +//! its bound; one that does not pass is cut, not read. A PDF that hayro cannot +//! open (encrypted with a password, too damaged) has no text here. A panic in +//! hayro is contained. + +use std::collections::HashMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use hayro_interpret::font::{Glyph, GlyphRun}; +use hayro_interpret::hayro_cmap::BfString; +use hayro_interpret::{ + BlendMode, CacheKey, ClipPath, Context, Device, DrawMode, DrawProps, Image, ImageDrawProps, + InterpreterCache, InterpreterSettings, SoftMask, interpret_page, +}; +use hayro_syntax::Pdf; +use kurbo::{Affine, BezPath, Point, Rect, Shape}; + +use super::Out; +use super::pdf_screen::images_are_small; +use crate::{Budget, Format, Limits}; + +/// Glyph shapes remembered, for how far each reaches. +const MAX_REMEMBERED: usize = 1 << 16; + +pub(super) fn read(data: &[u8], limits: &Limits, out: &mut Out) { + // An encrypted PDF has no text here: hayro opens none, and the scanner + // reports it as encrypted on its own. + if memchr::memmem::find(data, b"/Encrypt").is_some() { + return; + } + if !images_are_small(data, limits.max_buffer_bytes) { + out.cut(); + return; + } + // Every stream within the budget first: hayro does not look. + let mut budget = Budget::new(limits.clone()); + match crate::walk(Format::Pdf, &data, &mut budget, &mut |_, _, _| None::<()>) { + Ok(_) => {} + Err(_) => { + out.cut(); + return; + } + } + let read = catch_unwind(AssertUnwindSafe(|| pages(data, out))); + if read.is_err() { + out.cut(); + } +} + +fn pages(data: &[u8], out: &mut Out) { + let Ok(pdf) = Pdf::new(data.to_vec()) else { + return; + }; + let cache = InterpreterCache::new(); + let mut device = Lines { + out, + last: None, + reach: HashMap::new(), + }; + for page in pdf.pages().iter() { + if device.out.full() { + return; + } + let settings = InterpreterSettings { + render_annotations: false, + ..InterpreterSettings::default() + }; + let mut context = Context::new( + Affine::IDENTITY, + Rect::new(0.0, 0.0, 1.0, 1.0), + &cache, + pdf.xref(), + settings, + ); + interpret_page(page, &mut context, &mut device); + device.out.newline(); + device.last = None; + } +} + +/// Where the last glyph ended. +struct Last { + right: f64, + y: f64, +} + +struct Lines<'o> { + out: &'o mut Out, + last: Option, + /// How far right of its origin each glyph shape reaches, in 1000 units to + /// the em. + reach: HashMap, +} + +impl Device<'_> for Lines<'_> { + fn draw_path(&mut self, _: &BezPath, _: DrawProps<'_>, _: &DrawMode) {} + + fn push_clip_path(&mut self, _: &ClipPath) {} + + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + + fn draw_glyph_run(&mut self, run: &GlyphRun<'_, '_>, props: DrawProps<'_>, _: &DrawMode) { + for glyph in run.glyphs() { + if self.out.full() { + return; + } + let m = props.transform * glyph.transform(); + let origin = m * Point::ZERO; + let em = ((m * Point::new(0.0, 1000.0)) - origin).hypot().max(1e-6); + let reach = match &**glyph { + Glyph::Outline(g) => { + let key = g.identifier().cache_key(); + match self.reach.get(&key) { + Some(r) => *r, + None => { + let r = g.outline().bounding_box().x1.max(0.0); + if self.reach.len() < MAX_REMEMBERED { + self.reach.insert(key, r); + } + r + } + } + } + Glyph::Type3(_) => 500.0, + }; + let right = (m * Point::new(reach, 0.0)).x; + if let Some(last) = &self.last { + if (origin.y - last.y).abs() > em * 0.5 { + self.out.newline(); + } else if origin.x - last.right > em * 0.12 { + self.out.space(); + } + } + self.last = Some(Last { right, y: origin.y }); + match glyph.as_unicode() { + Some(BfString::Char(c)) => self.out.text(c.encode_utf8(&mut [0; 4])), + Some(BfString::String(s)) => self.out.text(&s), + None => {} + } + } + } + + fn draw_image(&mut self, _: Image<'_, '_>, _: ImageDrawProps<'_>) {} + + fn pop_clip(&mut self) {} + + fn pop_transparency_group(&mut self) {} +} diff --git a/crates/exav-unpack/src/text/pdf_screen.rs b/crates/exav-unpack/src/text/pdf_screen.rs new file mode 100644 index 00000000..9342fa26 --- /dev/null +++ b/crates/exav-unpack/src/text/pdf_screen.rs @@ -0,0 +1,181 @@ +//! What a PDF declares before hayro is given it. +//! +//! hayro sizes its buffers by what an image says of itself, in its dictionary +//! or in the header of its JPEG, JPEG 2000 or JBIG2 data: a 123 KB file with +//! JBIG2 data for 100000 by 100000 pixels took it 8.9 GB. This reads those +//! numbers, and nothing else of the data, from every stream of the document, +//! and refuses a document in which one is past [`MAX_IMAGE_PIXELS`] or cannot +//! be read. + +use std::collections::HashMap; + +use crate::formats::pdf::{apply_filters, filter_names}; +use crate::formats::pdf_parse::lex::Lexer; +use crate::formats::pdf_parse::parse::parse_indirect_object_body; +use crate::formats::pdf_parse::types::Primitive; + +/// Most pixels an image of the document may declare (a 134 million pixel image +/// is 536 MB of RGBA). +pub(super) const MAX_IMAGE_PIXELS: u64 = 1 << 27; + +/// Whether every image of `data` is within [`MAX_IMAGE_PIXELS`], by what it +/// declares. `cap` bounds what a stream decodes to before its image codec. +pub(super) fn images_are_small(data: &[u8], cap: u64) -> bool { + let mut lex = Lexer::new(data); + while let Some((_, _, start)) = lex.find_next_obj() { + lex.set_pos(start); + let Some((obj, _)) = parse_indirect_object_body(&mut lex) else { + continue; + }; + let (info, raw) = match obj { + Primitive::Stream { info, data } => (info, Some(data)), + Primitive::Dictionary(info) => (info, None), + _ => continue, + }; + if declared(&info).is_some_and(|(w, h)| w.saturating_mul(h) > MAX_IMAGE_PIXELS) { + return false; + } + let Some(raw) = raw else { continue }; + let Some(codec) = filter_names(&info).into_iter().find_map(|f| codec(&f)) else { + continue; + }; + // The data as the codec gets it, after the filters before it. + let Ok(decoded) = apply_filters(&raw, &info, cap) else { + return false; + }; + if decoded.truncated { + return false; + } + match header_dims(codec, &decoded.buf) { + Some((w, h)) if w.saturating_mul(h) <= MAX_IMAGE_PIXELS => {} + _ => return false, + } + } + true +} + +#[derive(Clone, Copy)] +enum Codec { + Jpeg, + Jpx, + Jbig2, +} + +/// The image codec a filter name is, when it is one whose header is read. +fn codec(filter: &str) -> Option { + match filter { + "DCTDecode" | "DCT" => Some(Codec::Jpeg), + "JPXDecode" => Some(Codec::Jpx), + "JBIG2Decode" => Some(Codec::Jbig2), + _ => None, + } +} + +/// `/Width` and `/Height` of a dictionary, when it has both as integers. +fn declared(info: &HashMap) -> Option<(u64, u64)> { + let n = |k: &str| { + info.get(k) + .and_then(Primitive::as_i64) + .map(|v| u64::try_from(v).unwrap_or(0)) + }; + Some((n("Width")?, n("Height")?)) +} + +fn header_dims(codec: Codec, d: &[u8]) -> Option<(u64, u64)> { + match codec { + Codec::Jpeg => jpeg_dims(d), + Codec::Jpx => jpx_dims(d), + Codec::Jbig2 => jbig2_dims(d), + } +} + +fn be16(d: &[u8], at: usize) -> Option { + Some(u64::from(u16::from_be_bytes( + d.get(at..at + 2)?.try_into().ok()?, + ))) +} + +fn be32(d: &[u8], at: usize) -> Option { + Some(u64::from(u32::from_be_bytes( + d.get(at..at + 4)?.try_into().ok()?, + ))) +} + +/// A JPEG's size, from its first start-of-frame marker. +fn jpeg_dims(d: &[u8]) -> Option<(u64, u64)> { + let mut at = 2; + for _ in 0..4096 { + if *d.get(at)? != 0xff { + at += 1; + continue; + } + let marker = *d.get(at + 1)?; + match marker { + 0xff => at += 1, + // Start of frame (not the Huffman table, the JPEG extension or the + // arithmetic conditioning). + 0xc0..=0xcf if !matches!(marker, 0xc4 | 0xc8 | 0xcc) => { + return Some((be16(d, at + 7)?, be16(d, at + 5)?)); + } + // Markers without a length. + 0x00 | 0x01 | 0xd0..=0xd9 => at += 2, + _ => at += 2 + usize::try_from(be16(d, at + 2)?).ok()?, + } + } + None +} + +/// A JPEG 2000 image's size: from the codestream's SIZ marker, or the `ihdr` +/// box of a JP2 file. +fn jpx_dims(d: &[u8]) -> Option<(u64, u64)> { + let head = &d[..d.len().min(4096)]; + if let Some(at) = memchr::memmem::find(head, b"ihdr") { + return Some((be32(d, at + 8)?, be32(d, at + 4)?)); + } + let at = memchr::memmem::find(head, &[0xff, 0x4f, 0xff, 0x51])? + 2; + let (x, y) = (be32(d, at + 6)?, be32(d, at + 10)?); + let (xo, yo) = (be32(d, at + 14)?, be32(d, at + 18)?); + Some((x.saturating_sub(xo), y.saturating_sub(yo))) +} + +/// A JBIG2 page's size, from its page information segment (embedded +/// organisation, as PDF has it: a run of segments). +fn jbig2_dims(d: &[u8]) -> Option<(u64, u64)> { + let mut at = 0usize; + for _ in 0..64 { + let number = be32(d, at)?; + let flags = *d.get(at + 4)?; + let kind = flags & 0x3f; + let mut p = at + 5; + // Referred-to segments: a count, retain bits, and their numbers. + let first = *d.get(p)?; + let count = if first >> 5 == 7 { + let n = be32(d, p)? & 0x1fff_ffff; + p += 4 + usize::try_from(n + 1).ok()?.div_ceil(8); + n + } else { + p += 1; + u64::from(first >> 5) + }; + let size = if number <= 256 { + 1 + } else if number <= 65536 { + 2 + } else { + 4 + }; + p += usize::try_from(count).ok()?.checked_mul(size)?; + // The page the segment belongs to, then its length. + p += if flags & 0x40 != 0 { 4 } else { 1 }; + let length = be32(d, p)?; + p += 4; + if kind == 48 { + return Some((be32(d, p)?, be32(d, p + 4)?)); + } + if length == 0xffff_ffff { + return None; + } + at = p.checked_add(usize::try_from(length).ok()?)?; + } + None +} diff --git a/crates/exav-unpack/src/text/xml.rs b/crates/exav-unpack/src/text/xml.rs new file mode 100644 index 00000000..67db315c --- /dev/null +++ b/crates/exav-unpack/src/text/xml.rs @@ -0,0 +1,109 @@ +//! The text of an XML part of an Office or OpenDocument file. +//! +//! Where the text is depends on the part: Word and PowerPoint keep it in `t` +//! elements, a sheet in `t` (inline strings) and `f` (formulas), the shared +//! strings in `t`, OpenDocument anywhere in its content. A field code +//! (`instrText`) and deleted text (`delText`) are not read: a reader does not +//! see them. Paragraphs, rows and breaks end the line, cells are apart by +//! tabs. Entities are the XML ones and character references; a DTD, and so a +//! custom entity, is never read. + +use quick_xml::Reader; +use quick_xml::events::Event; + +use super::{Out, XmlPart}; + +pub(super) fn read(src: &str, part: XmlPart, out: &mut Out) { + let mut reader = Reader::from_str(src); + { + let c = reader.config_mut(); + c.check_end_names = false; + c.allow_unmatched_ends = true; + c.allow_dangling_amp = true; + } + // How many open elements hold text, how many hold what is not read, and + // for each open element what it added to those. + let mut text = usize::from(part == XmlPart::Odf); + let mut skip = 0usize; + // OpenDocument table cells open, whose paragraphs do not end the line. + let mut cells = 0usize; + let mut open: Vec<(bool, bool)> = Vec::new(); + while !out.full() { + let event = match reader.read_event() { + Ok(Event::Eof) | Err(_) => break, + Ok(e) => e, + }; + match event { + Event::Start(e) => { + let name = e.local_name(); + let name = name.as_ref().as_bytes(); + let holds = holds_text(part, name); + let hides = matches!(name, b"instrText" | b"delText"); + text += usize::from(holds); + skip += usize::from(hides); + open.push((holds, hides)); + cells += usize::from(part == XmlPart::Odf && name == b"table-cell"); + starts(part, name, out); + } + Event::Empty(e) => starts(part, e.local_name().as_ref().as_bytes(), out), + Event::End(e) => { + let (holds, hides) = open.pop().unwrap_or((false, false)); + text -= usize::from(holds); + skip -= usize::from(hides); + let name = e.local_name(); + let name = name.as_ref().as_bytes(); + if part == XmlPart::Odf && name == b"table-cell" { + cells = cells.saturating_sub(1); + } + ends(part, name, cells > 0, out); + } + Event::Text(t) if text > 0 && skip == 0 => { + out.text(&t.xml10_content()); + } + Event::CData(t) if text > 0 && skip == 0 => { + out.text(&t); + } + Event::GeneralRef(r) if text > 0 && skip == 0 => { + if let Ok(Some(c)) = r.resolve_char_ref() { + out.text(c.encode_utf8(&mut [0; 4])); + } else if let Some(s) = quick_xml::escape::resolve_predefined_entity(&r) { + out.text(s); + } + } + _ => {} + } + } +} + +fn holds_text(part: XmlPart, name: &[u8]) -> bool { + match part { + XmlPart::Word | XmlPart::Slide | XmlPart::SharedStrings => name == b"t", + XmlPart::Sheet => matches!(name, b"t" | b"f"), + XmlPart::Odf => false, + } +} + +/// An element that is itself a break or a gap. +fn starts(part: XmlPart, name: &[u8], out: &mut Out) { + match (part, name) { + (XmlPart::Word, b"tab") | (XmlPart::Odf, b"tab" | b"s") => out.space(), + (XmlPart::Word, b"br" | b"cr") + | (XmlPart::Slide, b"br") + | (XmlPart::Odf, b"line-break") => out.newline(), + _ => {} + } +} + +/// An element whose end ends a line or a cell. `in_cell`: inside an +/// OpenDocument table cell, where a paragraph is a gap. +fn ends(part: XmlPart, name: &[u8], in_cell: bool, out: &mut Out) { + match (part, name) { + (XmlPart::Odf, b"p" | b"h") if in_cell => out.space(), + (XmlPart::Word | XmlPart::Slide, b"p") + | (XmlPart::SharedStrings, b"si") + | (XmlPart::Sheet, b"row") + | (XmlPart::Odf, b"p" | b"h" | b"table-row") => out.newline(), + (XmlPart::Sheet, b"c") | (XmlPart::Odf, b"table-cell") => out.tab(), + _ => {} + } +} diff --git a/crates/exav-unpack/src/volume.rs b/crates/exav-unpack/src/volume.rs index bd864d85..5bed4f23 100644 --- a/crates/exav-unpack/src/volume.rs +++ b/crates/exav-unpack/src/volume.rs @@ -76,28 +76,30 @@ pub fn parse(name: &str) -> Option { let stem = &name[..stem_l.len()]; // `stem.rNN` — continuation of the old RAR scheme. `.r00` is volume 1. - if let Some(digits) = ext.strip_prefix('r') { - if digits.len() == 2 && digits.bytes().all(|b| b.is_ascii_digit()) { - let n: usize = digits.parse().ok()?; - return Some(VolumeName { - stem: stem.to_string(), - index: n + 1, - scheme: Scheme::RarOld, - }); - } + if let Some(digits) = ext.strip_prefix('r') + && digits.len() == 2 + && digits.bytes().all(|b| b.is_ascii_digit()) + { + let n: usize = digits.parse().ok()?; + return Some(VolumeName { + stem: stem.to_string(), + index: n + 1, + scheme: Scheme::RarOld, + }); } // `stem.zNN` — ZIP spanning. `.z01` is volume 0; the bare `.zip` is LAST, // because it carries the central directory. - if let Some(digits) = ext.strip_prefix('z') { - if digits.len() == 2 && digits.bytes().all(|b| b.is_ascii_digit()) { - let n: usize = digits.parse().ok()?; - return Some(VolumeName { - stem: stem.to_string(), - index: n.checked_sub(1)?, - scheme: Scheme::ZipSplit, - }); - } + if let Some(digits) = ext.strip_prefix('z') + && digits.len() == 2 + && digits.bytes().all(|b| b.is_ascii_digit()) + { + let n: usize = digits.parse().ok()?; + return Some(VolumeName { + stem: stem.to_string(), + index: n.checked_sub(1)?, + scheme: Scheme::ZipSplit, + }); } // `stem..NNN` — the generic numbered split (7z, zip, arj, tar…). @@ -149,7 +151,14 @@ impl VolumeName { /// archive. pub fn same_set_as(&self, other: &VolumeName) -> bool { self.stem.eq_ignore_ascii_case(&other.stem) - && std::mem::discriminant(&self.scheme) == std::mem::discriminant(&other.scheme) + && match (&self.scheme, &other.scheme) { + // `a.7z.001` and `a.zip.002` are the volumes of two archives. + ( + Scheme::NumberedSuffix { base_ext: a, .. }, + Scheme::NumberedSuffix { base_ext: b, .. }, + ) => a.eq_ignore_ascii_case(b), + (a, b) => std::mem::discriminant(a) == std::mem::discriminant(b), + } } } @@ -177,11 +186,11 @@ pub fn group(names: &[&str]) -> Vec> { if claimed[j] { continue; } - if let Some(vj) = item { - if vi.same_set_as(vj) { - members.push((vj.index, j)); - claimed[j] = true; - } + if let Some(vj) = item + && vi.same_set_as(vj) + { + members.push((vj.index, j)); + claimed[j] = true; } } if members.len() < 2 { @@ -191,7 +200,7 @@ pub fn group(names: &[&str]) -> Vec> { members.sort_by_key(|(idx, _)| *idx); // Duplicate positions mean the names disagree about the set's shape; // concatenating them would splice bytes in an order nothing wrote. - if members.windows(2).any(|w| w[0].0 == w[1].0) { + if members.array_windows().any(|[a, b]| a.0 == b.0) { continue; } sets.push(members.into_iter().map(|(_, j)| j).collect()); @@ -399,7 +408,8 @@ impl Collector { continue; } let idx: Vec = set.parts.iter().map(|(i, _, _)| *i).collect(); - let complete = idx.first() == Some(&0) && idx.windows(2).all(|w| w[1] == w[0] + 1); + let complete = + idx.first() == Some(&0) && idx.array_windows().all(|[a, b]| *b == *a + 1); if !complete { let reason = if idx.first() != Some(&0) { "part of a multi-volume set whose first volume was not present" @@ -525,6 +535,32 @@ mod tests { assert_eq!(sets.len(), 2, "got {sets:?}"); } + #[test] + fn a_shared_stem_with_other_formats_numbered_is_two_archives() { + // The same stem and the same kind of numbering, but a 7z and a zip. + let names = ["a.7z.001", "a.zip.002", "a.ZIP.001", "a.7z.002"]; + let sets = group(&names); + assert_eq!(sets.len(), 2, "got {sets:?}"); + for s in &sets { + let exts: Vec<_> = s + .iter() + .map(|&i| { + names[i] + .rsplit_once('.') + .unwrap() + .0 + .rsplit('.') + .next() + .unwrap() + }) + .collect(); + assert!( + exts.iter().all(|e| e.eq_ignore_ascii_case(exts[0])), + "{exts:?}" + ); + } + } + #[test] fn a_lone_volume_is_not_a_set() { assert!(group(&["a.part1.rar"]).is_empty()); diff --git a/crates/exav-unpack/tests/cli.rs b/crates/exav-unpack/tests/cli.rs index b3510429..49d63462 100644 --- a/crates/exav-unpack/tests/cli.rs +++ b/crates/exav-unpack/tests/cli.rs @@ -412,6 +412,55 @@ fn nothing_is_written_through_a_link() { assert!(!fresh.join("up").is_symlink()); } +/// Each link of a chain passes a check of its own words: `a -> .` stays, and +/// `b -> a/..` has one `..` against two components. Followed through `a`, `b` is +/// the directory above the root, and `c -> b/..` the one above that. +#[cfg(unix)] +#[test] +fn a_chain_of_links_does_not_walk_out_of_the_extraction_directory() { + let sc = Scratch::new("link-chain"); + let t = sc.write( + "chain.tar", + &tar_of(&[ + ("a", b'2', 0o777, 0, ".", b""), + ("b", b'2', 0o777, 0, "a/..", b""), + ("c", b'2', 0o777, 0, "b/..", b""), + ("d", b'2', 0o777, 0, "c/etc", b""), + ("fine", b'2', 0o777, 0, "a/sub", b""), + ]), + ); + let fresh = sc.subdir("outer/root"); + let o = run(&[s(&t), "-d", s(&fresh)]); + assert!( + stderr(&o).contains("outside the extraction directory"), + "{}", + stderr(&o) + ); + let root = std::fs::canonicalize(&fresh).unwrap(); + for name in ["a", "b", "c", "d", "fine"] { + let l = fresh.join(name); + if l.is_symlink() { + let resolved = std::fs::canonicalize(&l).unwrap_or_else(|_| { + // Dangling: judge it by where its parent's target would be. + root.join(std::fs::read_link(&l).unwrap()) + }); + assert!( + resolved.starts_with(&root) || name == "fine", + "{name} leaves the root: {}", + resolved.display() + ); + } + } + assert!( + !fresh.join("b").is_symlink(), + "b -> a/.. is the directory above the root" + ); + assert!( + fresh.join("a").is_symlink(), + "a link inside the root is still made" + ); +} + /// tar keeps what unzip restores: the time, the permission bits, the links. #[cfg(unix)] #[test] @@ -716,7 +765,7 @@ fn a_real_rar_volume_set_extracts_from_any_part() { /// joined; parts named otherwise are given with `--volume`. #[test] fn a_rar_volume_set_is_read_whole() { - let arc = std::fs::read(concat!( + let arc = exav_unpack::read_fixture(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/rar_solid/solid_rar4.rar" )) @@ -835,3 +884,42 @@ fn an_unrecognised_format_fails_rather_than_reporting_an_empty_archive() { assert!(stderr(&o).contains("not an archive"), "{}", stderr(&o)); assert_eq!(stdout(&o), ""); } + +/// `exav-unpack -l archive | head` ends quietly, as every Unix filter does: +/// Rust ignores `SIGPIPE`, and `println!` panics on the `EPIPE` it gets back. +#[cfg(unix)] +#[test] +fn a_listing_into_a_closed_pipe_ends_quietly() { + use std::io::Read; + let sc = Scratch::new("closed-pipe"); + // Far more output than a pipe holds, so the program is still writing when + // the reader goes away. + let names: Vec = (0..6000) + .map(|i| format!("member-with-a-long-name-{i:05}.txt")) + .collect(); + let members: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), &b""[..])).collect(); + let arc = sc.write("many.zip", &zip_of(&members)); + let mut child = Command::new(BIN) + .arg("-l") + .arg(&arc) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // One byte read, then the pipe closed. + let mut stdout = child.stdout.take().unwrap(); + let mut one = [0u8; 1]; + stdout.read_exact(&mut one).unwrap(); + drop(stdout); + let mut err = String::new(); + child + .stderr + .take() + .unwrap() + .read_to_string(&mut err) + .unwrap(); + let status = child.wait().unwrap(); + assert!(!err.contains("panicked"), "{err}"); + assert!(!err.contains("failed printing"), "{err}"); + assert_ne!(status.code(), Some(101), "{err}"); +} diff --git a/crates/exav-unpack/tests/fixtures/7z/aes_40_folders_2p19.7z b/crates/exav-unpack/tests/fixtures/7z/aes_40_folders_2p19.7z new file mode 100644 index 00000000..878ca5d4 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/7z/aes_40_folders_2p19.7z differ diff --git a/crates/exav-unpack/tests/fixtures/7z/many_entries.7z b/crates/exav-unpack/tests/fixtures/7z/many_entries.7z new file mode 100644 index 00000000..6a59bcd4 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/7z/many_entries.7z differ diff --git a/crates/exav-unpack/tests/fixtures/README.md b/crates/exav-unpack/tests/fixtures/README.md index 7d1dd2e2..7cd93946 100644 --- a/crates/exav-unpack/tests/fixtures/README.md +++ b/crates/exav-unpack/tests/fixtures/README.md @@ -33,9 +33,10 @@ in archives would make the ZIP and 7z tests depend on working ZIP and decryption support to load their own inputs, and the `--no-default-features` build has neither compiled in. -`scripts/release.sh` re-checks the whole tracked tree with `clamscan` when a -signature database is present, so a fixture committed in the clear is caught -before it ships. +`scripts/release.sh` greps the tracked tree for the EICAR string on every run. +`make av-audit` scans it with `clamscan` and a signature database, which needs +both installed, so it is run when fixtures change; `docs/RELEASING.md` also +has the release build scanned over the tree. ## Real malware is never committed @@ -54,6 +55,18 @@ locally. Those are: That needs the `zip` and `decrypt` features; a build without them, like a checkout without the samples, skips the tests that use them. +## Written by a script + +`dmg/udif_lzfse.dmg` is `dmg/hfs_plus_udrw.dmg`'s disk in two LZFSE runs (sectors +0 to 8, then the rest): a UDIF image with a `koly` trailer and a block map +around the streams the `lzfse_rust` crate (an LZFSE encoder, MIT) writes, one +LZVN block and FSE blocks. `tests/suites/lzfse.rs` reads it back against the +raw image. + +`tests/oracle/` holds the C wrappers and `build.sh` for the differential tests +of the LZFSE and DCL decoders against their reference sources, which are +fetched, never committed (`src/formats/lzfse.rs`, `src/formats/dcl_explode.rs`). + To add one (`pip install pyzipper`): ```python diff --git a/crates/exav-unpack/tests/fixtures/diskimage/fat4g.deflate.qcow2.gz.xor b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.deflate.qcow2.gz.xor new file mode 100644 index 00000000..6509f53f Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.deflate.qcow2.gz.xor differ diff --git a/crates/exav-unpack/tests/fixtures/diskimage/fat4g.dynamic.vhd.gz.xor b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.dynamic.vhd.gz.xor new file mode 100644 index 00000000..f93286bf Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.dynamic.vhd.gz.xor differ diff --git a/crates/exav-unpack/tests/fixtures/diskimage/fat4g.dynamic.vhdx.gz.xor b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.dynamic.vhdx.gz.xor new file mode 100644 index 00000000..5f1b8380 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.dynamic.vhdx.gz.xor differ diff --git a/crates/exav-unpack/tests/fixtures/diskimage/fat4g.plain.qcow2.gz.xor b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.plain.qcow2.gz.xor new file mode 100644 index 00000000..330d3c0f Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.plain.qcow2.gz.xor differ diff --git a/crates/exav-unpack/tests/fixtures/diskimage/fat4g.sparse.vmdk.gz.xor b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.sparse.vmdk.gz.xor new file mode 100644 index 00000000..5bb23403 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.sparse.vmdk.gz.xor differ diff --git a/crates/exav-unpack/tests/fixtures/diskimage/fat4g.stream.vmdk.gz.xor b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.stream.vmdk.gz.xor new file mode 100644 index 00000000..f6cac4db Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.stream.vmdk.gz.xor differ diff --git a/crates/exav-unpack/tests/fixtures/diskimage/fat4g.zstd.qcow2.gz.xor b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.zstd.qcow2.gz.xor new file mode 100644 index 00000000..a139e2e2 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/diskimage/fat4g.zstd.qcow2.gz.xor differ diff --git a/crates/exav-unpack/tests/fixtures/dmg/udif_lzfse.dmg b/crates/exav-unpack/tests/fixtures/dmg/udif_lzfse.dmg new file mode 100644 index 00000000..9ee74cb4 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/dmg/udif_lzfse.dmg differ diff --git a/crates/exav-unpack/tests/fixtures/encrypted/mhe_lzma.7z b/crates/exav-unpack/tests/fixtures/encrypted/mhe_lzma.7z new file mode 100644 index 00000000..8780118c Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/encrypted/mhe_lzma.7z differ diff --git a/crates/exav-unpack/tests/fixtures/encrypted/mhe_lzma2.7z b/crates/exav-unpack/tests/fixtures/encrypted/mhe_lzma2.7z new file mode 100644 index 00000000..54a56d4a Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/encrypted/mhe_lzma2.7z differ diff --git a/crates/exav-unpack/tests/fixtures/encrypted/mhe_sevenz_rust2.7z b/crates/exav-unpack/tests/fixtures/encrypted/mhe_sevenz_rust2.7z new file mode 100644 index 00000000..43436c66 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/encrypted/mhe_sevenz_rust2.7z differ diff --git a/crates/exav-unpack/tests/fixtures/fs/cramfs.img.gz b/crates/exav-unpack/tests/fixtures/fs/cramfs.img.gz new file mode 100644 index 00000000..b341f1cb Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/cramfs.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/cramfs_big_endian.img.gz b/crates/exav-unpack/tests/fixtures/fs/cramfs_big_endian.img.gz new file mode 100644 index 00000000..cdc3643d Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/cramfs_big_endian.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/cramfs_extended.img.gz b/crates/exav-unpack/tests/fixtures/fs/cramfs_extended.img.gz new file mode 100644 index 00000000..e6644efa Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/cramfs_extended.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/cramfs_holes.img.gz b/crates/exav-unpack/tests/fixtures/fs/cramfs_holes.img.gz new file mode 100644 index 00000000..1988f76b Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/cramfs_holes.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/cramfs_padded.img.gz b/crates/exav-unpack/tests/fixtures/fs/cramfs_padded.img.gz new file mode 100644 index 00000000..589fcfe5 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/cramfs_padded.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/erofs.img.gz b/crates/exav-unpack/tests/fixtures/fs/erofs.img.gz new file mode 100644 index 00000000..6fd6d736 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/erofs.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/erofs_chunks.img.gz b/crates/exav-unpack/tests/fixtures/fs/erofs_chunks.img.gz new file mode 100644 index 00000000..759a0145 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/erofs_chunks.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/erofs_compact_inodes.img.gz b/crates/exav-unpack/tests/fixtures/fs/erofs_compact_inodes.img.gz new file mode 100644 index 00000000..deea360b Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/erofs_compact_inodes.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/erofs_lz4.img.gz b/crates/exav-unpack/tests/fixtures/fs/erofs_lz4.img.gz new file mode 100644 index 00000000..63529f81 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/erofs_lz4.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/erofs_small_blocks.img.gz b/crates/exav-unpack/tests/fixtures/fs/erofs_small_blocks.img.gz new file mode 100644 index 00000000..ee0696ba Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/erofs_small_blocks.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/exfat.img.gz b/crates/exav-unpack/tests/fixtures/fs/exfat.img.gz new file mode 100644 index 00000000..e94f0463 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/exfat.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/exfat_small_clusters.img.gz b/crates/exav-unpack/tests/fixtures/fs/exfat_small_clusters.img.gz new file mode 100644 index 00000000..833d0fcc Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/exfat_small_clusters.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/ext2_blockmap.img.gz b/crates/exav-unpack/tests/fixtures/fs/ext2_blockmap.img.gz new file mode 100644 index 00000000..caafa286 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/ext2_blockmap.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/ext3.img.gz b/crates/exav-unpack/tests/fixtures/fs/ext3.img.gz new file mode 100644 index 00000000..69c39e48 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/ext3.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/ext4.img.gz b/crates/exav-unpack/tests/fixtures/fs/ext4.img.gz new file mode 100644 index 00000000..cd393718 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/ext4.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/ext4_bigalloc.img.gz b/crates/exav-unpack/tests/fixtures/fs/ext4_bigalloc.img.gz new file mode 100644 index 00000000..2ce2bddb Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/ext4_bigalloc.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/ext4_inline.img.gz b/crates/exav-unpack/tests/fixtures/fs/ext4_inline.img.gz new file mode 100644 index 00000000..c24c58fa Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/ext4_inline.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/ext4_kernel.img.gz b/crates/exav-unpack/tests/fixtures/fs/ext4_kernel.img.gz new file mode 100644 index 00000000..ff60d728 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/ext4_kernel.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/ext4_metabg.img.gz b/crates/exav-unpack/tests/fixtures/fs/ext4_metabg.img.gz new file mode 100644 index 00000000..88dce6d4 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/ext4_metabg.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/extras.py b/crates/exav-unpack/tests/fixtures/fs/extras.py new file mode 100644 index 00000000..30e4804d --- /dev/null +++ b/crates/exav-unpack/tests/fixtures/fs/extras.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Write, in the current directory, what only a mounted filesystem makes. + + extras.py [--trunc-only] + +Preallocated space that was written in part, a hole punched in a written file, +a file written across the holes of deleted ones, a file longer than what was +written to it, and a directory that lost a third of its entries. The matching +list is `extras` in `tests/suites/fs_tree.rs`. +""" +import ctypes +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from tree import pat # noqa: E402 + +KEEP_SIZE, PUNCH_HOLE = 1, 2 + + +def main(): + os.mkdir("k") + # Blocks that hold something when they are preallocated, to be read as zeros. + with open("k/stale", "wb") as f: + f.write(b"stale" * (1 << 18)) + f.flush() + os.fsync(f.fileno()) + os.unlink("k/stale") + os.sync() + # A file longer than what was written to it. Only this, for a filesystem + # that keeps the length written apart (exFAT) and has no holes or + # preallocation to speak of. + with open("k/trunc.bin", "wb") as f: + f.write(b"abc") + f.truncate(100000) + if "--trunc-only" in sys.argv: + return + fd = os.open("k/prealloc.bin", os.O_CREAT | os.O_WRONLY) + os.posix_fallocate(fd, 0, 1 << 20) + os.pwrite(fd, pat(3000, 5000), 300000) + os.close(fd) + + with open("k/holey.bin", "wb") as f: + f.write(pat(3001, 1 << 20)) + fd = os.open("k/holey.bin", os.O_WRONLY) + libc = ctypes.CDLL(None, use_errno=True) + libc.fallocate(fd, PUNCH_HOLE | KEEP_SIZE, ctypes.c_long(100000), ctypes.c_long(300000)) + os.close(fd) + + os.mkdir("k/d") + for i in range(300): + with open("k/d/f%d" % i, "wb") as f: + f.write(b"x" * i) + for i in range(0, 300, 3): + os.unlink("k/d/f%d" % i) + + +if __name__ == "__main__": + main() diff --git a/crates/exav-unpack/tests/fixtures/fs/fat12.img.gz b/crates/exav-unpack/tests/fixtures/fs/fat12.img.gz new file mode 100644 index 00000000..644dc963 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/fat12.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/fat16.img.gz b/crates/exav-unpack/tests/fixtures/fs/fat16.img.gz new file mode 100644 index 00000000..5bc70706 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/fat16.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/fat16_kernel.img.gz b/crates/exav-unpack/tests/fixtures/fs/fat16_kernel.img.gz new file mode 100644 index 00000000..d824eff4 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/fat16_kernel.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/fat32.img.gz b/crates/exav-unpack/tests/fixtures/fs/fat32.img.gz new file mode 100644 index 00000000..097adc28 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/fat32.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/lzo_text_1.bin b/crates/exav-unpack/tests/fixtures/fs/lzo_text_1.bin new file mode 100644 index 00000000..252cb31b Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/lzo_text_1.bin differ diff --git a/crates/exav-unpack/tests/fixtures/fs/lzo_text_9.bin b/crates/exav-unpack/tests/fixtures/fs/lzo_text_9.bin new file mode 100644 index 00000000..6b4215a1 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/lzo_text_9.bin differ diff --git a/crates/exav-unpack/tests/fixtures/fs/make.sh b/crates/exav-unpack/tests/fixtures/fs/make.sh new file mode 100644 index 00000000..e156f4d2 --- /dev/null +++ b/crates/exav-unpack/tests/fixtures/fs/make.sh @@ -0,0 +1,178 @@ +#!/bin/bash +# Build the filesystem images of this directory. +# +# bash make.sh [ext] [fat] [exfat] [squashfs] [cramfs] [romfs] [erofs] +# +# Each image holds the tree of tree.py (the suites compare a walk with it), and +# is kept gzipped, a filesystem being mostly empty space. The tools are +# e2fsprogs, mtools, exfatprogs, squashfs-tools, erofs-utils, genromfs and +# util-linux's mkfs.cramfs, and `sudo` for the images a kernel writes (a loop +# mount): those are marked. +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" +WORK="$(mktemp -d)" +trap 'sudo umount "$WORK/mnt" 2>/dev/null || true; rm -rf "$WORK"' EXIT + +tree() { python3 "$DIR/tree.py" "$1" "${@:2}"; } +keep() { gzip -9 -n -c "$1" > "$2.img.gz"; } + +# A volume a kernel wrote: pads written and every other one deleted, so the +# files that follow land in the holes, then the tree and extras.py's files. +# $1 name, $2 mkfs command (IMG stands for the image), $3 size in MiB, $4 mount +# options, $5 pad size. +kernel() { + local img="$WORK/$1.img" + truncate -s "$3M" "$img" + eval "${2//IMG/$img}" + mkdir -p "$WORK/mnt" + sudo mount -o "loop,$4" "$img" "$WORK/mnt" + sudo chown "$(id -u)" "$WORK/mnt" 2>/dev/null || true + ( + cd "$WORK/mnt" + mkdir pad + for i in $(seq 0 99); do head -c "$5" /dev/zero | tr '\0' p > "pad/$i"; done + for i in $(seq 0 2 99); do rm "pad/$i"; done + tree . + # A filesystem without holes or preallocation has no use for most of + # these (plain), or only for the file longer than it was written (trunc). + case "${6:-}" in + plain) ;; + trunc) python3 "$DIR/extras.py" --trunc-only ;; + *) python3 "$DIR/extras.py" ;; + esac + rm -rf pad + ) + sync + sudo umount "$WORK/mnt" + keep "$img" "$1" +} + +# A volume made from a directory. $1 name, $2 mkfs command (IMG, TREE), $3 size. +from_tree() { + local img="$WORK/$1.img" + rm -rf "$WORK/tree" + tree "$WORK/tree" --sparse + truncate -s "$3M" "$img" + eval "$(echo "$2" | sed "s|IMG|$img|; s|TREE|$WORK/tree|")" + keep "$img" "$1" +} + +ext() { + local mk="mke2fs -q -F -E root_owner=0:0 -d TREE" + from_tree ext4 "$mk -t ext4 -b 4096 IMG" 8 + from_tree ext2_blockmap "$mk -t ext2 -b 1024 IMG" 8 + from_tree ext3 "$mk -t ext3 -b 2048 IMG" 8 + from_tree ext4_inline "$mk -t ext4 -b 1024 -O inline_data,^has_journal IMG" 8 + from_tree ext4_metabg "$mk -t ext4 -b 1024 -g 1024 -i 65536 -O meta_bg,^resize_inode,64bit IMG" 24 + from_tree ext4_bigalloc "$mk -t ext4 -b 1024 -O bigalloc -C 8192 IMG" 8 + # Written by a kernel (sudo). + kernel ext4_kernel "mkfs.ext4 -q -F -b 1024 IMG" 8 "" 8192 +} + +# A FAT volume made with mtools. $1 name, $2 mkfs.vfat options, $3 size in +# KiB. Pads are written and every other one deleted first, so the files that +# follow land in the holes: a fragmented file has more than one run. +mtools_fat() { + local img="$WORK/$1.img" + rm -rf "$WORK/tree" + tree "$WORK/tree" + truncate -s "${3}K" "$img" + mkfs.vfat -n TESTVOL $2 "$img" > /dev/null + mkdir "$WORK/pad" + for i in $(seq 0 99); do head -c 3000 /dev/zero | tr '\0' p > "$WORK/pad/$i"; done + mcopy -i "$img" "$WORK"/pad/* ::/ + for i in $(seq 0 2 99); do mdel -i "$img" "::/$i"; done + mcopy -s -i "$img" "$WORK"/tree/* ::/ + for i in $(seq 1 2 99); do mdel -i "$img" "::/$i"; done + rm -rf "$WORK/pad" + keep "$img" "$1" +} + +fat() { + mtools_fat fat12 "-F 12" 1440 + mtools_fat fat16 "-F 16" 16384 + mtools_fat fat32 "-F 32 -s 2" 40960 + # Written by a kernel (sudo), which keeps the case of a short name in two + # flags of its entry instead of a long name. + kernel fat16_kernel "mkfs.vfat -F 16 IMG" 16 "shortname=winnt,uid=$(id -u)" 3000 plain +} + +squashfs() { + rm -rf "$WORK/tree" + tree "$WORK/tree" --sparse + # A link holds no file's bytes. + ln -s tiny.txt "$WORK/tree/link" + local c + for c in gzip xz lzo lz4 zstd lzma; do + mksquashfs "$WORK/tree" "$WORK/$c.sqsh" -comp "$c" -noappend -quiet > /dev/null + keep "$WORK/$c.sqsh" "squashfs_$c" + done + mksquashfs "$WORK/tree" "$WORK/small.sqsh" -b 4096 -noappend -quiet > /dev/null + keep "$WORK/small.sqsh" squashfs_small_blocks + mksquashfs "$WORK/tree" "$WORK/plain.sqsh" -no-compression -no-fragments -noappend -quiet > /dev/null + keep "$WORK/plain.sqsh" squashfs_uncompressed +} + +# cramfs needs `mkcramfs` (MKCRAMFS names it: github.com/npitre/cramfs-tools, +# which Linux distributions do not package any more) and `cramfsswap`. +cramfs() { + local mk="${MKCRAMFS:-mkcramfs}" + rm -rf "$WORK/tree" + tree "$WORK/tree" + # Three blocks of nothing, which -z makes holes of, and a tail. + { head -c 12288 /dev/zero; printf end; } > "$WORK/tree/zeros.bin" + "$mk" "$WORK/tree" "$WORK/plain.cramfs" > /dev/null 2>&1 + keep "$WORK/plain.cramfs" cramfs + "$mk" -p "$WORK/tree" "$WORK/padded.cramfs" > /dev/null 2>&1 + keep "$WORK/padded.cramfs" cramfs_padded + "$mk" -z "$WORK/tree" "$WORK/holes.cramfs" > /dev/null 2>&1 + keep "$WORK/holes.cramfs" cramfs_holes + "$mk" -x "$WORK/tree" "$WORK/ext.cramfs" > /dev/null 2>&1 + keep "$WORK/ext.cramfs" cramfs_extended + cramfsswap "$WORK/plain.cramfs" "$WORK/be.cramfs" > /dev/null + keep "$WORK/be.cramfs" cramfs_big_endian +} + +romfs() { + rm -rf "$WORK/tree" + tree "$WORK/tree" + # An executable, a hard link to it and a symbolic link, which holds no file. + chmod +x "$WORK/tree/tiny.txt" + ln "$WORK/tree/tiny.txt" "$WORK/tree/hard" + ln -s tiny.txt "$WORK/tree/link" + genromfs -f "$WORK/plain.romfs" -d "$WORK/tree" -V TEST > /dev/null + keep "$WORK/plain.romfs" romfs + genromfs -f "$WORK/aligned.romfs" -d "$WORK/tree" -a 512 > /dev/null + keep "$WORK/aligned.romfs" romfs_aligned +} + +erofs() { + rm -rf "$WORK/tree" + tree "$WORK/tree" + # A link holds no file's bytes. + ln -s tiny.txt "$WORK/tree/link" + mkfs.erofs "$WORK/plain.erofs" "$WORK/tree" > /dev/null 2>&1 + keep "$WORK/plain.erofs" erofs + mkfs.erofs -b 1024 "$WORK/small.erofs" "$WORK/tree" > /dev/null 2>&1 + keep "$WORK/small.erofs" erofs_small_blocks + mkfs.erofs -E force-inode-compact "$WORK/compact.erofs" "$WORK/tree" > /dev/null 2>&1 + keep "$WORK/compact.erofs" erofs_compact_inodes + # Compressed: the files are reported, the directories are read. + mkfs.erofs -zlz4hc "$WORK/lz4.erofs" "$WORK/tree" > /dev/null 2>&1 + keep "$WORK/lz4.erofs" erofs_lz4 + # Files in chunks, with holes. + tree "$WORK/tree" --sparse + mkfs.erofs --chunksize 4096 "$WORK/chunks.erofs" "$WORK/tree" > /dev/null 2>&1 + keep "$WORK/chunks.erofs" erofs_chunks +} + +exfat() { + # Written by a kernel (sudo); the sparse file does not fit. + kernel exfat "mkfs.exfat -c 4096 IMG" 16 "uid=$(id -u)" 30000 trunc + kernel exfat_small_clusters "mkfs.exfat -c 512 IMG" 16 "uid=$(id -u)" 30000 trunc +} + +[ $# -eq 0 ] && set -- ext fat exfat squashfs cramfs romfs erofs +for what in "$@"; do "$what"; done +ls -l "$DIR"/*.img.gz diff --git a/crates/exav-unpack/tests/fixtures/fs/romfs.img.gz b/crates/exav-unpack/tests/fixtures/fs/romfs.img.gz new file mode 100644 index 00000000..8ab06722 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/romfs.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/romfs_aligned.img.gz b/crates/exav-unpack/tests/fixtures/fs/romfs_aligned.img.gz new file mode 100644 index 00000000..be478876 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/romfs_aligned.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/squashfs_gzip.img.gz b/crates/exav-unpack/tests/fixtures/fs/squashfs_gzip.img.gz new file mode 100644 index 00000000..f541bb4f Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/squashfs_gzip.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/squashfs_lz4.img.gz b/crates/exav-unpack/tests/fixtures/fs/squashfs_lz4.img.gz new file mode 100644 index 00000000..21fd9a6a Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/squashfs_lz4.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/squashfs_lzma.img.gz b/crates/exav-unpack/tests/fixtures/fs/squashfs_lzma.img.gz new file mode 100644 index 00000000..9a62e1d1 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/squashfs_lzma.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/squashfs_lzo.img.gz b/crates/exav-unpack/tests/fixtures/fs/squashfs_lzo.img.gz new file mode 100644 index 00000000..f3558b1d Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/squashfs_lzo.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/squashfs_small_blocks.img.gz b/crates/exav-unpack/tests/fixtures/fs/squashfs_small_blocks.img.gz new file mode 100644 index 00000000..1ecf6da9 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/squashfs_small_blocks.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/squashfs_uncompressed.img.gz b/crates/exav-unpack/tests/fixtures/fs/squashfs_uncompressed.img.gz new file mode 100644 index 00000000..41a002bd Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/squashfs_uncompressed.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/squashfs_xz.img.gz b/crates/exav-unpack/tests/fixtures/fs/squashfs_xz.img.gz new file mode 100644 index 00000000..6bfefeec Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/squashfs_xz.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/squashfs_zstd.img.gz b/crates/exav-unpack/tests/fixtures/fs/squashfs_zstd.img.gz new file mode 100644 index 00000000..a77b2b48 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/fs/squashfs_zstd.img.gz differ diff --git a/crates/exav-unpack/tests/fixtures/fs/tree.py b/crates/exav-unpack/tests/fixtures/fs/tree.py new file mode 100644 index 00000000..68a4305f --- /dev/null +++ b/crates/exav-unpack/tests/fixtures/fs/tree.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Write the one tree of files every filesystem fixture holds. + + tree.py DIR [--sparse] + +The same bytes are generated by `pat` in `tests/suites/fs_tree.rs`, which is +what the filesystem suites compare a walk against. --sparse adds a file with +data at its start, in its middle and at its end, and holes between. +""" +import os +import sys + +TEXT = b"abcdefghij0123456789" + +# (path, size). The seed of a file's bytes is its position in this list. +FILES = [ + ("empty", 0), + ("tiny.txt", 10), + ("sixty.bin", 60), + ("sixtyone.bin", 61), + ("p1.bin", 1025), + ("big4k.bin", 4096), + ("a/b/c/deep.bin", 5000), + ("café 日本.txt", 300), + ("long name with spaces and a long extension.markdown", 700), + ("frag.bin", 100000), +] +MANY = 300 +SPARSE_AT = (0, 30 << 20, 70 << 20) +SPARSE_LEN = (3000, 7000, 4) + + +def pat(seed, n): + out = bytearray(n) + for i in range(n): + if (i // 64) % 2 == 0: + out[i] = ((((i * 2654435761) + seed * 40503) & 0xFFFFFFFF) >> 13) & 0xFF + else: + out[i] = TEXT[(i + seed) % 20] + return bytes(out) + + +def write(root, path, data): + p = os.path.join(root, path) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "wb") as f: + f.write(data) + + +def main(): + root = sys.argv[1] + for seed, (path, size) in enumerate(FILES): + write(root, path, pat(seed, size)) + for i in range(MANY): + write(root, "many/file_%03d.dat" % i, pat(1000 + i, i % 97)) + os.makedirs(os.path.join(root, "emptydir"), exist_ok=True) + if "--sparse" in sys.argv: + with open(os.path.join(root, "sparse.bin"), "wb") as f: + for k, (at, n) in enumerate(zip(SPARSE_AT, SPARSE_LEN)): + f.seek(at) + f.write(pat(2000 + k, n)) + + +if __name__ == "__main__": + main() diff --git a/crates/exav-unpack/tests/fixtures/lzma_simple.lzma b/crates/exav-unpack/tests/fixtures/lzma_simple.lzma new file mode 100644 index 00000000..dba8ba8d Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/lzma_simple.lzma differ diff --git a/crates/exav-unpack/tests/fixtures/lzma_sized.lzma b/crates/exav-unpack/tests/fixtures/lzma_sized.lzma new file mode 100644 index 00000000..86967e52 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/lzma_sized.lzma differ diff --git a/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar4.rar b/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar4.rar deleted file mode 100644 index 10fd3f12..00000000 Binary files a/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar4.rar and /dev/null differ diff --git a/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar4.rar.xor b/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar4.rar.xor new file mode 100644 index 00000000..c77f59e7 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar4.rar.xor differ diff --git a/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar5.rar b/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar5.rar deleted file mode 100644 index b1250ee2..00000000 Binary files a/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar5.rar and /dev/null differ diff --git a/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar5.rar.xor b/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar5.rar.xor new file mode 100644 index 00000000..f665f005 Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/rar_solid/solid_rar5.rar.xor differ diff --git a/crates/exav-unpack/tests/fixtures/rar_solid/vol_rar4.part03.rar b/crates/exav-unpack/tests/fixtures/rar_solid/vol_rar4.part03.rar deleted file mode 100644 index 63c3e6c1..00000000 Binary files a/crates/exav-unpack/tests/fixtures/rar_solid/vol_rar4.part03.rar and /dev/null differ diff --git a/crates/exav-unpack/tests/fixtures/rar_solid/vol_rar4.part03.rar.xor b/crates/exav-unpack/tests/fixtures/rar_solid/vol_rar4.part03.rar.xor new file mode 100644 index 00000000..04ccca53 --- /dev/null +++ b/crates/exav-unpack/tests/fixtures/rar_solid/vol_rar4.part03.rar.xor @@ -0,0 +1 @@ +;({@]ZC )KZWZZZZZZZg;.zÊtZZZZZZZYf• 2Ã&¡GiSZîÛZZ?39;(t957Zê¢#[Qž’çŽObèVMÛä‚å>TRötøpt}-!´1å740`u3ª”%ñsdðã”­·86†Åg»cQÔÈ^EŠjvßÊõžŽ`!TNZÒÞ~PXZZZZZZZZ \ No newline at end of file diff --git a/crates/exav-unpack/tests/fixtures/rpm/hello.rpm b/crates/exav-unpack/tests/fixtures/rpm/hello.rpm new file mode 100644 index 00000000..bc72dedc Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/rpm/hello.rpm differ diff --git a/crates/exav-unpack/tests/fixtures/rpm/make.py b/crates/exav-unpack/tests/fixtures/rpm/make.py new file mode 100644 index 00000000..24ea63cc --- /dev/null +++ b/crates/exav-unpack/tests/fixtures/rpm/make.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Builds the RPM fixtures (rpmbuild is not needed): python3 make.py + +hello.rpm: a package with install scripts and a gzip cpio payload. +eicar.rpm: the EICAR test string in the payload, which the compression hides +(mask it with XOR 0x5A as the README of this directory's parent says, and +copy it to crates/exav-core/tests/fixtures/eicar.rpm.xor). +""" +import gzip +import struct +import sys + +STRING, BIN, STRING_ARRAY = 6, 7, 8 +EICAR = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-" + b"STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n" + + +def header(entries): + """entries: (tag, type, count, bytes). The index and data of a header structure.""" + data = b"" + index = b"" + for tag, kind, count, blob in sorted(entries): + index += struct.pack(">IIII", tag, kind, len(data), count) + data += blob + return b"\x8e\xad\xe8\x01" + bytes(4) + struct.pack(">II", len(entries), len(data)) + index + data + + +def cpio(files): + out = b"" + for ino, (name, content) in enumerate(files, 1): + name = name.encode() + b"\0" + out += b"070701" + b"".join( + b"%08X" % v for v in (ino, 0o100644, 0, 0, 1, 0, len(content), 0, 0, 0, 0, len(name), 0) + ) + out += name + bytes(-(110 + len(name)) % 4) + content + bytes(-len(content) % 4) + trailer = b"TRAILER!!!\0" + out += b"070701" + b"".join(b"%08X" % v for v in (0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, len(trailer), 0)) + return out + trailer + bytes(-(110 + len(trailer)) % 4) + + +def rpm(files, scripts): + payload = gzip.compress(cpio(files), mtime=0) + main = [ + (1000, STRING, 1, b"hello\0"), + (1124, STRING, 1, b"cpio\0"), + (1125, STRING, 1, b"gzip\0"), + ] + scripts + sig = header([(1000, BIN, 4, bytes(4))]) + sig += bytes(-len(sig) % 8) + lead = b"\xed\xab\xee\xdb" + bytes([3, 0]) + struct.pack(">HH", 0, 1) + b"hello".ljust(66, b"\0") + lead += struct.pack(">HH", 1, 5) + bytes(16) + assert len(lead) == 96 + return lead + sig + header(main) + payload + + +hello = rpm( + [("./usr/share/hello/hello.txt", b"hello from an rpm\n")], + [ + (1023, STRING, 1, b"echo prein\0"), + (1024, STRING, 1, b"#!/bin/sh\necho postin\n\0"), + (1065, STRING_ARRAY, 2, b"echo first trigger\0echo second trigger\0"), + ], +) +open("hello.rpm", "wb").write(hello) +open("eicar.rpm", "wb").write(rpm([("./usr/share/hello/eicar.com", EICAR)], [])) +print(len(hello), file=sys.stderr) diff --git a/crates/exav-unpack/tests/fixtures/xz/cut_where_xz_has_written_the_payload.xz.xor b/crates/exav-unpack/tests/fixtures/xz/cut_where_xz_has_written_the_payload.xz.xor new file mode 100644 index 00000000..ba601dfa Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/xz/cut_where_xz_has_written_the_payload.xz.xor differ diff --git a/crates/exav-unpack/tests/fixtures/xz_checks/crc32.xz b/crates/exav-unpack/tests/fixtures/xz_checks/crc32.xz new file mode 100644 index 00000000..f43e96ab Binary files /dev/null and b/crates/exav-unpack/tests/fixtures/xz_checks/crc32.xz differ diff --git a/crates/exav-unpack/tests/fixtures/xz_checks/make.py b/crates/exav-unpack/tests/fixtures/xz_checks/make.py new file mode 100644 index 00000000..27fe67d9 --- /dev/null +++ b/crates/exav-unpack/tests/fixtures/xz_checks/make.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Builds the xz fixtures with a check type the decoder does not read. + + python3 make.py # needs the xz command + +plain.bin the data every stream below decodes to (random bytes, so that + LZMA2 stores chunks, then text) +crc32.xz xz --check=crc32, three blocks (a check the decoder reads) +sha256.xz xz --check=sha256, three blocks +reserved_*.xz the same streams with a reserved check type (2, 9, 12, 15), + rebuilt from a CRC32 stream: the check bytes are filled + with 0xA5 and the index and the stream flags are rewritten + (liblzma decodes these without verifying them) +""" +import random +import struct +import subprocess +import zlib + +rng = random.Random(7) +plain = bytes(rng.randrange(256) for _ in range(6000)) + b"the quick brown fox jumps over the lazy dog\n" * 140 +open("plain.bin", "wb").write(plain) + + +def xz(check): + return subprocess.run( + ["xz", "-c", "-T1", f"--check={check}", "--block-size=4000", "-1"], + input=plain, + capture_output=True, + check=True, + ).stdout + + +def vli(n): + out = b"" + while n >= 0x80: + out += bytes([n & 0x7F | 0x80]) + n >>= 7 + return out + bytes([n]) + + +def read_vli(b, at): + v, shift = 0, 0 + while True: + byte = b[at] + at += 1 + v |= (byte & 0x7F) << shift + shift += 7 + if not byte & 0x80: + return v, at + + +def reserved(check_id): + """The CRC32 stream with check type `check_id` instead.""" + old = 4 # CRC32 + new = 4 << ((check_id - 1) // 3) + d = xz("crc32") + backward = struct.unpack(" +#include +#include +#include + +#include "blast.h" + +struct in_state { + unsigned char *p; + unsigned n; +}; + +static unsigned feed(void *how, unsigned char **buf) { + struct in_state *s = how; + unsigned n = s->n; + *buf = s->p; + s->p += n; + s->n = 0; + return n; +} + +struct out_state { + unsigned char *p; + size_t cap, len; +}; + +static int sink(void *how, unsigned char *buf, unsigned len) { + struct out_state *o = how; + size_t room = o->cap - o->len; + size_t take = len < room ? len : room; + memcpy(o->p + o->len, buf, take); + o->len += take; + return take < len; +} + +static int read_exact(void *p, size_t n) { return fread(p, 1, n, stdin) == n; } + +int main(void) { + uint32_t head[2]; + while (read_exact(head, sizeof head)) { + size_t cap = head[0], src_len = head[1]; + unsigned char *src = malloc(src_len ? src_len : 1); + unsigned char *dst = malloc(cap ? cap : 1); + if (!read_exact(src, src_len)) return 2; + struct in_state in = {src, (unsigned)src_len}; + struct out_state out = {dst, cap, 0}; + int r = blast(feed, &in, sink, &out, NULL, NULL); + uint8_t code = r == 0 ? 0 : r == 1 ? 1 : r == 2 ? 2 : 3; + uint32_t n = (uint32_t)out.len; + fwrite(&code, 1, 1, stdout); + fwrite(&n, 4, 1, stdout); + fwrite(dst, 1, n, stdout); + free(src); + free(dst); + } + fflush(stdout); + return 0; +} diff --git a/crates/exav-unpack/tests/oracle/lzfse_oracle.c b/crates/exav-unpack/tests/oracle/lzfse_oracle.c new file mode 100644 index 00000000..55e479c4 --- /dev/null +++ b/crates/exav-unpack/tests/oracle/lzfse_oracle.c @@ -0,0 +1,55 @@ +/* Differential oracle for formats/lzfse.rs: Apple's reference LZFSE decoder + * (BSD-3-Clause, github.com/lzfse/lzfse), driven through its internal state + * API so that the bytes written before a failure are reported too. + * + * Build and run: see build.sh. Optional argument: the destination fill byte. + * Input on stdin, a sequence of records: + * u32 dst_size, u32 src_len, src bytes (little endian) + * Output on stdout, one record per input record: + * u8 status, u32 out_len, out bytes + * status: 0 ok, 1 destination full, 2 source truncated, 3 error. + */ +#include +#include +#include +#include + +#include "lzfse_internal.h" + +static int read_exact(void *p, size_t n) { return fread(p, 1, n, stdin) == n; } + +int main(int argc, char **argv) { + /* The byte the destination is filled with first. The reference does not + * clear it, and a stream may make it copy from bytes it never wrote: two runs + * with different fills tell such a stream by its different output. */ + int fill = argc > 1 ? atoi(argv[1]) : 0; + lzfse_decoder_state *s = malloc(sizeof(*s)); + uint32_t head[2]; + while (read_exact(head, sizeof head)) { + size_t dst_size = head[0], src_len = head[1]; + /* Exact-size heap blocks, so a read or write past either is an ASan error. */ + uint8_t *src = malloc(src_len ? src_len : 1); + uint8_t *dst = malloc(dst_size ? dst_size : 1); + if (!read_exact(src, src_len)) return 2; + memset(dst, fill, dst_size); + memset(s, 0, sizeof(*s)); + s->src = s->src_begin = src; + s->src_end = src + src_len; + s->dst = s->dst_begin = dst; + s->dst_end = dst + dst_size; + int st = lzfse_decode(s); + uint8_t code = st == LZFSE_STATUS_OK ? 0 + : st == LZFSE_STATUS_DST_FULL ? 1 + : st == LZFSE_STATUS_SRC_EMPTY ? 2 + : 3; + uint32_t n = (uint32_t)(s->dst - dst); + fwrite(&code, 1, 1, stdout); + fwrite(&n, 4, 1, stdout); + fwrite(dst, 1, n, stdout); + free(src); + free(dst); + } + fflush(stdout); + free(s); + return 0; +} diff --git a/crates/exav-unpack/tests/saturate.rs b/crates/exav-unpack/tests/saturate.rs new file mode 100644 index 00000000..7701e571 --- /dev/null +++ b/crates/exav-unpack/tests/saturate.rs @@ -0,0 +1,334 @@ +//! Every container fixture, with each length or count in turn set to an +//! extreme. +//! +//! A container's header says how big its parts are and how many there are, and +//! the file that lies about it is the oldest trick against a reader: the size +//! becomes an allocation before a byte of the data has been seen, a count +//! becomes a loop, an offset a seek. Fuzzing finds these by chance, and an +//! over-allocation is the kind it finds worst, because asking for the memory +//! costs nothing until it is used. This sweep is the deterministic version: +//! for each fixture, at each position of its start, a field of 4 bytes is +//! overwritten with `0xFFFFFFFF`, with `0x7FFFFFFF`, and the walk is made. None +//! may allocate more than a few times the file (the allocator counts what was +//! asked for, whether or not it was used), take seconds, or panic where it +//! should have said the file is damaged. +//! +//! The quick sweep runs with the suite; `EXAV_DEBUG_SATURATE=full` takes every +//! position of the first kilobytes, with more values; `deep` takes every +//! fourth position of the first 64 KB of the fixtures up to 256 KB, where the +//! tables of the file systems and the directories of the archives are. +//! +//! A case that fails prints the fixture, the position and the value, which is +//! the whole reproduction. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use exav_unpack::{Budget, Limits, Member, detect, walk}; + +struct Counting; + +static NOW: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); +/// The largest single request this run has made. +static BIGGEST: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let p = unsafe { System.alloc(layout) }; + if !p.is_null() { + note(layout.size()); + } + p + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + let p = unsafe { System.alloc_zeroed(layout) }; + if !p.is_null() { + note(layout.size()); + } + p + } + + unsafe fn dealloc(&self, p: *mut u8, layout: Layout) { + unsafe { System.dealloc(p, layout) }; + NOW.fetch_sub(layout.size(), Ordering::Relaxed); + } + + unsafe fn realloc(&self, p: *mut u8, layout: Layout, new: usize) -> *mut u8 { + let q = unsafe { System.realloc(p, layout, new) }; + if !q.is_null() { + if new >= layout.size() { + note(new - layout.size()); + BIGGEST.fetch_max(new, Ordering::Relaxed); + } else { + NOW.fetch_sub(layout.size() - new, Ordering::Relaxed); + } + } + q + } +} + +/// Set to print where the request over [`ALLOC_BASE`] comes from. +static TRACING: AtomicBool = AtomicBool::new(false); +static IN_TRACE: AtomicBool = AtomicBool::new(false); + +fn note(size: usize) { + let now = NOW.fetch_add(size, Ordering::Relaxed) + size; + PEAK.fetch_max(now, Ordering::Relaxed); + BIGGEST.fetch_max(size, Ordering::Relaxed); + if size > REQUEST_MAX + && TRACING.load(Ordering::Relaxed) + && !IN_TRACE.swap(true, Ordering::Relaxed) + { + eprintln!( + "{} bytes asked for at:\n{}", + size, + std::backtrace::Backtrace::force_capture() + ); + IN_TRACE.store(false, Ordering::Relaxed); + } +} + +#[global_allocator] +static ALLOCATOR: Counting = Counting; + +/// What a walk may hold, over the size of the file: a decoder's window and a +/// member that really expands both need room, a header that only says so does +/// not. Measured as what the process has resident at its peak, where the +/// kernel counts memory a decoder asked for and never touched as nothing. +const RESIDENT_BASE: usize = 96 << 20; +const RESIDENT_PER_BYTE: usize = 256; +/// What a walk may ask for in one request, touched or not: past what any limit +/// of the extractor's allows, a request is a header's size taken at its word. +const REQUEST_MAX: usize = 1 << 30; +const SLOW: Duration = Duration::from_secs(4); + +fn fixtures(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + fixtures(&p, out); + } else { + out.push(p); + } + } +} + +/// The fixtures as `(name, bytes)`, masked ones unmasked, those too large to +/// sweep and those no container reader takes left out. +fn corpus() -> Vec<(String, Vec)> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let mut paths = vec![]; + fixtures(&root, &mut paths); + let mut out = vec![]; + for p in paths { + let name = p.strip_prefix(&root).unwrap().display().to_string(); + let plain = name.strip_suffix(".xor").unwrap_or(&name).to_string(); + if matches!( + plain.rsplit('.').next(), + Some("md" | "json" | "txt" | "py" | "sh" | "zip" | "gz") + ) { + continue; + } + let Ok(data) = exav_unpack::read_fixture(root.join(&plain).to_str().unwrap()) else { + continue; + }; + let largest = if mode() == "deep" { + 256 << 10 + } else { + 64 << 10 + }; + if data.len() <= largest && detect(&data).is_some() { + out.push((plain, data)); + } + } + out.dedup_by(|a, b| a.0 == b.0); + out +} + +/// `EXAV_DEBUG_SATURATE`: `full`, `deep`, or the quick sweep. +fn mode() -> String { + std::env::var("EXAV_DEBUG_SATURATE").unwrap_or_default() +} + +/// The positions to put a value at, and the values. +fn sweep() -> (Vec, Vec>) { + if mode() == "deep" { + return ( + (0..64 << 10).step_by(4).collect(), + vec![vec![0xFF; 4], vec![0xFF, 0xFF, 0xFF, 0x7F]], + ); + } + if mode() == "full" { + let mut at: Vec = (0..256).collect(); + at.extend((256..2048).step_by(2)); + at.extend((2048..8192).step_by(8)); + let values = vec![ + vec![0xFF; 4], + vec![0xFF, 0xFF, 0xFF, 0x7F], + vec![0x00, 0x00, 0x00, 0x80], + vec![0x00, 0x00, 0x00, 0x10], + vec![0xFF; 2], + vec![0xFF; 8], + ]; + (at, values) + } else { + let mut at: Vec = (0..64).collect(); + at.extend((64..512).step_by(8)); + (at, vec![vec![0xFF; 4], vec![0xFF, 0xFF, 0xFF, 0x7F]]) + } +} + +/// What came of one walk. +struct Outcome { + resident: usize, + biggest: usize, + took: Duration, + panicked: Option, +} + +/// The resident-set high-water mark in bytes, from `/proc` (Linux only). +fn high_water() -> usize { + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| { + s.lines() + .find_map(|l| l.strip_prefix("VmHWM:")) + .and_then(|v| v.trim().trim_end_matches("kB").trim().parse::().ok()) + }) + .map_or(0, |kb| kb << 10) +} + +/// Starts the high-water mark again from what is resident now. +fn reset_high_water() { + let _ = std::fs::write("/proc/self/clear_refs", "5"); +} + +fn run(fmt: exav_unpack::Format, data: &[u8]) -> Outcome { + let before = NOW.load(Ordering::Relaxed); + PEAK.store(before, Ordering::Relaxed); + BIGGEST.store(0, Ordering::Relaxed); + reset_high_water(); + let resident_before = high_water(); + let started = Instant::now(); + let mut budget = Budget::new(Limits::default()); + let mut panicked = None; + let r = walk::<()>(fmt, &data, &mut budget, &mut |_, content, _| { + match content { + Some(Member::Bytes(b)) => drop(b), + Some(Member::Stream(r)) => { + let mut sink = [0u8; 8192]; + while matches!(r.read(&mut sink), Ok(n) if n > 0) {} + } + None => {} + } + None + }); + if let Err(hit) = r + && hit.reason.contains("panicked") + { + panicked = Some(hit.reason); + } + Outcome { + resident: high_water().saturating_sub(resident_before), + biggest: BIGGEST.load(Ordering::Relaxed), + took: started.elapsed(), + panicked, + } +} + +/// `EXAV_DEBUG_SATURATE_CASE=::` walks one case and +/// prints where its large requests come from: the reproduction of a finding. +#[test] +fn one_case_traced() { + let Ok(case) = std::env::var("EXAV_DEBUG_SATURATE_CASE") else { + return; + }; + let mut parts = case.rsplitn(3, ':'); + let hex = parts.next().unwrap(); + let at: usize = parts.next().unwrap().parse().unwrap(); + let name = parts.next().unwrap(); + let value: Vec = (0..hex.len() / 2) + .map(|i| u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).unwrap()) + .collect(); + let (_, data) = corpus().into_iter().find(|(n, _)| n == name).unwrap(); + let mut mutated = data.clone(); + mutated[at..at + value.len()].copy_from_slice(&value); + TRACING.store(true, Ordering::Relaxed); + let o = run(detect(&mutated).unwrap(), &mutated); + TRACING.store(false, Ordering::Relaxed); + eprintln!("held {} MiB in {:?}", o.resident >> 20, o.took); +} + +#[test] +fn a_field_at_its_extreme_costs_nothing_like_what_it_says() { + let corpus = corpus(); + assert!(corpus.len() > 50, "only {} fixtures", corpus.len()); + let (positions, values) = sweep(); + let mut findings: Vec = vec![]; + let mut runs = 0u64; + for (name, data) in &corpus { + let fmt = detect(data).unwrap(); + let allowed = RESIDENT_BASE + RESIDENT_PER_BYTE * data.len(); + // One finding per fixture and field: the same field set to the other + // value is the same bug. + let mut seen: Vec = vec![]; + for &at in &positions { + if seen.contains(&at) { + continue; + } + for value in &values { + if at + value.len() > data.len() { + continue; + } + let mut mutated = data.clone(); + mutated[at..at + value.len()].copy_from_slice(value); + // A mutation that turns the container into another kind is + // another test: this one is about the format's own fields. + if detect(&mutated) != Some(fmt) { + continue; + } + runs += 1; + let o = run(fmt, &mutated); + let hex: String = value.iter().map(|b| format!("{b:02x}")).collect(); + if o.resident > allowed || o.biggest > REQUEST_MAX { + findings.push(format!( + "{name} ({fmt:?}): bytes {at}..{} set to {hex} held {} MiB at its peak, and asked for {} MiB at once (allowed {} MiB held, {} MiB asked)", + at + value.len(), + o.resident >> 20, + o.biggest >> 20, + allowed >> 20, + REQUEST_MAX >> 20 + )); + seen.push(at); + } else if o.took > SLOW { + findings.push(format!( + "{name} ({fmt:?}): bytes {at}..{} set to {hex} took {:.1} s", + at + value.len(), + o.took.as_secs_f64() + )); + seen.push(at); + } else if let Some(why) = o.panicked { + findings.push(format!( + "{name} ({fmt:?}): bytes {at}..{} set to {hex} panicked: {why}", + at + value.len() + )); + seen.push(at); + } + } + } + } + eprintln!( + "{} fixtures, {runs} walks, {} findings", + corpus.len(), + findings.len() + ); + assert!(findings.is_empty(), "\n{}", findings.join("\n")); +} diff --git a/crates/exav-unpack/tests/suites/allowed_formats.rs b/crates/exav-unpack/tests/suites/allowed_formats.rs index c3d625c6..2dd40cc8 100644 --- a/crates/exav-unpack/tests/suites/allowed_formats.rs +++ b/crates/exav-unpack/tests/suites/allowed_formats.rs @@ -11,7 +11,7 @@ // `extract` and `Budget` are used only by the tests that open a real container, // which are behind the `zip` feature; `Format` and `Limits` are always needed. #[allow(unused_imports)] -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; use std::collections::BTreeSet; /// A two-member ZIP, stored (no compression), so the test does not depend on a diff --git a/crates/exav-unpack/tests/suites/alz.rs b/crates/exav-unpack/tests/suites/alz.rs index bbe99775..73aa0d34 100644 --- a/crates/exav-unpack/tests/suites/alz.rs +++ b/crates/exav-unpack/tests/suites/alz.rs @@ -17,7 +17,7 @@ //! confirmed field by field: the first member's declared sizes (1,171,458 //! compressed, 1,195,080 uncompressed) and its name matched `unalz` exactly. -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; use sha2::{Digest, Sha256}; /// `(name, uncompressed size, sha256 prefix)` for every member of the fixture, as diff --git a/crates/exav-unpack/tests/suites/ar_cpio_irregular.rs b/crates/exav-unpack/tests/suites/ar_cpio_irregular.rs new file mode 100644 index 00000000..a2605206 --- /dev/null +++ b/crates/exav-unpack/tests/suites/ar_cpio_irregular.rs @@ -0,0 +1,170 @@ +//! `ar` and `cpio` archives that are a little irregular are read as far as the +//! tools that unpack them read them. +//! +//! `ar` and `dpkg-deb` take a size field that has a stray byte after its digits; +//! GNU `cpio` skips bytes that are not a header and goes on at the next one; the +//! Linux kernel unpacks the archives of an initramfs one after the other. A +//! reader that stops at the first of these shows the rest of the file to no +//! scan. + +use exav_unpack::{Budget, Entry, Format, Limits, extract}; + +fn entries(format: Format, blob: &[u8]) -> Vec { + let mut budget = Budget::new(Limits::default()); + extract(format, &blob, &mut budget).unwrap() +} + +fn names(es: &[Entry]) -> Vec<&str> { + es.iter().map(|e| e.name.as_str()).collect() +} + +// ---- ar -------------------------------------------------------------------- + +fn ar_member(name: &str, data: &[u8], size_field: Option<&[u8; 10]>) -> Vec { + let mut size = [b' '; 10]; + let digits = data.len().to_string(); + size[..digits.len()].copy_from_slice(digits.as_bytes()); + let mut h = format!( + "{:<16}{:<12}{:<6}{:<6}{:<8}", + format!("{name}/"), + 0, + 0, + 0, + 100644 + ) + .into_bytes(); + h.extend_from_slice(size_field.unwrap_or(&size)); + h.extend_from_slice(b"`\n"); + assert_eq!(h.len(), 60); + h.extend_from_slice(data); + if data.len() % 2 == 1 { + h.push(b'\n'); + } + h +} + +#[test] +fn an_ar_size_field_with_a_stray_byte_after_its_digits_is_read() { + let mut size = [b' '; 10]; + size[..2].copy_from_slice(b"11"); + size[9] = 0; + let mut ar = b"!\n".to_vec(); + ar.extend(ar_member("first.txt", b"hello world", Some(&size))); + ar.extend(ar_member("second.bin", b"the payload!", None)); + let es = entries(Format::Ar, &ar); + assert_eq!(names(&es), ["first.txt", "second.bin"], "{es:?}"); + assert_eq!(es[1].data, b"the payload!"); +} + +#[test] +fn bytes_after_an_ar_member_that_are_not_a_header_are_scanned() { + let mut ar = b"!\n".to_vec(); + ar.extend(ar_member("first.txt", b"hello world", None)); + let at = ar.len(); + ar.extend_from_slice(b"this is not a header at all, but it is in the file"); + let es = entries(Format::Ar, &ar); + let rest = es + .iter() + .find(|e| e.name == format!("")) + .expect("the rest, as a member"); + assert!(rest.data.starts_with(b"this is not a header")); +} + +#[test] +fn padding_after_the_last_ar_member_is_not_a_member() { + let mut ar = b"!\n".to_vec(); + ar.extend(ar_member("first.txt", b"hello world", None)); + ar.extend_from_slice(b"\n\n"); + let es = entries(Format::Ar, &ar); + assert_eq!(names(&es), ["first.txt"]); +} + +// ---- cpio ------------------------------------------------------------------ + +fn newc(name: &str, data: &[u8]) -> Vec { + let name_z = format!("{name}\0"); + let fields = [0, 0, 0, 0, 1, 0, data.len(), 0, 0, 0, 0, name_z.len(), 0]; + let mut out = b"070701".to_vec(); + for f in fields { + out.extend_from_slice(format!("{f:08x}").as_bytes()); + } + out.extend_from_slice(name_z.as_bytes()); + while !out.len().is_multiple_of(4) { + out.push(0); + } + out.extend_from_slice(data); + while !out.len().is_multiple_of(4) { + out.push(0); + } + out +} + +fn archive(members: &[(&str, &[u8])]) -> Vec { + let mut v: Vec = members.iter().flat_map(|(n, d)| newc(n, d)).collect(); + v.extend(newc("TRAILER!!!", b"")); + v +} + +#[test] +fn a_cpio_header_with_a_bad_digit_is_skipped_and_the_next_one_read() { + let mut cpio = archive(&[("benign.txt", b"hello"), ("payload.bin", b"the payload!")]); + // The first header's file size field. + cpio[54] = b'g'; + let es = entries(Format::Cpio, &cpio); + assert!( + es.iter() + .any(|e| e.name == "payload.bin" && e.data == b"the payload!"), + "{:?}", + names(&es) + ); + // And what was skipped was scanned. + assert!( + es.iter().any(|e| e.name.starts_with("")) + .expect("the rest"); + assert!(rest.data.starts_with(b"\x1f\x8b\x08")); +} + +#[test] +fn a_plain_cpio_with_zero_padding_is_unchanged() { + let mut cpio = archive(&[("a.txt", b"one"), ("b.txt", b"two")]); + cpio.extend_from_slice(&[0; 1024]); + let es = entries(Format::Cpio, &cpio); + assert_eq!(names(&es), ["a.txt", "b.txt"]); +} diff --git a/crates/exav-unpack/tests/suites/arc.rs b/crates/exav-unpack/tests/suites/arc.rs index 4e0f2ff0..fbc20ecb 100644 --- a/crates/exav-unpack/tests/suites/arc.rs +++ b/crates/exav-unpack/tests/suites/arc.rs @@ -19,7 +19,7 @@ //! ``` use super::extract_each; -use exav_unpack::{detect, Budget, Entry, Format, Limits}; +use exav_unpack::{Budget, Entry, Format, Limits, detect}; const EICAR: &str = "275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f"; const TEXT: &str = "080d15586f0165e6fe26ccc32060051fb67344c54691e3bca8ea7871b2c5d23f"; diff --git a/crates/exav-unpack/tests/suites/bzip2_blocks.rs b/crates/exav-unpack/tests/suites/bzip2_blocks.rs index 77b9d5c8..a1e86e8f 100644 --- a/crates/exav-unpack/tests/suites/bzip2_blocks.rs +++ b/crates/exav-unpack/tests/suites/bzip2_blocks.rs @@ -3,7 +3,7 @@ use std::io::Write; -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; /// Bytes no compressor can shrink, from a fixed seed. fn noise(len: usize) -> Vec { diff --git a/crates/exav-unpack/tests/suites/cab_quantum.rs b/crates/exav-unpack/tests/suites/cab_quantum.rs index bdfca1bb..c13bff42 100644 --- a/crates/exav-unpack/tests/suites/cab_quantum.rs +++ b/crates/exav-unpack/tests/suites/cab_quantum.rs @@ -17,7 +17,7 @@ //! outputs up to 11 KB — zero disagreements. Treat a pass here as "still //! wired up", not as "the codec is correct". -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; fn fixture() -> Vec { let p = format!( diff --git a/crates/exav-unpack/tests/suites/cab_salvage.rs b/crates/exav-unpack/tests/suites/cab_salvage.rs new file mode 100644 index 00000000..5a789d76 --- /dev/null +++ b/crates/exav-unpack/tests/suites/cab_salvage.rs @@ -0,0 +1,127 @@ +//! A cabinet cut or damaged inside a CFDATA block keeps what that block +//! decoded before the cut or the damage. +//! +//! A damaged block is reported (the compressed bytes after the damage were +//! present and never decoded); one cut by the end of the file is not (nothing +//! is hidden: the rest is absent). + +use exav_unpack::{Budget, Entry, Format, Limits, extract}; +use std::io::{Cursor, Write}; + +/// Text that deflates well, so a block holds a long run of it. +fn text(n: usize) -> Vec { + (0..n as u32) + .flat_map(|i| format!("line {} of the file {}\n", i % 977, i / 3).into_bytes()) + .take(n) + .collect() +} + +fn mszip_cab(data: &[u8]) -> Vec { + let mut builder = cab::CabinetBuilder::new(); + builder + .add_folder(cab::CompressionType::MsZip) + .add_file("data.bin"); + let mut w = builder.build(Cursor::new(Vec::new())).unwrap(); + while let Some(mut f) = w.next_file().unwrap() { + f.write_all(data).unwrap(); + } + w.finish().unwrap().into_inner() +} + +/// The offset of the first CFDATA record of a one-folder cabinet. +fn first_block(cab: &[u8]) -> usize { + u32::from_le_bytes(cab[36..40].try_into().unwrap()) as usize +} + +fn entries(cab: &[u8]) -> Vec { + extract(Format::Cab, &cab, &mut Budget::new(Limits::default())).unwrap() +} + +#[test] +fn a_block_whose_deflate_stream_ends_early_keeps_what_it_decoded_and_is_reported() { + let data = text(20_000); + let mut cab = mszip_cab(&data); + let at = first_block(&cab); + let cb = u16::from_le_bytes([cab[at + 4], cab[at + 5]]); + assert!(cb > 200); + // The record claims fewer compressed bytes than the stream needs. + cab[at + 4..at + 6].copy_from_slice(&(cb / 2).to_le_bytes()); + let e = entries(&cab); + assert_eq!(e.len(), 1); + assert!(e[0].unsupported.is_some(), "a damaged block came out whole"); + assert!( + e[0].data.len() > 1000 && data.starts_with(&e[0].data), + "the part of the block decoded before the damage is lost: {} bytes", + e[0].data.len() + ); +} + +#[test] +fn a_block_damaged_inside_its_deflate_stream_keeps_what_came_before() { + let data = text(20_000); + let cab = mszip_cab(&data); + let at = first_block(&cab); + // A reserved block type, some way into the first block's stream. The + // bits before it decode; which byte does it is found by trying. + let end = at + 8 + u16::from_le_bytes([cab[at + 4], cab[at + 5]]) as usize; + let mut kept = 0; + for k in (at + 40..end - 8).step_by(13) { + let mut bad = cab.clone(); + bad[k] = 0xff; + bad[k + 1] = 0xff; + let e = entries(&bad); + if e.len() == 1 && e[0].unsupported.is_some() && e[0].data.len() > 100 { + // The start of the block is the file's own, up to the damage. + assert!(e[0].data.starts_with(&data[..100])); + kept += 1; + } + } + assert!(kept > 0, "no damaged block kept a decoded prefix"); +} + +#[test] +fn a_cabinet_cut_inside_a_block_keeps_what_the_block_decoded() { + let data = text(20_000); + let cab = mszip_cab(&data); + let at = first_block(&cab); + let cb = u16::from_le_bytes([cab[at + 4], cab[at + 5]]) as usize; + let cut = &cab[..at + 8 + cb / 2]; + let e = extract(Format::Cab, &cut, &mut Budget::new(Limits::default())); + let e = e.expect("a cut cabinet is still walked"); + assert_eq!(e.len(), 1); + assert!( + e[0].data.len() > 1000 && data.starts_with(&e[0].data), + "the part of the block present is lost: {} bytes", + e[0].data.len() + ); + // The rest of the block is absent, not hidden: not a damaged member. + assert!(e[0].unsupported.is_none(), "{:?}", e[0].unsupported); +} + +#[test] +fn a_whole_cabinet_still_decodes_to_the_file() { + let data = text(150_000); + let cab = mszip_cab(&data); + let e = entries(&cab); + assert_eq!(e.len(), 1); + assert!(e[0].unsupported.is_none()); + assert_eq!(e[0].data, data); +} + +#[test] +fn a_second_block_damaged_keeps_the_first_whole_and_part_of_the_second() { + let data = text(150_000); + let mut cab = mszip_cab(&data); + let mut at = first_block(&cab); + at += 8 + u16::from_le_bytes([cab[at + 4], cab[at + 5]]) as usize; + let cb = u16::from_le_bytes([cab[at + 4], cab[at + 5]]); + let first = 32768usize; + cab[at + 4..at + 6].copy_from_slice(&(cb / 2).to_le_bytes()); + let e = entries(&cab); + assert!(e[0].unsupported.is_some()); + assert!( + e[0].data.len() > first + 1000 && data.starts_with(&e[0].data), + "{} bytes", + e[0].data.len() + ); +} diff --git a/crates/exav-unpack/tests/suites/cab_volumes.rs b/crates/exav-unpack/tests/suites/cab_volumes.rs new file mode 100644 index 00000000..11b826d3 --- /dev/null +++ b/crates/exav-unpack/tests/suites/cab_volumes.rs @@ -0,0 +1,549 @@ +//! Cabinets cut across several `.cab` files, put back into the one they were +//! cut from. +//! +//! The layout of a cut set is the format specification's, and was checked +//! against `cabextract`: sets built to it as `build` does are read by it as the +//! files they were made from, and a set whose first cabinet ends on a whole +//! block (not one cut short, `cbUncomp` zero) is refused by it as damaged. The +//! oracle here is the plaintext each set was built from; the MSZIP blocks are +//! real ones, written by the `cab` crate and cut apart here. + +use super::extract_each; +use exav_unpack::{Budget, Entry, Format, Limits, cab_links, join_cab_volumes}; + +struct FileSpec { + size: u32, + offset: u32, + /// `iFolder`: a folder, or 0xFFFD (from the cabinet before), 0xFFFE (to the + /// next), 0xFFFF (both). + place: u16, + name: &'static str, +} + +/// One folder: its compression, and its blocks as (cbUncomp, payload). +struct FolderSpec { + kind: u16, + blocks: Vec<(u16, Vec)>, +} + +struct Set<'a> { + prev: Option<&'a str>, + next: Option<&'a str>, + id: u16, + index: u16, +} + +fn build(files: &[FileSpec], folders: &[FolderSpec], set: Set) -> Vec { + let flags = u16::from(set.prev.is_some()) | (u16::from(set.next.is_some()) << 1); + let mut links = Vec::new(); + for name in [set.prev, set.next].into_iter().flatten() { + links.extend_from_slice(name.as_bytes()); + links.extend_from_slice(b"\0disk\0"); + } + let mut file_records = Vec::new(); + for f in files { + file_records.extend_from_slice(&f.size.to_le_bytes()); + file_records.extend_from_slice(&f.offset.to_le_bytes()); + file_records.extend_from_slice(&f.place.to_le_bytes()); + file_records.extend_from_slice(&[0x21, 0x5a, 0x00, 0x60, 0x20, 0x00]); + file_records.extend_from_slice(f.name.as_bytes()); + file_records.push(0); + } + let coff_files = 36 + links.len() + 8 * folders.len(); + let data_at = coff_files + file_records.len(); + let (mut records, mut data) = (Vec::new(), Vec::new()); + for f in folders { + records.extend_from_slice(&((data_at + data.len()) as u32).to_le_bytes()); + records.extend_from_slice(&(f.blocks.len() as u16).to_le_bytes()); + records.extend_from_slice(&f.kind.to_le_bytes()); + for (uncompressed, payload) in &f.blocks { + data.extend_from_slice(&0u32.to_le_bytes()); + data.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + data.extend_from_slice(&uncompressed.to_le_bytes()); + data.extend_from_slice(payload); + } + } + let mut out = b"MSCF".to_vec(); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&((data_at + data.len()) as u32).to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&(coff_files as u32).to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&[3, 1]); + for v in [ + folders.len() as u16, + files.len() as u16, + flags, + set.id, + set.index, + ] { + out.extend_from_slice(&v.to_le_bytes()); + } + out.extend(links); + out.extend(records); + out.extend(file_records); + out.extend(data); + out +} + +fn members(cab: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut budget = Budget::new(Limits::default()); + let _ = extract_each( + Format::Cab, + cab, + &mut budget, + &mut |e: Entry, _: &mut Budget| { + out.push(e); + None::<()> + }, + ); + out +} + +fn named<'a>(entries: &'a [Entry], name: &str) -> &'a Entry { + entries.iter().find(|e| e.name == name).unwrap_or_else(|| { + panic!( + "{name} in {:?}", + entries.iter().map(|e| &e.name).collect::>() + ) + }) +} + +fn noise(seed: u32, n: usize) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + (x >> 11) as u8 + }) + .collect() +} + +/// Two files of one folder of three blocks, the second file running from +/// the first cabinet into the second, with the third block cut in two. +fn two_part_set() -> (Vec, Vec, Vec, Vec) { + let (f1, f2) = (noise(1, 3000), noise(2, 2000)); + let stream = [f1.clone(), f2.clone()].concat(); + let one = build( + &[ + FileSpec { + size: 3000, + offset: 0, + place: 0, + name: "f1.bin", + }, + FileSpec { + size: 2000, + offset: 3000, + place: 0xFFFE, + name: "f2.bin", + }, + ], + &[FolderSpec { + kind: 0, + blocks: vec![ + (2000, stream[..2000].to_vec()), + (2000, stream[2000..4000].to_vec()), + (0, stream[4000..4500].to_vec()), + ], + }], + Set { + prev: None, + next: Some("two.cab"), + id: 7, + index: 0, + }, + ); + let two = build( + &[FileSpec { + size: 2000, + offset: 3000, + place: 0xFFFD, + name: "f2.bin", + }], + &[FolderSpec { + kind: 0, + blocks: vec![(1000, stream[4500..].to_vec())], + }], + Set { + prev: Some("one.cab"), + next: None, + id: 7, + index: 1, + }, + ); + (one, two, f1, f2) +} + +#[test] +fn a_cut_set_is_read_as_the_files_it_was_cut_from() { + let (one, two, f1, f2) = two_part_set(); + // Alone, each is cut short. + // The first reports the file it cannot read, not drops it; the second is + // refused as a whole. + let alone = members(&one); + let lost = alone + .iter() + .find(|e| e.name == "f2.bin") + .expect("the first part says nothing of f2.bin"); + assert!( + lost.unsupported + .is_some_and(|r| r.contains("another cabinet")), + "{:?}", + lost.unsupported + ); + let mut budget = Budget::new(Limits::default()); + let second = extract_each( + Format::Cab, + &two, + &mut budget, + &mut |_: Entry, _: &mut Budget| None::<()>, + ); + assert!( + second.is_err(), + "the second part, read alone, is not read as complete" + ); + let joined = join_cab_volumes(&[&one, &two]).unwrap(); + let got = members(&joined); + assert_eq!( + got.len(), + 2, + "{:?}", + got.iter().map(|e| &e.name).collect::>() + ); + assert_eq!(named(&got, "f1.bin").data, f1); + assert_eq!(named(&got, "f2.bin").data, f2); + assert!(got.iter().all(|e| e.unsupported.is_none())); +} + +#[test] +fn a_set_of_three_with_a_file_through_the_middle_one_is_joined() { + let big = noise(3, 6000); + let tail = noise(4, 500); + let stream = [big.clone(), tail.clone()].concat(); + // One block of 6500 bytes, cut at 2000 and 4000. + let one = build( + &[FileSpec { + size: 6000, + offset: 0, + place: 0xFFFE, + name: "big.bin", + }], + &[FolderSpec { + kind: 0, + blocks: vec![(0, stream[..2000].to_vec())], + }], + Set { + prev: None, + next: Some("b.cab"), + id: 1, + index: 0, + }, + ); + let two = build( + &[FileSpec { + size: 6000, + offset: 0, + place: 0xFFFF, + name: "big.bin", + }], + &[FolderSpec { + kind: 0, + blocks: vec![(0, stream[2000..4000].to_vec())], + }], + Set { + prev: Some("a.cab"), + next: Some("c.cab"), + id: 1, + index: 1, + }, + ); + let three = build( + &[ + FileSpec { + size: 6000, + offset: 0, + place: 0xFFFD, + name: "big.bin", + }, + FileSpec { + size: 500, + offset: 6000, + place: 0, + name: "tail.bin", + }, + ], + &[FolderSpec { + kind: 0, + blocks: vec![(6500, stream[4000..].to_vec())], + }], + Set { + prev: Some("b.cab"), + next: None, + id: 1, + index: 2, + }, + ); + let got = members(&join_cab_volumes(&[&one, &two, &three]).unwrap()); + assert_eq!(got.len(), 2); + assert_eq!(named(&got, "big.bin").data, big); + assert_eq!(named(&got, "tail.bin").data, tail); +} + +#[test] +fn a_folder_that_starts_in_the_next_cabinet_is_its_own() { + let (a, b) = (noise(5, 1200), noise(6, 900)); + let one = build( + &[FileSpec { + size: 1200, + offset: 0, + place: 0, + name: "a.bin", + }], + &[FolderSpec { + kind: 0, + blocks: vec![(1200, a.clone())], + }], + Set { + prev: None, + next: Some("two.cab"), + id: 2, + index: 0, + }, + ); + let two = build( + &[FileSpec { + size: 900, + offset: 0, + place: 0, + name: "b.bin", + }], + &[FolderSpec { + kind: 0, + blocks: vec![(900, b.clone())], + }], + Set { + prev: Some("one.cab"), + next: None, + id: 2, + index: 1, + }, + ); + let got = members(&join_cab_volumes(&[&one, &two]).unwrap()); + assert_eq!(named(&got, "a.bin").data, a); + assert_eq!(named(&got, "b.bin").data, b); +} + +/// The blocks MSZIP writes for `data`, as the `cab` crate writes them. +fn mszip_blocks(data: &[u8]) -> Vec<(u16, Vec)> { + use std::io::{Cursor, Write}; + let mut builder = cab::CabinetBuilder::new(); + builder + .add_folder(cab::CompressionType::MsZip) + .add_file("data.bin"); + let mut writer = builder.build(Cursor::new(Vec::new())).unwrap(); + while let Some(mut file) = writer.next_file().unwrap() { + file.write_all(data).unwrap(); + } + let bytes = writer.finish().unwrap().into_inner(); + let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]) as usize; + let u32_at = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as usize; + // One folder: its record is the first after the 36-byte header. + let (mut pos, count) = (u32_at(36), u16_at(40)); + (0..count) + .map(|_| { + let (len, uncompressed) = (u16_at(pos + 4), u16_at(pos + 6)); + let payload = bytes[pos + 8..pos + 8 + len].to_vec(); + pos += 8 + len; + (uncompressed as u16, payload) + }) + .collect() +} + +#[test] +fn an_mszip_folder_cut_across_cabinets_decodes_to_the_file() { + // Text that compresses, over several 32 KiB blocks, so that a block + // depends on the ones before it. + let data: Vec = (0..150_000u32) + .flat_map(|i| format!("line {} of the file, {}\n", i % 977, i / 3).into_bytes()) + .take(150_000) + .collect(); + let blocks = mszip_blocks(&data); + assert!(blocks.len() >= 4, "{} blocks", blocks.len()); + let cut = 2; + let (whole, rest) = blocks.split_at(cut); + let (mid_uncompressed, mid) = &rest[0]; + let half = mid.len() / 2; + let mut first: Vec<(u16, Vec)> = whole.to_vec(); + first.push((0, mid[..half].to_vec())); + let mut second: Vec<(u16, Vec)> = vec![(*mid_uncompressed, mid[half..].to_vec())]; + second.extend_from_slice(&rest[1..]); + let size = data.len() as u32; + let one = build( + &[FileSpec { + size, + offset: 0, + place: 0xFFFE, + name: "data.bin", + }], + &[FolderSpec { + kind: 1, + blocks: first, + }], + Set { + prev: None, + next: Some("part2.cab"), + id: 9, + index: 0, + }, + ); + let two = build( + &[FileSpec { + size, + offset: 0, + place: 0xFFFD, + name: "data.bin", + }], + &[FolderSpec { + kind: 1, + blocks: second, + }], + Set { + prev: Some("part1.cab"), + next: None, + id: 9, + index: 1, + }, + ); + let got = members(&join_cab_volumes(&[&one, &two]).unwrap()); + assert_eq!(got.len(), 1); + assert!(got[0].unsupported.is_none(), "{:?}", got[0].unsupported); + assert!( + got[0].data == data, + "{} bytes of {}", + got[0].data.len(), + data.len() + ); +} + +#[test] +fn a_cabinet_says_where_it_is_in_its_set() { + let (one, two, _, _) = two_part_set(); + let l1 = cab_links(&one).unwrap(); + assert_eq!( + (l1.set_id, l1.index, l1.prev.as_deref(), l1.next.as_deref()), + (7, 0, None, Some("two.cab")) + ); + let l2 = cab_links(&two).unwrap(); + assert_eq!( + (l2.set_id, l2.index, l2.prev.as_deref(), l2.next.as_deref()), + (7, 1, Some("one.cab"), None) + ); + // A cabinet that is not one of several, and what is not a cabinet. + let alone = build( + &[FileSpec { + size: 3, + offset: 0, + place: 0, + name: "x", + }], + &[FolderSpec { + kind: 0, + blocks: vec![(3, b"abc".to_vec())], + }], + Set { + prev: None, + next: None, + id: 1, + index: 0, + }, + ); + assert_eq!(cab_links(&alone), None); + assert_eq!(cab_links(b"MSCF"), None); + assert_eq!(cab_links(b"not a cabinet at all, nothing like one"), None); +} + +#[test] +fn what_does_not_fit_together_is_refused_not_joined_wrong() { + let (one, two, _, _) = two_part_set(); + assert!(join_cab_volumes(&[&one]).is_err(), "one part is not a set"); + assert!(join_cab_volumes(&[&two, &one]).is_err(), "out of order"); + assert!(join_cab_volumes(&[&one, &one]).is_err(), "a part twice"); + assert!( + join_cab_volumes(&[&one, b"MSCF\0\0\0\0"]).is_err(), + "not a cabinet" + ); + // The second of another set. + let other = { + let mut v = two.clone(); + v[32..34].copy_from_slice(&8u16.to_le_bytes()); + v + }; + assert!(join_cab_volumes(&[&one, &other]).is_err(), "another set"); + // A file continued from the cabinet before with another size than it had. + let wrong = build( + &[FileSpec { + size: 1999, + offset: 3000, + place: 0xFFFD, + name: "f2.bin", + }], + &[FolderSpec { + kind: 0, + blocks: vec![(1000, vec![0; 500])], + }], + Set { + prev: Some("one.cab"), + next: None, + id: 7, + index: 1, + }, + ); + assert!( + join_cab_volumes(&[&one, &wrong]).is_err(), + "a file that is not the one that ran on" + ); + // The second cabinet without the file the first continues. + let missing = build( + &[], + &[FolderSpec { + kind: 0, + blocks: vec![(1000, vec![0; 500])], + }], + Set { + prev: Some("one.cab"), + next: None, + id: 7, + index: 1, + }, + ); + assert!(join_cab_volumes(&[&one, &missing]).is_err(), "a lost file"); + // A third that is not there: the second says another follows. + let (a, b, _, _) = two_part_set(); + let mut open = b.clone(); + open[30..32].copy_from_slice(&3u16.to_le_bytes()); + let _ = a; + assert!(join_cab_volumes(&[&one, &open]).is_err() || cab_links(&open).is_some()); +} + +#[test] +fn damaged_cabinets_of_a_set_are_errors_not_panics() { + let (one, two, _, _) = two_part_set(); + for at in 0..one.len().min(two.len()) { + for v in [0x00u8, 0x7F, 0xFF] { + let (mut a, mut b) = (one.clone(), two.clone()); + a[at] = v; + b[at] = v; + let _ = join_cab_volumes(&[&a, &two]); + let _ = join_cab_volumes(&[&one, &b]); + let _ = cab_links(&a); + } + } + for cut in 0..one.len() { + let _ = join_cab_volumes(&[&one[..cut], &two]); + let _ = join_cab_volumes(&[&one, &two[..cut.min(two.len())]]); + } +} diff --git a/crates/exav-unpack/tests/suites/chm.rs b/crates/exav-unpack/tests/suites/chm.rs index a9c9a641..c073b507 100644 --- a/crates/exav-unpack/tests/suites/chm.rs +++ b/crates/exav-unpack/tests/suites/chm.rs @@ -11,7 +11,7 @@ //! The tests read them at runtime and **skip when absent** (a fresh clone / //! CI), adding extra real-world hostile-input robustness for local runs. -use exav_unpack::{detect, extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, detect, extract}; /// The committed, benign LZX oracle. Must always be present. const BENIGN_LZX: &str = "benign-lzx.chm"; diff --git a/crates/exav-unpack/tests/suites/cramfs.rs b/crates/exav-unpack/tests/suites/cramfs.rs new file mode 100644 index 00000000..907c68c8 --- /dev/null +++ b/crates/exav-unpack/tests/suites/cramfs.rs @@ -0,0 +1,75 @@ +//! cramfs: reading the filesystem, a block at a time. +//! +//! The images are what `mkcramfs` writes for the tree of `tree.py` and a file of +//! three blocks of zeros and a tail (`fixtures/fs/make.sh`): plain, padded by 512 +//! bytes for boot code, with the empty blocks made holes, with extended block +//! pointers, and byte-swapped, as an image of a big-endian machine is. + +use super::fs_tree::{assert_streams_alike, assert_tree, image, members, tree, walked}; +use exav_unpack::{Format, Limits, detect}; + +const IMAGES: [&str; 5] = [ + "cramfs", + "cramfs_padded", + "cramfs_holes", + "cramfs_extended", + "cramfs_big_endian", +]; + +fn want() -> Vec<(String, Vec)> { + let mut want = tree(false); + let mut zeros = vec![0u8; 12288]; + zeros.extend_from_slice(b"end"); + want.push(("zeros.bin".to_string(), zeros)); + want +} + +#[test] +fn a_cramfs_image_is_recognised() { + for name in IMAGES { + assert_eq!(detect(&image(name)), Some(Format::Cramfs), "{name}"); + } +} + +#[test] +fn every_kind_of_image_reads_the_same_tree() { + for name in IMAGES { + let e = members(Format::Cramfs, &image(name)); + std::panic::catch_unwind(|| assert_tree(&e, &want())).unwrap_or_else(|_| panic!("{name}")); + } +} + +#[test] +fn files_over_the_buffer_limit_are_read_as_they_go() { + for name in IMAGES { + assert_streams_alike(Format::Cramfs, &image(name), 1000); + } +} + +#[test] +fn an_image_cut_short_is_reported() { + let img = image("cramfs"); + let (e, end) = walked(Format::Cramfs, &img[..100], Limits::default()); + assert!(e.is_empty() && end.is_err()); + let (e, end) = walked(Format::Cramfs, &img[..img.len() / 2], Limits::default()); + assert!(end.is_err() || e.iter().any(|x| x.unsupported.is_some())); +} + +/// A block that does not inflate is a file reported with what came before it. +#[test] +fn a_block_that_does_not_inflate_is_a_file_reported_with_what_precedes_it() { + let mut img = image("cramfs"); + let at = img.len() - 3000; + for b in &mut img[at..at + 1000] { + *b ^= 0x5A; + } + let (e, _) = walked(Format::Cramfs, &img, Limits::default()); + assert!( + e.iter().any(|x| x.unsupported.is_some()), + "nothing was reported" + ); + assert!( + e.iter().any(|x| x.unsupported.is_none()), + "nothing else was read" + ); +} diff --git a/crates/exav-unpack/tests/suites/cut_streams.rs b/crates/exav-unpack/tests/suites/cut_streams.rs new file mode 100644 index 00000000..9c52f51b --- /dev/null +++ b/crates/exav-unpack/tests/suites/cut_streams.rs @@ -0,0 +1,134 @@ +//! A stream cut short gives up what the reference decoder gives up. +//! +//! `bzip2 -dc`, `xz -dc` and Python's `bz2` and `lzma` write every byte they can +//! decode from a stream whose end is missing, then report the cut. An extractor +//! that holds back more than they do shows a scan less than the file has: the +//! payload at the end of a stream is the one a truncation puts at the cut. + +use exav_unpack::{Budget, Entry, Format, Limits, extract}; +use std::io::Write; + +/// A text that compresses, so the string is not in the compressed bytes. +fn payload() -> Vec { + let mut v = b"filler to put the payload at the end ".repeat(30); + v.extend([exav_unpack::eicar(), b"\n"].concat().repeat(8)); + v +} + +fn decoded(format: Format, blob: &[u8]) -> Vec { + let mut budget = Budget::new(Limits::default()); + match extract(format, &blob, &mut budget) { + Ok(entries) => entries.into_iter().flat_map(|e: Entry| e.data).collect(), + Err(_) => Vec::new(), + } +} + +fn finds_the_payload(out: &[u8]) -> bool { + let eicar = exav_unpack::eicar(); + out.windows(eicar.len()).any(|w| w == eicar) +} + +fn bzip2(data: &[u8]) -> Vec { + let mut enc = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::new(9)); + enc.write_all(data).unwrap(); + enc.finish().unwrap() +} + +#[test] +fn a_bzip2_stream_cut_inside_its_trailer_still_gives_its_last_block() { + let data = payload(); + let bz = bzip2(&data); + assert!(!bz.windows(68).any(|w| w == exav_unpack::eicar())); + assert!( + finds_the_payload(&decoded(Format::Bzip2, &bz)), + "the whole stream" + ); + // The trailer is ten bytes: the end magic and the combined checksum. + for cut in 1..=9 { + let out = decoded(Format::Bzip2, &bz[..bz.len() - cut]); + assert!( + finds_the_payload(&out), + "cut {cut} bytes short: {} bytes out", + out.len() + ); + } +} + +#[test] +fn a_later_bzip2_stream_that_is_cut_short_is_scanned_as_far_as_it_goes() { + let benign = bzip2(&b"nothing to see here ".repeat(50)); + let mut cut = bzip2(&payload()); + cut.truncate(cut.len() - 5); + let both = [benign.as_slice(), &cut].concat(); + assert!( + finds_the_payload(&decoded(Format::Bzip2, &both)), + "the second stream was read as far as the first" + ); + // Whole, the two streams give both. + let whole = [benign.as_slice(), &bzip2(&payload())].concat(); + assert!(finds_the_payload(&decoded(Format::Bzip2, &whole))); +} + +fn xz(data: &[u8]) -> Vec { + let mut w = + lzma_rust2::XzWriter::new(Vec::new(), lzma_rust2::XzOptions::with_preset(6)).unwrap(); + w.write_all(data).unwrap(); + w.finish().unwrap() +} + +/// Words, so that the stream is compressed and its end carries real symbols, +/// and the string at the very end. +fn words_then_payload() -> Vec { + let mut x: u32 = 0x1234_5678; + let mut v = Vec::new(); + while v.len() < 30_000 { + for _ in 0..(3 + x % 7) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + v.push(b'a' + (x % 16) as u8); + } + v.push(b' '); + } + v.extend_from_slice(exav_unpack::eicar()); + v +} + +/// The decoder reads ahead of what it writes, so the last bytes it could decode +/// from what it was given wait in it for input that is not there; `xz -dc` and +/// Python's `lzma` write them. This stream is cut at the shortest length at +/// which they have written the whole of the string at its end (generated with +/// Python: 30,000 bytes of words, then the string, `lzma.compress`, then a +/// binary search for the cut). +#[test] +fn an_xz_stream_cut_gives_what_xz_writes() { + // Masked: a scanner that reads a cut stream as `xz -dc` does finds the + // string in it. + let blob = exav_unpack::read_fixture(&format!( + "{}/tests/fixtures/xz/cut_where_xz_has_written_the_payload.xz", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + let out = decoded(Format::Xz, &blob); + assert!(finds_the_payload(&out), "{} bytes out", out.len()); +} + +/// A cut anywhere gives a prefix of the whole, and at least what a +/// straightforward decoder gives. +#[test] +fn an_xz_stream_cut_anywhere_gives_a_prefix_of_the_whole() { + let data = words_then_payload(); + let blob = xz(&data); + assert!( + finds_the_payload(&decoded(Format::Xz, &blob)), + "the whole stream" + ); + for cut in 1..=400 { + let got = decoded(Format::Xz, &blob[..blob.len() - cut]); + assert!( + data.starts_with(&got), + "cut {cut}: {} bytes that are not the stream's", + got.len() + ); + } +} diff --git a/crates/exav-unpack/tests/suites/decrypt_disabled.rs b/crates/exav-unpack/tests/suites/decrypt_disabled.rs index 7b83489d..9093b10c 100644 --- a/crates/exav-unpack/tests/suites/decrypt_disabled.rs +++ b/crates/exav-unpack/tests/suites/decrypt_disabled.rs @@ -2,7 +2,7 @@ //! never decrypted (the cipher stack isn't compiled in) — the same as when no //! password is supplied, and never a silent clean. Compiled/run only in a //! no-decrypt build: `cargo test -p exav-unpack --no-default-features --features zip`. -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; #[test] fn encrypted_zip_is_unsupported_without_decrypt() { @@ -15,7 +15,9 @@ fn encrypted_zip_is_unsupported_without_decrypt() { let mut budget = Budget::with_passwords(Limits::default(), vec!["exavtest".into()]); let entries = extract(Format::Zip, &data, &mut budget).unwrap(); assert!( - entries.iter().any(|e| e.encrypted && e.unsupported.is_some()), + entries + .iter() + .any(|e| e.encrypted && e.unsupported.is_some()), "encrypted member must be reported unsupported (not decrypted) without the decrypt feature", ); // And never handed back as decrypted plaintext. diff --git a/crates/exav-unpack/tests/suites/disabled_formats.rs b/crates/exav-unpack/tests/suites/disabled_formats.rs index 6a02b1ce..91a24c70 100644 --- a/crates/exav-unpack/tests/suites/disabled_formats.rs +++ b/crates/exav-unpack/tests/suites/disabled_formats.rs @@ -10,7 +10,7 @@ //! come back clean — the one outcome exav refuses. #![cfg(not(feature = "all-formats"))] -use exav_unpack::{detect, extract, Budget, Limits}; +use exav_unpack::{Budget, Limits, detect, extract}; /// `(what it is, a blob that sniffs as it)`. Magic only — enough for `detect`, /// not a valid archive, which is deliberate: recognition must not need a diff --git a/crates/exav-unpack/tests/suites/dmg.rs b/crates/exav-unpack/tests/suites/dmg.rs index 76310770..2e0e2875 100644 --- a/crates/exav-unpack/tests/suites/dmg.rs +++ b/crates/exav-unpack/tests/suites/dmg.rs @@ -3,7 +3,7 @@ //! Fixtures are generated by `fixture_gen.sh` in this directory. //! Run it on macOS: `cd tests/fixtures/dmg && bash fixture_gen.sh` -use exav_unpack::{detect, extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, detect, extract}; const MARKER: &[u8] = b"hello hfs+"; @@ -18,10 +18,10 @@ fn contains_marker(entries: &[exav_unpack::Entry]) -> bool { .any(|e| e.data.windows(MARKER.len()).any(|w| w == MARKER)) } -/// Only the data fork is read, so a file with a resource fork, which is where -/// macOS keeps a compressed file's bytes, is reported rather than scanned as -/// though the data fork were all of it. The fixture's one file is given a -/// resource fork in its catalog record. +/// A resource fork that is not a compressed file's bytes is not read, so a file +/// with one is reported rather than scanned as though the data fork were all of +/// it (which is read beside it). The fixture's one file is given a resource +/// fork in its catalog record. #[test] fn a_file_with_a_resource_fork_is_reported() { let mut blob = fixture("hfs_plus_udrw.dmg"); diff --git a/crates/exav-unpack/tests/suites/dwg.rs b/crates/exav-unpack/tests/suites/dwg.rs index 9c147421..bac0ec7d 100644 --- a/crates/exav-unpack/tests/suites/dwg.rs +++ b/crates/exav-unpack/tests/suites/dwg.rs @@ -13,8 +13,8 @@ use std::io::Read; -use exav_unpack::dwg::{looks_like_dwg, Dwg, PreviewKind, Version}; -use exav_unpack::{detect, extract, Budget, Entry, Format, Limits}; +use exav_unpack::dwg::{Dwg, PreviewKind, Version, looks_like_dwg}; +use exav_unpack::{Budget, Entry, Format, Limits, detect, extract}; /// Every version the converter writes, as the fixture directories name them. const VERSIONS: [&str; 8] = [ diff --git a/crates/exav-unpack/tests/suites/dxf.rs b/crates/exav-unpack/tests/suites/dxf.rs index c281dc80..8feb0890 100644 --- a/crates/exav-unpack/tests/suites/dxf.rs +++ b/crates/exav-unpack/tests/suites/dxf.rs @@ -11,7 +11,7 @@ use std::io::{Read, Write}; -use exav_unpack::{detect, extract, Budget, Entry, Format, Limits}; +use exav_unpack::{Budget, Entry, Format, Limits, detect, extract}; fn members(data: &[u8]) -> Vec { let mut budget = Budget::new(Limits::default()); diff --git a/crates/exav-unpack/tests/suites/email.rs b/crates/exav-unpack/tests/suites/email.rs new file mode 100644 index 00000000..5d1b57fa --- /dev/null +++ b/crates/exav-unpack/tests/suites/email.rs @@ -0,0 +1,95 @@ +//! MIME email: every part that holds bytes is a member, those of attached +//! messages included. + +use super::extract_each; +use exav_unpack::{Budget, Entry, Format, Limits}; + +/// Members as `(name, bytes)`. +type Members = Vec<(String, Vec)>; + +/// Every member of `data` read as an email, and how the walk ended. +fn members(data: &[u8]) -> (Members, Result<(), String>) { + let mut out = vec![]; + let mut budget = Budget::new(Limits::default()); + let r = extract_each( + Format::Email, + data, + &mut budget, + &mut |e: Entry, _: &mut Budget| { + out.push((e.name, e.data)); + None::<()> + }, + ); + (out, r.map(|_| ()).map_err(|e| e.reason)) +} + +const B64_X: &str = "WFhYWFhYWFg="; // XXXXXXXX +const B64_Y: &str = "WVlZWVlZWVk="; // YYYYYYYY + +fn mail(attached: &str) -> String { + format!( + "From: a@b\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"out\"\r\n\r\n\ + --out\r\nContent-Type: text/plain\r\n\r\nbody\r\n\ + --out\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\n\r\n{B64_X}\r\n\ + --out\r\nContent-Type: message/rfc822\r\n\r\n{attached}\r\n--out--\r\n" + ) +} + +fn inner() -> String { + format!( + "From: c@d\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"in\"\r\n\r\n\ + --in\r\nContent-Type: text/plain\r\n\r\ninner body\r\n\ + --in\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\n\r\n{B64_Y}\r\n--in--\r\n" + ) +} + +#[test] +fn a_part_is_a_member_decoded() { + let (m, r) = members(mail(&inner()).as_bytes()); + assert_eq!(r, Ok(())); + assert!(m.iter().any(|(_, d)| d == b"XXXXXXXX"), "{m:?}"); + assert!(m.iter().any(|(_, d)| d.starts_with(b"body")), "{m:?}"); +} + +#[test] +fn the_parts_of_an_attached_message_are_members_too() { + let (m, r) = members(mail(&inner()).as_bytes()); + assert_eq!(r, Ok(())); + let inner_attachment = m + .iter() + .find(|(_, d)| d == b"YYYYYYYY") + .unwrap_or_else(|| panic!("the attachment of the attached message was not read: {m:?}")); + assert!( + inner_attachment.0.matches('-').count() >= 2, + "{}", + inner_attachment.0 + ); + assert!(m.iter().any(|(_, d)| d.starts_with(b"inner body")), "{m:?}"); + let mut names: Vec<_> = m.iter().map(|(n, _)| n.as_str()).collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), m.len(), "names are distinct: {names:?}"); +} + +/// A message attached to a message, `levels` deep, with an attachment at the +/// bottom. +fn chain(levels: usize) -> String { + let mut body = inner(); + for i in 0..levels { + body = format!( + "From: a@b\r\nContent-Type: multipart/mixed; boundary=\"b{i}\"\r\n\r\n\ + --b{i}\r\nContent-Type: message/rfc822\r\n\r\n{body}\r\n--b{i}--\r\n" + ); + } + body +} + +#[test] +fn a_chain_of_attached_messages_is_read_to_a_bound_and_refused_past_it() { + let (m, r) = members(chain(8).as_bytes()); + assert_eq!(r, Ok(())); + assert!(m.iter().any(|(_, d)| d == b"YYYYYYYY"), "{m:?}"); + let (_, r) = members(chain(200).as_bytes()); + let why = r.expect_err("a chain this deep is not read"); + assert!(why.contains("attached to messages"), "{why}"); +} diff --git a/crates/exav-unpack/tests/suites/email_readers_agree.rs b/crates/exav-unpack/tests/suites/email_readers_agree.rs new file mode 100644 index 00000000..d5a12ffe --- /dev/null +++ b/crates/exav-unpack/tests/suites/email_readers_agree.rs @@ -0,0 +1,170 @@ +//! What a mail reader shows of a message is what exav scans. +//! +//! The parser leaves out three things a reader shows: the last part of a +//! multipart that nothing closed, a base64 part with a byte in it outside the +//! alphabet, and a file uuencoded into the text. The payload is base64 or +//! uuencoded EICAR, so the string is nowhere in the message itself. + +use exav_unpack::{Budget, Entry, Format, Limits, extract}; + +fn eicar() -> &'static [u8] { + exav_unpack::eicar() +} + +fn base64(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in data.chunks(3) { + let n = chunk + .iter() + .enumerate() + .fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for i in 0..4 { + if i <= chunk.len() { + out.push(A[(n >> (18 - 6 * i) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +/// 76 columns, as a mail client writes it. +fn wrapped(b64: &str) -> String { + b64.as_bytes() + .chunks(76) + .map(|c| String::from_utf8_lossy(c).into_owned()) + .collect::>() + .join("\r\n") +} + +fn message(body_after_headers: &str) -> Vec { + format!( + "From: a@example.org\r\nTo: b@example.org\r\nSubject: s\r\nMIME-Version: 1.0\r\n\ + Content-Type: multipart/mixed; boundary=\"b1\"\r\n\r\n{body_after_headers}" + ) + .into_bytes() +} + +fn entries(msg: &[u8]) -> Vec { + let mut budget = Budget::new(Limits::default()); + extract(Format::Email, &msg, &mut budget).unwrap() +} + +fn has_payload(entries: &[Entry]) -> bool { + entries + .iter() + .any(|e| e.data.windows(eicar().len()).any(|w| w == eicar())) +} + +fn attachment(encoded: &str) -> String { + format!( + "--b1\r\nContent-Type: text/plain\r\n\r\nSee the attachment.\r\n\ + --b1\r\nContent-Type: application/octet-stream; name=\"e.com\"\r\n\ + Content-Transfer-Encoding: base64\r\n\r\n{encoded}\r\n" + ) +} + +fn assert_hidden_from_a_byte_search(msg: &[u8]) { + assert!( + !msg.windows(eicar().len()).any(|w| w == eicar()), + "the string must not be in the message's bytes" + ); +} + +#[test] +fn a_closed_multipart_is_read() { + let msg = message(&format!( + "{}--b1--\r\n", + attachment(&wrapped(&base64(eicar()))) + )); + assert_hidden_from_a_byte_search(&msg); + assert!(has_payload(&entries(&msg))); +} + +#[test] +fn the_last_part_of_a_multipart_nothing_closed_is_read() { + let msg = message(&attachment(&wrapped(&base64(eicar())))); + assert_hidden_from_a_byte_search(&msg); + assert!(has_payload(&entries(&msg))); +} + +#[test] +fn an_inner_multipart_nothing_closed_is_read_with_the_outer_one() { + let inner = format!( + "--b1\r\nContent-Type: multipart/mixed; boundary=\"b2\"\r\n\r\n\ + --b2\r\nContent-Type: application/octet-stream\r\n\ + Content-Transfer-Encoding: base64\r\n\r\n{}\r\n", + wrapped(&base64(eicar())) + ); + let msg = message(&inner); + assert_hidden_from_a_byte_search(&msg); + assert!(has_payload(&entries(&msg))); +} + +#[test] +fn a_base64_part_with_a_byte_outside_the_alphabet_is_read_as_far_as_it_goes() { + let mut enc = wrapped(&base64(eicar())); + enc.insert(30, '!'); + let msg = message(&format!("{}--b1--\r\n", attachment(&enc))); + assert_hidden_from_a_byte_search(&msg); + assert!(has_payload(&entries(&msg))); +} + +#[cfg(feature = "uuencode")] +#[test] +fn a_file_uuencoded_into_the_text_is_read() { + fn uu(name: &str, data: &[u8]) -> String { + let enc = |v: u8| (if v == 0 { b'`' } else { v + 32 }) as char; + let mut out = format!("begin 644 {name}\n"); + for line in data.chunks(45) { + out.push(enc(line.len() as u8)); + for g in line.chunks(3) { + let b = [g[0], *g.get(1).unwrap_or(&0), *g.get(2).unwrap_or(&0)]; + out.push(enc(b[0] >> 2)); + out.push(enc((b[0] << 4 | b[1] >> 4) & 63)); + out.push(enc((b[1] << 2 | b[2] >> 6) & 63)); + out.push(enc(b[2] & 63)); + } + out.push('\n'); + } + out.push_str("`\nend\n"); + out + } + let text = format!( + "--b1\r\nContent-Type: text/plain\r\n\r\nHere is the file.\n\n{}\r\n--b1--\r\n", + uu("e.com", eicar()) + ); + let msg = message(&text); + assert_hidden_from_a_byte_search(&msg); + assert!(has_payload(&entries(&msg))); +} + +/// Each multipart has its own boundary, so a search of the message for each +/// closing delimiter reads all of it once per multipart. +#[test] +fn messages_nested_deep_with_boundaries_of_their_own_are_read_in_linear_time() { + let depth = 40_000; + let mut msg = Vec::new(); + for i in 0..depth { + msg.extend_from_slice( + format!( + "Content-Type: multipart/mixed; boundary=\"b{i}\"\r\n\r\n--b{i}\r\n\ + Content-Type: message/rfc822\r\n\r\n" + ) + .as_bytes(), + ); + } + for i in (0..depth).rev() { + msg.extend_from_slice(format!("\r\n--b{i}--\r\n").as_bytes()); + } + let start = std::time::Instant::now(); + let mut budget = Budget::new(Limits::default()); + let _ = extract(Format::Email, &msg, &mut budget); + assert!( + start.elapsed() < std::time::Duration::from_secs(3), + "{:?}", + start.elapsed() + ); +} diff --git a/crates/exav-unpack/tests/suites/encrypted_zip.rs b/crates/exav-unpack/tests/suites/encrypted_zip.rs index 19a4ff69..48ee47fd 100644 --- a/crates/exav-unpack/tests/suites/encrypted_zip.rs +++ b/crates/exav-unpack/tests/suites/encrypted_zip.rs @@ -14,7 +14,7 @@ //! member must surface as `unsupported(encrypted=true)` (→ `PasswordProtected`), //! never silently clean. -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; const EICAR_PREFIX: &[u8] = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR"; @@ -198,6 +198,50 @@ fn sevenz_encrypted_detected() { } } +/// A header-encrypted 7z whose header is AES and then LZMA (what 7-Zip's +/// `-mhe=on` and sevenz-rust2 write; the one above has no compressed layer) is +/// listed with the right password: each coder of the header's chain decodes to +/// its own size, not to the sum of them. With a wrong password it is still an +/// encrypted member and nothing else. +#[cfg(feature = "decrypt")] +#[test] +fn sevenz_header_encrypted_over_lzma_is_listed_with_the_password() { + for (name, first, count, content) in [ + // 7-Zip 25.01, `7z a -phunter2 -mhe=on` (LZMA2) and with `-m0=LZMA`. + ("mhe_lzma2.7z", "f0.txt", 5, b'x'), + ("mhe_lzma.7z", "f0.txt", 5, b'x'), + // sevenz-rust2 0.24.1's writer, 40 members. + ("mhe_sevenz_rust2.7z", "m0.txt", 40, b'A'), + ] { + let p = format!( + "{}/tests/fixtures/encrypted/{name}", + env!("CARGO_MANIFEST_DIR") + ); + let blob = std::fs::read(&p).unwrap(); + let mut budget = Budget::with_passwords(Limits::default(), vec!["hunter2".to_string()]); + let entries = extract(Format::SevenZip, &blob, &mut budget).unwrap(); + assert_eq!( + entries.len(), + count, + "{name}: {:?}", + entries.iter().map(|e| &e.name).collect::>() + ); + assert_eq!(entries[0].name, first, "{name}"); + assert!( + entries + .iter() + .all(|e| e.unsupported.is_none() && e.data == vec![content; 64]), + "{name}" + ); + let mut budget = Budget::with_passwords(Limits::default(), vec!["wrong".to_string()]); + let entries = extract(Format::SevenZip, &blob, &mut budget).unwrap(); + assert!( + entries.len() == 1 && entries[0].encrypted && entries[0].unsupported.is_some(), + "{name}" + ); + } +} + /// Verify that MSI stream names are correctly decompressed by exav-unpack. /// Reads the PowerShell MSI, extracts all streams, and checks that: /// 1. Known decompressed names appear (e.g., !_Tables, !_StringPool) diff --git a/crates/exav-unpack/tests/suites/entry_accounting.rs b/crates/exav-unpack/tests/suites/entry_accounting.rs index 26320485..418e886a 100644 --- a/crates/exav-unpack/tests/suites/entry_accounting.rs +++ b/crates/exav-unpack/tests/suites/entry_accounting.rs @@ -10,7 +10,7 @@ //! arithmetic to the shape a caller can reason about: a flat archive costs its //! member count, and nesting costs the members at each level and nothing more. -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; use std::io::Write; /// Extract with `max_members` set to `cap`, and say whether the limit fired. diff --git a/crates/exav-unpack/tests/suites/erofs.rs b/crates/exav-unpack/tests/suites/erofs.rs new file mode 100644 index 00000000..33fbfb65 --- /dev/null +++ b/crates/exav-unpack/tests/suites/erofs.rs @@ -0,0 +1,72 @@ +//! EROFS: reading the filesystem, which stores its files where an inode says. +//! +//! The images are what `mkfs.erofs` writes for the tree of `tree.py` and a link +//! (`fixtures/fs/make.sh`): with blocks of 4 KiB and of 1 KiB, with the small +//! inodes, in chunks (and the sparse file's holes), and compressed with LZ4, +//! which is reported and not read. + +use super::fs_tree::{assert_streams_alike, assert_tree, image, members, tree, walked}; +use exav_unpack::{Format, Limits, detect}; + +#[test] +fn an_erofs_image_is_recognised() { + for name in ["erofs", "erofs_chunks", "erofs_lz4"] { + assert_eq!(detect(&image(name)), Some(Format::Erofs), "{name}"); + } +} + +/// The tree comes out whole: files whose tail is kept after their inode, files +/// in whole blocks, and files in chunks with holes. +#[test] +fn the_tree_comes_out_of_every_uncompressed_layout() { + for (name, sparse) in [ + ("erofs", false), + ("erofs_small_blocks", false), + ("erofs_compact_inodes", false), + ("erofs_chunks", true), + ] { + let e = members(Format::Erofs, &image(name)); + std::panic::catch_unwind(|| assert_tree(&e, &tree(sparse))) + .unwrap_or_else(|_| panic!("{name}")); + } +} + +/// A file over the buffer limit is read as it goes in every layout: whole +/// blocks, a tail after the inode, and chunks with holes. +#[test] +fn files_over_the_buffer_limit_are_read_as_they_go() { + for name in [ + "erofs", + "erofs_small_blocks", + "erofs_compact_inodes", + "erofs_chunks", + ] { + assert_streams_alike(Format::Erofs, &image(name), 1000); + } +} + +/// A file stored compressed is reported, not read as though it were plain, and +/// the files that were stored as they are come out. +#[test] +fn a_compressed_file_is_reported_and_the_directories_are_still_read() { + let e = members(Format::Erofs, &image("erofs_lz4")); + let want = tree(false); + assert_eq!(e.len(), want.len(), "every file is a member"); + for (path, data) in &want { + let m = e.iter().find(|x| &x.name == path).unwrap(); + match m.unsupported { + None => assert_eq!(&m.data, data, "{path}"), + Some(why) => assert_eq!(why, "EROFS compressed file not read", "{path}"), + } + } + assert!(e.iter().any(|x| x.unsupported.is_some())); +} + +#[test] +fn an_image_cut_short_is_reported() { + let img = image("erofs"); + let (e, end) = walked(Format::Erofs, &img[..1100], Limits::default()); + assert!(e.is_empty() && end.is_err()); + let (e, end) = walked(Format::Erofs, &img[..img.len() / 2], Limits::default()); + assert!(end.is_err() || e.iter().any(|x| x.unsupported.is_some())); +} diff --git a/crates/exav-unpack/tests/suites/exfat.rs b/crates/exav-unpack/tests/suites/exfat.rs new file mode 100644 index 00000000..ca055199 --- /dev/null +++ b/crates/exav-unpack/tests/suites/exfat.rs @@ -0,0 +1,98 @@ +//! exFAT: reading the filesystem, not carving the clusters. +//! +//! The images are written by a kernel (`fixtures/fs/make.sh`): the tree of +//! `tree.py`, written across the holes of deleted files so that the larger ones +//! are chained and not in a row, and a file given a length longer than what was +//! written to it. Clusters of 4 KiB and of 512 bytes. + +use super::fs_tree::{assert_streams_alike, assert_tree, image, members, tree, truncated, walked}; +use exav_unpack::{Format, Limits, detect}; + +fn want() -> Vec<(String, Vec)> { + let mut want = tree(false); + want.push(truncated()); + want +} + +#[test] +fn an_exfat_volume_is_recognised() { + assert_eq!(detect(&image("exfat")), Some(Format::Exfat)); +} + +/// The tree comes out whole, the files that are chained as well as those in a +/// row, and the file that was lengthened reads as zeros past what was written, +/// though the clusters it was given held other files' bytes before. +#[test] +fn the_tree_comes_out_of_a_volume_a_kernel_wrote() { + for name in ["exfat", "exfat_small_clusters"] { + let e = members(Format::Exfat, &image(name)); + std::panic::catch_unwind(|| assert_tree(&e, &want())).unwrap_or_else(|_| panic!("{name}")); + } +} + +/// A file over the buffer limit is read as it goes: the chained ones, those in +/// a row, and the one lengthened past what was written. +#[test] +fn files_over_the_buffer_limit_are_read_as_they_go() { + for name in ["exfat", "exfat_small_clusters"] { + assert_streams_alike(Format::Exfat, &image(name), 1000); + } +} + +#[test] +fn an_image_cut_short_is_reported() { + let img = image("exfat"); + let (_, end) = walked(Format::Exfat, &img[..2048], Limits::default()); + assert!(end.unwrap_err().is_corrupt()); + // Cut where the files are, the volume opens and the files are partial. + let heap = u32::from_le_bytes(img[88..92].try_into().unwrap()) as usize * 512; + let (e, _) = walked(Format::Exfat, &img[..heap + 200 * 1024], Limits::default()); + assert!(e.iter().any(|x| x.unsupported.is_some())); +} + +/// The place of the file entry of the set named `name`. +fn set_of(img: &[u8], name: &str) -> usize { + let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + (0..img.len().saturating_sub(96)) + .step_by(32) + .find(|&p| { + img[p] == 0x85 + && img[p + 32] == 0xC0 + && img[p + 64] == 0xC1 + && usize::from(img[p + 35]) == name.len() + && img[p + 66..].starts_with(&units) + }) + .unwrap_or_else(|| panic!("no entry {name}")) +} + +/// A folder that names the folder it is in is walked once. +#[test] +fn a_folder_that_names_the_folder_it_is_in_is_walked_once() { + let mut img = image("exfat"); + let a = set_of(&img, "a"); + let b = set_of(&img, "b"); + let first: [u8; 4] = img[a + 32 + 20..a + 32 + 24].try_into().unwrap(); + img[b + 32 + 20..b + 32 + 24].copy_from_slice(&first); + let e = members(Format::Exfat, &img); + let mut want = want(); + want.retain(|(p, _)| p != "a/b/c/deep.bin"); + assert_tree(&e, &want); +} + +/// A file that is no longer said to be in a row is followed in the table, which +/// holds no chain for it: it is reported with its first cluster. +#[test] +fn a_chain_the_table_does_not_hold_is_a_file_reported_with_what_it_has() { + let mut img = image("exfat"); + let at = set_of(&img, "frag.bin") + 32; + assert_eq!(img[at + 1] & 2, 2, "the fixture's file is in a row"); + img[at + 1] &= !2; + let (e, end) = walked(Format::Exfat, &img, Limits::default()); + end.unwrap(); + let f = e.iter().find(|x| x.name == "frag.bin").unwrap(); + assert_eq!( + f.unsupported, + Some("file could not be read from the volume") + ); + assert_eq!(f.data.len(), 4096); +} diff --git a/crates/exav-unpack/tests/suites/ext.rs b/crates/exav-unpack/tests/suites/ext.rs index a6d05eb3..81bb7c78 100644 --- a/crates/exav-unpack/tests/suites/ext.rs +++ b/crates/exav-unpack/tests/suites/ext.rs @@ -36,13 +36,17 @@ //! ``` use super::extract_each; -use exav_unpack::{detect, Budget, Entry, Format, Limits}; +use super::fs_tree::{ + assert_streams_alike, assert_tree, came, extras, image, members as walk_members, pat, tree, + walked, +}; +use exav_unpack::{Budget, Entry, Format, Limits, detect}; /// `sha256sum` of the ZIP that was written in, which is what following the /// extent tree has to reproduce. const PAYLOAD: &str = "d61da8b0c3cd1d5ffa812601aae787c81a7424850c4dca15c27e593f8f92fcc8"; -fn fixture() -> Vec { +pub(super) fn fixture() -> Vec { let p = format!( "{}/tests/fixtures/ext/fragmented.img.gz", env!("CARGO_MANIFEST_DIR") @@ -158,39 +162,179 @@ fn a_corrupt_filesystem_is_reported_not_shrugged_off() { for b in &mut img[2048..8192] { *b = 0xFF; } - let e = members(&img); - assert!( - e.iter().any(|x| x.unsupported.is_some()), - "an image that cannot be walked must say so, got {:?}", - e.iter() - .map(|x| (&x.name, x.unsupported)) - .collect::>() - ); + let (e, end) = walked(Format::Ext, &img, Limits::default()); + assert!(e.is_empty()); + assert!(end.unwrap_err().is_corrupt()); } +/// A file over the buffer limit is read as it goes, and comes out as it does +/// held, in every layout of the filesystem. #[test] -fn an_image_past_the_buffer_budget_is_reported_not_skipped() { - // The reader owns its bytes, so the image is copied once. An image too - // large to copy is a gap the operator must be able to see. - let img = fixture(); - let mut limits = Limits::default(); - limits.max_buffer_bytes = 1024; - let mut b = Budget::new(limits); - let mut out = Vec::new(); - let _ = extract_each( - Format::Ext, - &img, - &mut b, - &mut |e: Entry, _: &mut Budget| { - out.push(e); - None::<()> - }, +fn files_over_the_buffer_limit_are_read_as_they_go() { + for name in [ + "ext4", + "ext2_blockmap", + "ext3", + "ext4_metabg", + "ext4_bigalloc", + ] { + assert_streams_alike(Format::Ext, &image(name), 8192); + } + let (e, end) = came(Format::Ext, &image("ext4"), 8192); + end.unwrap(); + let by = |n: &str| e.iter().find(|x| x.name == n).unwrap(); + assert!(by("frag.bin").streamed); + assert_eq!(by("frag.bin").data.len(), 100000); + assert!(!by("big4k.bin").streamed); + assert!(!by("many/file_299.dat").streamed); +} + +/// The same tree comes out of every layout of the filesystem: extents, block +/// maps with their three levels of indirection, data in the inode, descriptors +/// in meta block groups, and clusters of several blocks. +#[test] +fn every_layout_of_the_filesystem_reads_the_same_tree() { + for name in [ + "ext4", + "ext2_blockmap", + "ext3", + "ext4_inline", + "ext4_metabg", + "ext4_bigalloc", + ] { + let e = walk_members(Format::Ext, &image(name)); + std::panic::catch_unwind(|| assert_tree(&e, &tree(true))) + .unwrap_or_else(|_| panic!("{name}")); + } +} + +/// A volume a kernel wrote: preallocated space reads as zeros, a punched hole +/// reads as zeros, and a file written across the holes of deleted ones comes +/// back whole. +#[test] +fn a_volume_a_kernel_wrote_reads_as_the_kernel_shows_it() { + let mut want = tree(false); + want.extend(extras()); + let e = walk_members(Format::Ext, &image("ext4_kernel")); + assert_tree(&e, &want); +} + +/// The place of inode `ino` in `img`, an ext4 volume of 4 KiB blocks. +fn inode_at(img: &[u8], ino: usize) -> usize { + let le32 = |at: usize| u32::from_le_bytes(img[at..at + 4].try_into().unwrap()) as usize; + let (ipg, size) = ( + le32(1024 + 40), + u16::from_le_bytes([img[1024 + 88], img[1024 + 89]]), ); - assert!( - out.iter().any(|x| x.unsupported.is_some()), - "an image over the budget must be reported, got {:?}", - out.iter() - .map(|x| (&x.name, x.unsupported)) - .collect::>() + let (group, slot) = ((ino - 1) / ipg, (ino - 1) % ipg); + // The descriptors start in block 1 and are 64 bytes each with 64bit. + let desc = 4096 + group * 64; + le32(desc + 8) * 4096 + slot * usize::from(size) +} + +/// The inode number a name has in a directory block of `img`. +fn ino_of(img: &[u8], name: &str) -> (usize, usize) { + // An entry: inode, length, name length, type, name. + let entry = |p: usize| { + p >= 8 && { + let rec = usize::from(u16::from_le_bytes([img[p - 4], img[p - 3]])); + img[p - 2] == name.len() as u8 + && (1..=7).contains(&img[p - 1]) + && rec % 4 == 0 + && rec >= 8 + name.len() + } + }; + let at = img + .windows(name.len()) + .enumerate() + .position(|(p, w)| w == name.as_bytes() && entry(p)) + .unwrap_or_else(|| panic!("no entry {name}")); + let ino = u32::from_le_bytes(img[at - 8..at - 4].try_into().unwrap()) as usize; + (ino, at - 8) +} + +/// A directory that names one above it is walked once, not forever. +#[test] +fn a_directory_that_names_its_ancestor_is_walked_once() { + let mut img = image("ext4"); + let (_, at) = ino_of(&img, "emptydir"); + img[at..at + 4].copy_from_slice(&2u32.to_le_bytes()); + let e = walk_members(Format::Ext, &img); + assert_tree(&e, &tree(true)); +} + +/// A file whose extent tree is gone is reported, and the others come out. +#[test] +fn a_file_with_a_damaged_extent_tree_is_reported_and_the_others_are_read() { + let mut img = image("ext4"); + let (ino, _) = ino_of(&img, "big4k.bin"); + // The extent header's magic, 40 bytes into the inode. + let at = inode_at(&img, ino) + 40; + assert_eq!(&img[at..at + 2], &[0x0A, 0xF3]); + img[at] = 0; + let (e, end) = walked(Format::Ext, &img, Limits::default()); + end.unwrap(); + let by = |n: &str| e.iter().find(|x| x.name == n).unwrap(); + assert_eq!( + by("big4k.bin").unsupported, + Some("file could not be read from the volume") ); + assert_eq!(by("p1.bin").data, pat(4, 1025)); + assert_eq!(by("many/file_299.dat").data, pat(1299, 299 % 97)); +} + +/// A file in an encrypted directory holds ciphertext; it is reported as +/// encrypted. +#[test] +fn an_encrypted_file_is_reported_as_encrypted() { + let mut img = image("ext4"); + let (ino, _) = ino_of(&img, "tiny.txt"); + // `i_flags`, 32 bytes into the inode: encrypted. + let at = inode_at(&img, ino) + 32; + let flags = u32::from_le_bytes(img[at..at + 4].try_into().unwrap()) | 0x800; + img[at..at + 4].copy_from_slice(&flags.to_le_bytes()); + let e = walk_members(Format::Ext, &img); + let tiny = e.iter().find(|x| x.name == "tiny.txt").unwrap(); + assert!(tiny.encrypted && tiny.unsupported.is_some() && tiny.data.is_empty()); +} + +/// What the image records of a file besides its bytes comes with it. +#[test] +fn a_file_comes_with_its_mode() { + let e = walk_members(Format::Ext, &image("ext4")); + let tiny = e.iter().find(|x| x.name == "tiny.txt").unwrap(); + assert_eq!(tiny.mode.map(|m| m & 0o170000), Some(0o100000)); + assert!(tiny.mtime.is_some()); +} + +/// An image cut short is a volume whose structures are not all there: that is +/// said, not read as a smaller volume. +#[test] +fn an_image_cut_short_is_reported() { + let img = image("ext4"); + let (_, end) = walked(Format::Ext, &img[..40 * 1024], Limits::default()); + assert!(end.unwrap_err().is_corrupt()); +} + +/// The walk stops at the member limit, as every container does, and the +/// entry past it is not lost without a word: the root with its folders removed +/// holds 10 files. +#[test] +fn a_volume_with_more_entries_than_the_limit_is_walked_to_it_and_stops() { + let mut img = image("ext4"); + for dir in ["lost+found", "a", "many", "emptydir"] { + let (_, at) = ino_of(&img, dir); + img[at..at + 4].fill(0); + } + let run = |max: u64| { + let mut limits = Limits::default(); + limits.max_members = max; + walked(Format::Ext, &img, limits) + }; + let (e, end) = run(10); + end.unwrap(); + assert_eq!(e.len(), 10); + let (e, end) = run(9); + assert!(!end.unwrap_err().is_corrupt()); + assert_eq!(e.len(), 9); } diff --git a/crates/exav-unpack/tests/suites/extreme.rs b/crates/exav-unpack/tests/suites/extreme.rs index a7b451fd..e0d683ec 100644 --- a/crates/exav-unpack/tests/suites/extreme.rs +++ b/crates/exav-unpack/tests/suites/extreme.rs @@ -5,7 +5,8 @@ //! checks, which `walk` turns into a "decoder panicked" error, and wraps in the //! WebAssembly builds, where it can defeat the bounds check next to it. Each //! test here takes a valid container, sets one field at a time to a value near -//! the top of its range, and requires that no run ends in a panic. +//! the top of its range, and requires that no run ends in a panic or fails to +//! end at all. use super::extract_each; use exav_unpack::{Budget, Entry, Format, Limits}; @@ -28,23 +29,83 @@ const POSITION_OFFSETS: [usize; 4] = [0, 4, 8, 16]; /// What makes a changed input pass its own checks again. type Fixup = dyn Fn(&mut [u8]); -/// Whether the walk of `blob` ended in a panic inside the decoder. -fn panicked(fmt: Format, blob: &[u8]) -> bool { - let mut limits = Limits::default(); - limits.max_buffer_bytes = 8 * 1024 * 1024; - limits.max_extracted_bytes = 16 * 1024 * 1024; - let mut budget = Budget::new(limits); - match extract_each(fmt, blob, &mut budget, &mut |_: Entry, _: &mut Budget| { - None::<()> - }) { - Err(e) => e.reason.contains("panicked"), - Ok(_) => false, +/// How long a walk of one of these small inputs may take. A real one is +/// milliseconds; one that wraps a position and loops is not stopped by the +/// budgets, which count bytes and members, not steps. +const HANG_AFTER: std::time::Duration = std::time::Duration::from_secs(10); + +/// How the walk of `blob` went wrong: a panic inside the decoder, or no end +/// within [`HANG_AFTER`]. `None` when it ended, however it ended. +fn broke(fmt: Format, blob: &[u8]) -> Option<&'static str> { + let blob = blob.to_vec(); + bounded(HANG_AFTER, move || { + let mut limits = Limits::default(); + limits.max_buffer_bytes = 8 * 1024 * 1024; + limits.max_extracted_bytes = 16 * 1024 * 1024; + let mut budget = Budget::new(limits); + matches!( + extract_each(fmt, &blob, &mut budget, &mut |_: Entry, _: &mut Budget| { + None::<()> + }), + Err(e) if e.reason.contains("panicked") + ) + }) +} + +/// Runs `walk`, which says whether the decoder panicked: `Some("panic")` if it +/// did, `Some("hang")` if it did not end within `limit`, `None` otherwise. +/// +/// A thread cannot be stopped: a walk that never ends is left running, and the +/// test reports it. The WebAssembly build has no threads, and can only say +/// after the fact that a walk took too long. +fn bounded( + limit: std::time::Duration, + walk: impl FnOnce() -> bool + Send + 'static, +) -> Option<&'static str> { + #[cfg(not(target_family = "wasm"))] + { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(walk()); + }); + match rx.recv_timeout(limit) { + Ok(true) => Some("panic"), + Ok(false) => None, + Err(_) => Some("hang"), + } } + #[cfg(target_family = "wasm")] + { + let start = std::time::Instant::now(); + if walk() { + Some("panic") + } else if start.elapsed() > limit { + Some("hang") + } else { + None + } + } +} + +/// The sweeps are only worth their name if a walk that never ends is seen as +/// one, and a panic is not taken for it. +#[test] +fn a_walk_that_does_not_end_is_a_hang_and_a_panic_is_a_panic() { + let short = std::time::Duration::from_millis(100); + assert_eq!(bounded(short, || false), None); + assert_eq!(bounded(short, || true), Some("panic")); + assert_eq!( + bounded(short, || { + std::thread::sleep(std::time::Duration::from_secs(2)); + false + }), + Some("hang") + ); } /// Sets every aligned 4- and 8-byte field, and every byte, inside `regions` to /// the values above, one at a time, and returns the changes that panicked the -/// decoder as `"offset width value"`. +/// decoder or kept it from ending as `"offset width value (how)"`. pub fn sweep(fmt: Format, blob: &[u8], regions: &[Range]) -> Vec { sweep_fixing(fmt, blob, regions, None) } @@ -63,7 +124,11 @@ pub fn sweep_fixing( regions: &[Range], fix: Option<&Fixup>, ) -> Vec { - assert!(!panicked(fmt, blob), "the unmodified input already panics"); + assert_eq!( + broke(fmt, blob), + None, + "the unmodified input already breaks" + ); let mut work = blob.to_vec(); let mut bad = Vec::new(); let mut try_set = |work: &mut Vec, at: usize, bytes: &[u8], what: String| { @@ -72,15 +137,15 @@ pub fn sweep_fixing( let mut trial = work.clone(); trial[at..at + bytes.len()].copy_from_slice(bytes); fix(&mut trial); - if panicked(fmt, &trial) { - bad.push(what); + if let Some(how) = broke(fmt, &trial) { + bad.push(format!("{what} ({how})")); } return; } let saved = work[at..at + bytes.len()].to_vec(); work[at..at + bytes.len()].copy_from_slice(bytes); - if panicked(fmt, work) { - bad.push(what); + if let Some(how) = broke(fmt, work) { + bad.push(format!("{what} ({how})")); } work[at..at + bytes.len()].copy_from_slice(&saved); }; @@ -127,7 +192,7 @@ pub fn sweep_fixing( /// The first `len` bytes of up to `cap` evenly spread 512-byte sectors that are /// not all zero: where a container keeps its headers and tables. -fn sectors(blob: &[u8], cap: usize, len: usize) -> Vec> { +pub fn sectors(blob: &[u8], cap: usize, len: usize) -> Vec> { let live: Vec = (0..blob.len() / 512) .map(|i| i * 512) .filter(|&s| blob[s..s + 512].iter().any(|&b| b != 0)) @@ -246,6 +311,12 @@ fn other_containers_fields_at_their_extremes() { (Format::SevenZip, "7z/aes_lzma2.7z"), (Format::Zip, "encrypted/zip_aes256_deflate.zip"), (Format::Xz, "xz/simple.xz"), + (Format::Xz, "xz/multi.xz"), + (Format::Lzip, "lzip_simple.lz"), + (Format::Zip, "zip/dd_python_stored.zip"), + (Format::Zip, "zip/eicar_deflate64.zip"), + (Format::SevenZip, "7z/lzma2_solid.7z"), + (Format::Binhex, "binhex/eicar.hqx.xor"), (Format::Lzw, "lzw/repetitive.b16.Z"), (Format::IshieldZ, "ishieldz/demo.z"), (Format::Hwp3, "hwp3/tika_testHWP_3.0.hwp"), @@ -370,7 +441,7 @@ fn lz4_with_a_skippable_frame_fields_at_their_extremes() { pub fn none(bad: Vec) { assert!( bad.is_empty(), - "{} field values panic the decoder, first: {:?}", + "{} field values panic or hang the decoder, first: {:?}", bad.len(), &bad[..bad.len().min(12)] ); diff --git a/crates/exav-unpack/tests/suites/fat.rs b/crates/exav-unpack/tests/suites/fat.rs index 703285ec..4e162592 100644 --- a/crates/exav-unpack/tests/suites/fat.rs +++ b/crates/exav-unpack/tests/suites/fat.rs @@ -24,7 +24,10 @@ //! ``` use super::extract_each; -use exav_unpack::{detect, Budget, Entry, Format, Limits}; +use super::fs_tree::{ + assert_streams_alike, assert_tree, image, members as walk_members, tree, walked as fs_walked, +}; +use exav_unpack::{Budget, Entry, Format, Limits, detect}; /// `sha256sum` of the ZIP that was copied in, which is what following the /// cluster chain has to reproduce. @@ -128,16 +131,189 @@ fn a_corrupt_filesystem_is_reported_not_shrugged_off() { // The boot sector still says FAT, so the files are there and a real mount // would read them; failing to must not read as clean. let mut img = fixture(); - // Scribble over the file allocation table itself. - for b in &mut img[512..2048] { - *b = 0xFF; - } + // Scribble over the file allocation table itself: the first copy, which + // begins after the reserved sectors. + let sector = usize::from(u16::from_le_bytes([img[11], img[12]])); + let start = usize::from(u16::from_le_bytes([img[14], img[15]])) * sector; + let len = usize::from(u16::from_le_bytes([img[22], img[23]])) * sector; + img[start..start + len].fill(0xFF); let e = members(&img); assert!( - e.is_empty() || e.iter().any(|x| x.unsupported.is_some()), - "a filesystem that cannot be walked must be reported, got {:?}", + e.iter().any(|x| x.unsupported.is_some()), + "a file whose chain is gone must be reported, got {:?}", e.iter() .map(|x| (&x.name, x.unsupported)) .collect::>() ); } + +/// The fixture with the first cluster of the root directory's entry `name` +/// (8.3, padded to 11 bytes) set to `cluster`. +fn with_first_cluster(name: &[u8; 11], cluster: u16) -> Vec { + let mut img = fixture(); + let at = img + .windows(11) + .position(|w| w == name) + .unwrap_or_else(|| panic!("no entry {:?}", String::from_utf8_lossy(name))); + img[at + 26..at + 28].copy_from_slice(&cluster.to_le_bytes()); + img +} + +fn walked(blob: &[u8]) -> (Vec, Result, exav_unpack::LimitHit>) { + let mut out = Vec::new(); + let mut b = Budget::new(Limits::default()); + let end = extract_each( + Format::Fat, + blob, + &mut b, + &mut |e: Entry, _: &mut Budget| { + out.push(e); + None::<()> + }, + ); + (out, end) +} + +/// Clusters are numbered from 2, and a directory that names cluster 1 has no +/// place to be read from. The rest of the volume is walked, and the walk ends +/// as one over a damaged volume. +#[test] +fn a_directory_at_cluster_1_ends_the_walk_as_damaged_after_the_rest_of_the_volume() { + let (e, end) = walked(&with_first_cluster(b"SUB ", 1)); + assert!(end.unwrap_err().is_corrupt()); + let names: Vec<&str> = e.iter().map(|x| x.name.as_str()).collect(); + assert!( + names.contains(&"a.bin") && names.contains(&"c.bin"), + "{names:?}" + ); + assert!(!names.iter().any(|n| n.starts_with("sub/")), "{names:?}"); +} + +/// The same for a file: the others come out, it is reported. +#[test] +fn a_file_at_cluster_1_is_reported_and_the_other_files_come_out() { + let (e, end) = walked(&with_first_cluster(b"A BIN", 1)); + end.unwrap_or_else(|h| panic!("the volume was ended: {h:?}")); + let by = |n: &str| e.iter().find(|x| x.name == n); + assert!(by("a.bin").is_some_and(|x| x.unsupported.is_some())); + assert!(by("c.bin").is_some_and(|x| x.unsupported.is_none())); + assert_eq!( + by("sub/p.zip").map(|x| sha256_hex(&x.data)).as_deref(), + Some(PAYLOAD) + ); +} + +/// The same tree comes out of FAT12, FAT16 and FAT32, long names included, and +/// of a volume a kernel wrote, which keeps the case of a short name in two flags +/// where the other images have a long name. The deleted entries (pads written +/// first, to fragment the files) and the volume label are not files. +#[test] +fn every_kind_of_fat_reads_the_same_tree() { + for name in ["fat12", "fat16", "fat32", "fat16_kernel"] { + let e = walk_members(Format::Fat, &image(name)); + std::panic::catch_unwind(|| assert_tree(&e, &tree(false))) + .unwrap_or_else(|_| panic!("{name}")); + } +} + +/// A file over the buffer limit is read as it goes, in every kind of FAT, a +/// fragmented one included. +#[test] +fn files_over_the_buffer_limit_are_read_as_they_go() { + for name in ["fat12", "fat16", "fat32", "fat16_kernel"] { + assert_streams_alike(Format::Fat, &image(name), 1000); + } +} + +/// The place of the 32-byte directory entry with the short name `name`. +fn entry_of(img: &[u8], name: &[u8; 11]) -> usize { + img.windows(12) + .position(|w| w[..11] == name[..] && w[11] & 0x10 != 0) + .unwrap_or_else(|| panic!("no folder {:?}", String::from_utf8_lossy(name))) +} + +/// A folder that names a folder it is in is walked once. +#[test] +fn a_folder_that_names_the_folder_it_is_in_is_walked_once() { + let mut img = image("fat16"); + let a = entry_of(&img, b"A "); + let b = entry_of(&img, b"B "); + let first = [img[a + 26], img[a + 27]]; + img[b + 26..b + 28].copy_from_slice(&first); + let e = walk_members(Format::Fat, &img); + let mut want = tree(false); + want.retain(|(p, _)| p != "a/b/c/deep.bin"); + assert_tree(&e, &want); +} + +/// A file with a chain that ends before its size does is reported with what +/// there is. +#[test] +fn a_chain_that_ends_early_is_a_file_reported_with_what_it_holds() { + let mut img = image("fat16"); + // The table begins after the reserved sectors; cluster 3 is the second + // entry of a file in the first run, and an end of chain makes it the last. + let e = walk_members(Format::Fat, &img); + assert!(e.iter().all(|x| x.unsupported.is_none())); + let reserved = usize::from(u16::from_le_bytes([img[14], img[15]])); + let table = reserved * usize::from(u16::from_le_bytes([img[11], img[12]])); + let frag = entry_of_file(&img, b"FRAG BIN"); + let first = usize::from(u16::from_le_bytes([img[frag + 26], img[frag + 27]])); + img[table + first * 2..table + first * 2 + 2].copy_from_slice(&0xFFFFu16.to_le_bytes()); + let (e, end) = walked(&img); + end.unwrap(); + let f = e.iter().find(|x| x.name == "frag.bin").unwrap(); + assert_eq!( + f.unsupported, + Some("file could not be read from the volume") + ); + assert!(!f.data.is_empty() && f.data.len() < 100000); +} + +fn entry_of_file(img: &[u8], name: &[u8; 11]) -> usize { + img.windows(12) + .position(|w| w[..11] == name[..] && w[11] & 0x10 == 0) + .unwrap_or_else(|| panic!("no file {:?}", String::from_utf8_lossy(name))) +} + +/// Entries past the member limit are not lost without a word. +#[test] +fn a_directory_longer_than_the_member_limit_stops_the_walk() { + let img = image("fat16"); + // The root holds 10 files and 3 folders, `many` 300 files. + let run = |max: u64| { + let mut limits = Limits::default(); + limits.max_members = max; + fs_walked(Format::Fat, &img, limits) + }; + let (e, end) = run(1000); + end.unwrap(); + assert_eq!(e.len(), 310); + let (e, end) = run(100); + assert!(!end.unwrap_err().is_corrupt()); + assert!(!e.is_empty() && e.len() <= 100); +} + +/// An image cut short is a volume whose structures are not all there. +#[test] +fn an_image_cut_short_is_reported() { + let img = image("fat16"); + let (_, end) = fs_walked(Format::Fat, &img[..2048], Limits::default()); + assert!(end.unwrap_err().is_corrupt()); + // Cut where the files are, the volume opens and the files are partial. + let (e, end) = fs_walked(Format::Fat, &img[..200 * 1024], Limits::default()); + let _ = end; + assert!(e.iter().any(|x| x.unsupported.is_some())); +} + +/// What a file records of when it was written is kept, as the wall-clock time +/// a FAT volume records it as. +#[test] +fn a_file_comes_with_the_time_it_was_written() { + let e = walk_members(Format::Fat, &image("fat16_kernel")); + let tiny = e.iter().find(|x| x.name == "tiny.txt").unwrap(); + assert!(matches!( + tiny.mtime, + Some(exav_unpack::Mtime::Local { year: 2026, .. }) + )); +} diff --git a/crates/exav-unpack/tests/suites/fs_tree.rs b/crates/exav-unpack/tests/suites/fs_tree.rs new file mode 100644 index 00000000..044f598d --- /dev/null +++ b/crates/exav-unpack/tests/suites/fs_tree.rs @@ -0,0 +1,224 @@ +//! The tree every filesystem fixture holds (`fixtures/fs/tree.py`, built into +//! images by `fixtures/fs/make.sh`), and the check of a walk against it. +//! +//! The bytes of a file are made by `pat`, the same function `tree.py` has, so +//! a walk is compared with what was written and not with a list of hashes. + +use std::collections::HashMap; + +use exav_unpack::{Budget, Entry, Format, LimitHit, Limits, Member, walk}; + +use super::extract_each; + +/// `n` bytes for the file numbered `seed`: runs of 64 that are noise and runs +/// of 64 that are text, so a compressor has something to do and something not +/// to. +pub(super) fn pat(seed: u32, n: usize) -> Vec { + (0..n) + .map(|i| { + if (i / 64) % 2 == 0 { + (((i as u32).wrapping_mul(2654435761)).wrapping_add(seed.wrapping_mul(40503)) >> 13) + as u8 + } else { + b"abcdefghij0123456789"[(i + seed as usize) % 20] + } + }) + .collect() +} + +/// An image of `fixtures/fs`, gunzipped. +pub(super) fn image(name: &str) -> Vec { + let p = format!( + "{}/tests/fixtures/fs/{name}.img.gz", + env!("CARGO_MANIFEST_DIR") + ); + let raw = exav_unpack::read_fixture(&p).unwrap_or_else(|e| panic!("read {p}: {e}")); + let mut out = Vec::new(); + std::io::Read::read_to_end(&mut flate2::read::GzDecoder::new(&raw[..]), &mut out) + .unwrap_or_else(|e| panic!("gunzip {p}: {e}")); + out +} + +/// The files of `tree.py`, with `sparse.bin` where asked. +pub(super) fn tree(sparse: bool) -> Vec<(String, Vec)> { + let files: [(&str, usize); 10] = [ + ("empty", 0), + ("tiny.txt", 10), + ("sixty.bin", 60), + ("sixtyone.bin", 61), + ("p1.bin", 1025), + ("big4k.bin", 4096), + ("a/b/c/deep.bin", 5000), + ("caf\u{e9} \u{65e5}\u{672c}.txt", 300), + ("long name with spaces and a long extension.markdown", 700), + ("frag.bin", 100000), + ]; + let mut out: Vec<(String, Vec)> = files + .iter() + .enumerate() + .map(|(seed, (path, size))| ((*path).to_string(), pat(seed as u32, *size))) + .collect(); + for i in 0..300u32 { + out.push(( + format!("many/file_{i:03}.dat"), + pat(1000 + i, (i % 97) as usize), + )); + } + if sparse { + let mut data = vec![0u8; (70 << 20) + 4]; + for (k, (at, n)) in [(0usize, 3000usize), (30 << 20, 7000), (70 << 20, 4)] + .into_iter() + .enumerate() + { + data[at..at + n].copy_from_slice(&pat(2000 + k as u32, n)); + } + out.push(("sparse.bin".to_string(), data)); + } + out +} + +/// The files `fixtures/fs/extras.py` makes in a mounted volume. +pub(super) fn extras() -> Vec<(String, Vec)> { + let mut prealloc = vec![0u8; 1 << 20]; + prealloc[300000..305000].copy_from_slice(&pat(3000, 5000)); + let mut holey = pat(3001, 1 << 20); + holey[100000..400000].fill(0); + let mut trunc = b"abc".to_vec(); + trunc.resize(100000, 0); + let mut out = vec![ + ("k/prealloc.bin".to_string(), prealloc), + ("k/holey.bin".to_string(), holey), + ("k/trunc.bin".to_string(), trunc), + ]; + for i in (0..300usize).filter(|i| i % 3 != 0) { + out.push((format!("k/d/f{i}"), vec![b'x'; i])); + } + out +} + +/// The file `extras.py --trunc-only` makes: 3 bytes written and the length set +/// to 100000. +pub(super) fn truncated() -> (String, Vec) { + let mut data = b"abc".to_vec(); + data.resize(100000, 0); + ("k/trunc.bin".to_string(), data) +} + +/// What a walk of `img` gave, and how it ended. +pub(super) fn walked( + fmt: Format, + img: &[u8], + limits: Limits, +) -> (Vec, Result, LimitHit>) { + let mut out = Vec::new(); + let mut budget = Budget::new(limits); + let end = extract_each(fmt, img, &mut budget, &mut |e: Entry, _: &mut Budget| { + out.push(e); + None::<()> + }); + (out, end) +} + +/// A member of a walk as it came: whether it was read as it goes, its bytes, +/// the error that ended that read, and what the walk reported of it. +pub(super) struct Came { + pub(super) name: String, + pub(super) streamed: bool, + pub(super) data: Vec, + pub(super) read_error: Option, + pub(super) unsupported: Option<&'static str>, +} + +/// A walk of `img` under a buffer limit of `cap` bytes, which a file past it is +/// read as it goes under. +pub(super) fn came(fmt: Format, img: &[u8], cap: u64) -> (Vec, Result, LimitHit>) { + let mut out = Vec::new(); + let mut limits = Limits::default(); + limits.max_buffer_bytes = cap; + let mut budget = Budget::new(limits); + let end = walk(fmt, &img, &mut budget, &mut |meta, content, _| { + let mut read_error = None; + let (streamed, data) = match content { + None => (false, Vec::new()), + Some(Member::Bytes(bytes)) => (false, bytes), + Some(Member::Stream(reader)) => { + let mut bytes = Vec::new(); + read_error = reader.read_to_end(&mut bytes).err().map(|e| e.to_string()); + (true, bytes) + } + }; + out.push(Came { + name: meta.name.clone(), + streamed, + data, + read_error, + unsupported: meta.unsupported, + }); + None::<()> + }); + (out, end) +} + +/// A walk of `img` under a buffer limit of `cap` gives what a walk that holds +/// every file gives, the files past `cap` read as they go and the others +/// held. +pub(super) fn assert_streams_alike(fmt: Format, img: &[u8], cap: u64) { + let held = members(fmt, img); + let (came, end) = came(fmt, img, cap); + end.unwrap_or_else(|h| panic!("{fmt:?} ended: {h:?}")); + assert_eq!(came.len(), held.len(), "{fmt:?}: the same members"); + for (c, h) in came.iter().zip(&held) { + assert_eq!(c.name, h.name, "{fmt:?}"); + assert_eq!(c.data, h.data, "{fmt:?}: {}", c.name); + assert_eq!(c.unsupported, h.unsupported, "{fmt:?}: {}", c.name); + assert_eq!(c.read_error, None, "{fmt:?}: {}", c.name); + assert_eq!(c.streamed, c.data.len() as u64 > cap, "{fmt:?}: {}", c.name); + } + assert!( + came.iter().any(|c| c.streamed), + "{fmt:?}: nothing was past {cap}" + ); +} + +/// A walk of `img` that must end cleanly. +pub(super) fn members(fmt: Format, img: &[u8]) -> Vec { + let (e, end) = walked(fmt, img, Limits::default()); + end.unwrap_or_else(|h| panic!("{fmt:?} ended: {h:?}")); + e +} + +/// The walk has every file of `want` with its bytes, and nothing else, and +/// reports nothing unreadable. +pub(super) fn assert_tree(entries: &[Entry], want: &[(String, Vec)]) { + let bad: Vec<_> = entries + .iter() + .filter(|e| e.unsupported.is_some()) + .map(|e| (&e.name, e.unsupported)) + .collect(); + assert!(bad.is_empty(), "reported: {bad:?}"); + let mut got: HashMap<&str, &[u8]> = HashMap::new(); + for e in entries { + assert!( + got.insert(&e.name, &e.data).is_none(), + "{} came out twice", + e.name + ); + } + let mut missing = Vec::new(); + let mut differ = Vec::new(); + for (path, data) in want { + match got.get(path.as_str()) { + None => missing.push(path.as_str()), + Some(d) if d != &data.as_slice() => differ.push(path.as_str()), + Some(_) => {} + } + } + let extra: Vec<_> = got + .keys() + .filter(|k| !want.iter().any(|(p, _)| p == **k)) + .collect(); + assert!( + missing.is_empty() && differ.is_empty() && extra.is_empty(), + "missing {missing:?}, differing {differ:?}, unexpected {extra:?}" + ); +} diff --git a/crates/exav-unpack/tests/suites/ishield_z.rs b/crates/exav-unpack/tests/suites/ishield_z.rs index 8d5debe0..f41f8fac 100644 --- a/crates/exav-unpack/tests/suites/ishield_z.rs +++ b/crates/exav-unpack/tests/suites/ishield_z.rs @@ -13,7 +13,7 @@ //! Fixtures are the `unshield` project's own examples (MIT); see `NOTICE`. use super::extract_each; -use exav_unpack::{detect, Budget, Entry, Format, Limits}; +use exav_unpack::{Budget, Entry, Format, Limits, detect}; fn fixture(name: &str) -> Vec { let p = format!( @@ -156,6 +156,86 @@ fn a_member_that_expands_past_the_budget_is_reported() { ); } +/// An archive whose table of contents has `dirs` directory records, each +/// declaring `dir_files` members and each a zero-length chunk, so that every +/// record and every member reads the same bytes again. 498 bytes of this took +/// the `unshield` reader 1.5 GB: it builds one entry per declared member before +/// anything is checked. +fn declared_member_bomb(dirs: u16, dir_files: u16) -> Vec { + const HEADER: usize = 255; + const TOC: usize = 64; + let mut d = vec![0u8; HEADER + TOC]; + d[..4].copy_from_slice(&[0x13, 0x5D, 0x65, 0x8C]); + d[12..14].copy_from_slice(&1u16.to_le_bytes()); + d[18..22].copy_from_slice(&((HEADER + TOC) as u32).to_le_bytes()); + d[41..45].copy_from_slice(&(HEADER as u32).to_le_bytes()); + d[49..51].copy_from_slice(&dirs.to_le_bytes()); + d[HEADER..HEADER + 2].copy_from_slice(&dir_files.to_le_bytes()); + d +} + +#[test] +fn a_table_declaring_more_members_than_the_walk_bound_is_reported_not_built() { + let blob = declared_member_bomb(1, u16::MAX); + assert_eq!(detect(&blob), Some(Format::IshieldZ)); + let e = members(&blob); + assert_eq!( + e.len(), + 1, + "got {:?}", + e.iter().map(|x| &x.name).collect::>() + ); + assert!( + e[0].unsupported.is_some(), + "members left unexamined must be reported" + ); +} + +#[test] +fn a_table_of_many_directories_each_declaring_many_members_stays_small() { + // 65535 x 65535 declared members: 4 billion entries if built. + let blob = declared_member_bomb(u16::MAX, u16::MAX); + let e = members(&blob); + assert_eq!(e.len(), 1); + assert!(e[0].unsupported.is_some()); +} + +/// `undhr.z`'s one member twice, under the same path. +fn same_path_twice() -> Vec { + let z = fixture("undhr.z"); + let u32at = |o: usize| u32::from_le_bytes(z[o..o + 4].try_into().unwrap()) as usize; + let toc = &z[u32at(41)..u32at(18)]; + let dir_chunk = u16::from_le_bytes([toc[2], toc[3]]) as usize; + let (dir, file) = toc.split_at(dir_chunk); + let data = &z[255..u32at(41)]; + + let mut out = z[..255].to_vec(); + out.extend_from_slice(data); + out.extend_from_slice(data); + let toc_at = out.len(); + out.extend_from_slice(&[2, 0]); + out.extend_from_slice(&dir[2..]); + out.extend_from_slice(file); + out.extend_from_slice(file); + let end = out.len() as u32; + out[18..22].copy_from_slice(&end.to_le_bytes()); + out[41..45].copy_from_slice(&(toc_at as u32).to_le_bytes()); + out +} + +#[test] +fn members_sharing_a_path_are_all_extracted() { + let e = members(&same_path_twice()); + let want = fixture("undhr.md"); + assert_eq!( + e.len(), + 2, + "one member hid the other: {:?}", + e.iter().map(|x| &x.name).collect::>() + ); + assert!(e.iter().all(|x| x.data == want)); +} + #[test] fn a_member_past_the_budget_is_reported_not_skipped() { let mut limits = Limits::default(); @@ -179,3 +259,67 @@ fn a_member_past_the_budget_is_reported_not_skipped() { .collect::>() ); } + +/// `undhr.z` with its one member's compressed size (in the file record of the +/// table of contents) set to `size`. +fn with_member_size(size: u32) -> Vec { + let mut z = fixture("undhr.z"); + let u32at = |z: &[u8], o: usize| u32::from_le_bytes(z[o..o + 4].try_into().unwrap()) as usize; + let toc = u32at(&z, 41); + let dir_chunk = u16::from_le_bytes([z[toc + 2], z[toc + 3]]) as usize; + let at = toc + dir_chunk + 7; + z[at..at + 4].copy_from_slice(&size.to_le_bytes()); + z +} + +#[test] +fn a_member_cut_inside_its_stream_keeps_its_prefix_and_is_reported() { + let whole = members(&fixture("undhr.z")); + let want = fixture("undhr.md"); + let half = (whole[0].comp_size / 2) as u32; + let e = members(&with_member_size(half)); + assert_eq!(e.len(), 1); + assert!( + e[0].unsupported.is_some(), + "a member whose stream stops short was reported as whole" + ); + assert!( + e[0].data.len() > 100 && want.starts_with(&e[0].data), + "the decoded prefix is lost: {} bytes", + e[0].data.len() + ); + assert!(e[0].data.len() < want.len()); +} + +#[test] +fn a_member_damaged_part_way_keeps_its_prefix_and_is_reported() { + // A byte of the compressed data set to each value in turn until one + // makes the stream invalid after it has produced output. + let z = fixture("undhr.z"); + let want = fixture("undhr.md"); + let mut found = false; + for at in (300..1500).step_by(7) { + let mut bad = z.clone(); + bad[at] = !bad[at]; + let e = members(&bad); + if e.len() != 1 || e[0].unsupported.is_none() || e[0].data.is_empty() { + continue; + } + found = true; + // Output before the damaged byte is the file's own. + assert!(e[0].data.starts_with(&want[..20]), "byte {at}"); + break; + } + assert!(found, "no damaged member kept a decoded prefix"); +} + +#[test] +fn a_member_whose_range_runs_past_the_file_is_reported() { + // Past the end of the file, but within the size budget; its stream still + // ends, so only the range says something is wrong. + let whole = members(&fixture("undhr.z")); + let e = members(&with_member_size(whole[0].comp_size as u32 + 100_000)); + assert_eq!(e.len(), 1); + assert!(e[0].unsupported.is_some()); + assert_eq!(e[0].data, fixture("undhr.md")); +} diff --git a/crates/exav-unpack/tests/suites/iso_joliet.rs b/crates/exav-unpack/tests/suites/iso_joliet.rs index 9942916a..49c80f5e 100644 --- a/crates/exav-unpack/tests/suites/iso_joliet.rs +++ b/crates/exav-unpack/tests/suites/iso_joliet.rs @@ -4,7 +4,7 @@ //! that file extracted and scanned. Walking only the primary volume descriptor's //! directory tree would silently miss such files. -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; const EICAR: &[u8] = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR"; const SECTOR: usize = 2048; @@ -125,3 +125,76 @@ fn file_in_both_trees_is_emitted_once() { "a file in both trees must be extracted exactly once" ); } + +/// A file is its start and its length. A record of no length at the start of +/// another file's extent, in either tree, must not stand in for that file: an +/// extractor that deduplicated on the start alone reported the empty one and +/// skipped the real one, and the ISO mounted, with the file in it, anywhere. +#[test] +fn an_empty_record_in_one_tree_does_not_hide_the_file_of_the_other() { + // The Joliet tree lists an empty file at the extent, the primary tree the + // real one. The Joliet tree is walked first. + let mut iso = joliet_only_iso(&utf16be("empty.txt"), b""); + let mut jol = dir_record(20, SECTOR as u32, true, &[0]); + jol.extend(dir_record(20, SECTOR as u32, true, &[1])); + jol.extend(dir_record(21, 0, false, &utf16be("empty.txt"))); + let a = 20 * SECTOR; + iso[a..a + SECTOR].fill(0); + iso[a..a + jol.len()].copy_from_slice(&jol); + let mut prim = dir_record(19, SECTOR as u32, true, &[0]); + prim.extend(dir_record(19, SECTOR as u32, true, &[1])); + prim.extend(dir_record(21, EICAR.len() as u32, false, b"PAYLOAD.BIN;1")); + let a = 19 * SECTOR; + iso[a..a + prim.len()].copy_from_slice(&prim); + iso[21 * SECTOR..21 * SECTOR + EICAR.len()].copy_from_slice(EICAR); + + let mut budget = Budget::new(Limits::default()); + let entries = extract(Format::Iso, &iso, &mut budget).expect("iso extract"); + assert!( + entries + .iter() + .any(|e| e.data.windows(EICAR.len()).any(|w| w == EICAR)), + "{entries:?}" + ); +} + +/// In both trees, the file takes the long name the Joliet tree gives it. +#[test] +fn a_file_in_both_trees_is_named_by_the_joliet_tree() { + let mut iso = joliet_only_iso(&utf16be("a-long-name.bin"), EICAR); + let mut prim = dir_record(19, SECTOR as u32, true, &[0]); + prim.extend(dir_record(19, SECTOR as u32, true, &[1])); + prim.extend(dir_record(21, EICAR.len() as u32, false, b"A_LONG_N.BIN;1")); + let a = 19 * SECTOR; + iso[a..a + prim.len()].copy_from_slice(&prim); + let mut budget = Budget::new(Limits::default()); + let entries = extract(Format::Iso, &iso, &mut budget).expect("iso extract"); + let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["a-long-name.bin"]); +} + +/// The same inside one tree: the empty record is listed first. +#[test] +fn an_empty_record_before_the_file_in_the_same_directory_does_not_hide_it() { + let mut iso = joliet_only_iso(&utf16be("payload.bin"), EICAR); + let mut jol = dir_record(20, SECTOR as u32, true, &[0]); + jol.extend(dir_record(20, SECTOR as u32, true, &[1])); + jol.extend(dir_record(21, 0, false, &utf16be("empty.txt"))); + jol.extend(dir_record( + 21, + EICAR.len() as u32, + false, + &utf16be("payload.bin"), + )); + let a = 20 * SECTOR; + iso[a..a + jol.len()].copy_from_slice(&jol); + + let mut budget = Budget::new(Limits::default()); + let entries = extract(Format::Iso, &iso, &mut budget).expect("iso extract"); + assert!( + entries + .iter() + .any(|e| e.name == "payload.bin" && e.data.windows(EICAR.len()).any(|w| w == EICAR)), + "{entries:?}" + ); +} diff --git a/crates/exav-unpack/tests/suites/iso_never_silent.rs b/crates/exav-unpack/tests/suites/iso_never_silent.rs index 3f446f9f..0c0c313f 100644 --- a/crates/exav-unpack/tests/suites/iso_never_silent.rs +++ b/crates/exav-unpack/tests/suites/iso_never_silent.rs @@ -5,7 +5,7 @@ //! examine — and the containing file must not be reportable as clean on that //! basis. Each case here was a silent `break`/`continue` before. -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; const SECTOR: usize = 2048; diff --git a/crates/exav-unpack/tests/suites/lha_header_bounds.rs b/crates/exav-unpack/tests/suites/lha_header_bounds.rs index 3325490c..99f8b799 100644 --- a/crates/exav-unpack/tests/suites/lha_header_bounds.rs +++ b/crates/exav-unpack/tests/suites/lha_header_bounds.rs @@ -8,7 +8,7 @@ //! unwinding. The only place to stop it is before the reader is handed the //! bytes. -use exav_unpack::{extract, Budget, Format, Limits}; +use exav_unpack::{Budget, Format, Limits, extract}; /// The 143-byte archive that reserves ~3.2 GB. Its level-3 header states a /// total of 0xFFFFFFDF, which cannot be true of a file this size. diff --git a/crates/exav-unpack/tests/suites/lz4.rs b/crates/exav-unpack/tests/suites/lz4.rs index ab953a1d..55c63d07 100644 --- a/crates/exav-unpack/tests/suites/lz4.rs +++ b/crates/exav-unpack/tests/suites/lz4.rs @@ -18,7 +18,7 @@ //! ``` use super::extract_each; -use exav_unpack::{detect, Budget, Entry, Format, Limits}; +use exav_unpack::{Budget, Entry, Format, Limits, detect}; /// `sha256sum` of what `lz4 -dc` writes for each fixture. const ONE: &str = "30c2abfccdf15a28990bae7bb0efa2444d82af3ce7053ff493956ef9612f775d"; diff --git a/crates/exav-unpack/tests/suites/lzfse.rs b/crates/exav-unpack/tests/suites/lzfse.rs new file mode 100644 index 00000000..3ef2fbe9 --- /dev/null +++ b/crates/exav-unpack/tests/suites/lzfse.rs @@ -0,0 +1,65 @@ +//! DMG runs compressed with LZFSE, through the whole extraction. +//! +//! `udif_lzfse.dmg` is `hfs_plus_udrw.dmg`'s disk in two LZFSE runs (an LZVN +//! block, then FSE blocks) written by an encoder that is not exav's +//! (`lzfse_rust`), so the files that come out are checked against the same +//! disk read from its raw runs. + +use exav_unpack::{Budget, Entry, Format, Limits, extract}; + +fn fixture(name: &str) -> Vec { + let p = format!("{}/tests/fixtures/dmg/{name}", env!("CARGO_MANIFEST_DIR")); + exav_unpack::read_fixture(&p).unwrap_or_else(|e| panic!("read {p}: {e}")) +} + +fn entries(data: &[u8]) -> Vec { + extract(Format::Dmg, &data, &mut Budget::new(Limits::default())).unwrap() +} + +fn files(entries: &[Entry]) -> Vec<(String, Vec)> { + entries + .iter() + .map(|e| (e.name.clone(), e.data.clone())) + .collect() +} + +#[test] +fn an_lzfse_image_gives_the_files_of_its_raw_twin() { + let raw = entries(&fixture("hfs_plus_udrw.dmg")); + let lzfse = entries(&fixture("udif_lzfse.dmg")); + assert!( + lzfse.iter().all(|e| e.unsupported.is_none()), + "{:?}", + lzfse + .iter() + .map(|e| (&e.name, e.unsupported)) + .collect::>() + ); + let want = files(&raw); + assert!(want.iter().any(|(_, d)| d.starts_with(b"hello hfs+"))); + assert_eq!(files(&lzfse), want); +} + +#[test] +fn an_lzfse_image_with_a_damaged_run_is_not_the_whole_image() { + let image = fixture("udif_lzfse.dmg"); + // The second run is the one with the FSE block; its magic is damaged. + let at = image.windows(4).rposition(|w| w == b"bvx2").unwrap(); + let mut bad = image.clone(); + bad[at + 3] = b'Z'; + let got = extract( + Format::Dmg, + &bad.as_slice(), + &mut Budget::new(Limits::default()), + ); + // Refused outright, or walked with the damage reported. + if let Ok(got) = got { + assert!( + got.iter().any(|e| e.unsupported.is_some()), + "a damaged run came out clean: {:?}", + got.iter() + .map(|e| (&e.name, e.unsupported)) + .collect::>() + ); + } +} diff --git a/crates/exav-unpack/tests/suites/mod.rs b/crates/exav-unpack/tests/suites/mod.rs index ce522197..63468c12 100644 --- a/crates/exav-unpack/tests/suites/mod.rs +++ b/crates/exav-unpack/tests/suites/mod.rs @@ -14,6 +14,8 @@ mod allowed_formats; #[cfg(feature = "alz")] mod alz; +#[cfg(all(feature = "ar", feature = "cpio"))] +mod ar_cpio_irregular; #[cfg(feature = "arc")] mod arc; #[cfg(feature = "arj")] @@ -23,10 +25,18 @@ mod broken_media; mod bzip2_blocks; #[cfg(feature = "cab")] mod cab_quantum; +#[cfg(feature = "cab")] +mod cab_salvage; +#[cfg(feature = "cab")] +mod cab_volumes; #[cfg(feature = "chm")] mod chm; #[cfg(feature = "cpio")] mod cpio_never_silent; +#[cfg(feature = "cramfs")] +mod cramfs; +#[cfg(all(feature = "bzip2", feature = "xz"))] +mod cut_streams; #[cfg(all(not(feature = "decrypt"), feature = "zip"))] mod decrypt_disabled; mod disabled_formats; @@ -39,15 +49,24 @@ mod dwg; #[cfg(feature = "dxf")] mod dxf; mod egg; +#[cfg(feature = "email")] +mod email; +#[cfg(feature = "email")] +mod email_readers_agree; #[cfg(feature = "decrypt")] mod encrypted_zip; #[cfg(feature = "tar")] mod entry_accounting; +#[cfg(feature = "erofs")] +mod erofs; +#[cfg(feature = "exfat")] +mod exfat; #[cfg(feature = "ext")] mod ext; mod extreme; #[cfg(feature = "fat")] mod fat; +mod fs_tree; mod hwp3; #[cfg(feature = "ishieldz")] mod ishield_z; @@ -59,8 +78,11 @@ mod iso_never_silent; mod lha_header_bounds; #[cfg(feature = "lz4")] mod lz4; +#[cfg(feature = "dmg")] +mod lzfse; #[cfg(feature = "lzw")] mod lzw; +mod nesting; #[cfg(feature = "nsis")] mod nsis; #[cfg(feature = "ntfs")] @@ -78,6 +100,8 @@ mod partition_never_silent; mod pdf_empty_password; #[cfg(feature = "pdf")] mod pdf_parse; +#[cfg(feature = "pdf")] +mod pdf_readers_agree; #[cfg(feature = "pepack")] mod pe_emulation; #[cfg(feature = "pepack")] @@ -95,13 +119,23 @@ mod rar4_compat; mod rar5_encryption; #[cfg(feature = "rar")] mod rar_solid; +#[cfg(feature = "romfs")] +mod romfs; +#[cfg(feature = "rpm")] +mod rpm; #[cfg(feature = "sevenz")] mod sevenz_bcj2; #[cfg(feature = "sevenz")] +mod sevenz_entries; +#[cfg(feature = "sevenz")] mod sevenz_ppmd; mod silent_skips; +#[cfg(feature = "squashfs")] +mod squashfs; #[cfg(feature = "all-formats")] mod stream_read_errors; +#[cfg(feature = "text")] +mod text; #[cfg(feature = "iso")] mod udf; #[cfg(feature = "upx")] @@ -110,9 +144,20 @@ mod upx_rebuild; mod vba_module_source; #[cfg(feature = "vhd")] mod vhd; +#[cfg(all( + feature = "diskimage", + feature = "vhd", + feature = "partition", + feature = "ntfs", + feature = "fat", + feature = "ext" +))] +mod virtual_disks; #[cfg(feature = "wim")] mod wim; #[cfg(feature = "xz")] +mod xz_checks; +#[cfg(feature = "xz")] mod xz_dict; #[cfg(feature = "zip")] mod zip_appended; @@ -123,11 +168,13 @@ mod zip_deflate64; #[cfg(feature = "zip")] mod zip_disguised_members; #[cfg(feature = "zip")] +mod zip_local_vs_directory; +#[cfg(feature = "zip")] mod zip_overlap; #[cfg(feature = "zoo")] mod zoo; -use exav_unpack::{walk, Budget, Entry, Format, LimitHit, Member}; +use exav_unpack::{Budget, Entry, Format, LimitHit, Member, walk}; /// A real-malware sample under `tests/fixtures/`, kept locally only (see /// `read_fixture`), or `None` when it is absent or this build cannot open its diff --git a/crates/exav-unpack/tests/suites/nesting.rs b/crates/exav-unpack/tests/suites/nesting.rs new file mode 100644 index 00000000..064b33d6 --- /dev/null +++ b/crates/exav-unpack/tests/suites/nesting.rs @@ -0,0 +1,319 @@ +//! Structure nested as deep as the file is long. +//! +//! A reader that follows a group, a part or an object into the next one with +//! a call of its own uses a frame of the stack per level, and a level costs a +//! few bytes of input: a megabyte of `{` is a million levels. The stack of a +//! thread is a few hundred kilobytes (a worker of the scan, a viewer, a +//! WebAssembly host) and running out of it ends the process, not the file. Each +//! reader that has nesting is walked here on a small stack with its structure +//! as deep as 200 KB of input makes it. It may refuse the file or read it, but +//! it has to end, in seconds, and without a panic. + +use super::extract_each; +use exav_unpack::{Budget, Entry, Format, Limits}; +use std::time::{Duration, Instant}; + +const STACK: usize = 512 * 1024; +const LEVELS: usize = 50_000; + +/// Walks `data` as `fmt` on a [`STACK`]-sized thread. The failure, if any. +fn walk_small(fmt: Format, data: Vec) -> Result { + let started = Instant::now(); + let r = std::thread::Builder::new() + .stack_size(STACK) + .spawn(move || { + let mut limits = Limits::default(); + limits.max_buffer_bytes = 64 << 20; + limits.max_extracted_bytes = 128 << 20; + let mut budget = Budget::new(limits); + let r = extract_each(fmt, &data, &mut budget, &mut |_: Entry, _: &mut Budget| { + None::<()> + }); + matches!(r, Err(e) if e.reason.contains("panicked")) + }) + .unwrap() + .join(); + match r { + Err(_) => Err("overflowed its stack or panicked".into()), + Ok(true) => Err("the decoder panicked".into()), + Ok(false) => Ok(started.elapsed()), + } +} + +fn check(fmt: Format, name: &str, data: Vec) -> Option { + match walk_small(fmt, data) { + Err(e) => Some(format!("{fmt:?} {name}: {e}")), + Ok(took) if took > Duration::from_secs(20) => { + Some(format!("{fmt:?} {name}: took {:.1} s", took.as_secs_f64())) + } + Ok(_) => None, + } +} + +fn assert_all(findings: Vec>) { + let findings: Vec = findings.into_iter().flatten().collect(); + assert!(findings.is_empty(), "\n{}", findings.join("\n")); +} + +#[cfg(feature = "rtf")] +#[test] +fn rtf_groups() { + let n = LEVELS * 4; + let wrap = |open: &str, inner: &str, close: &str| { + format!("{{\\rtf1 {}{inner}{}}}", open.repeat(n), close.repeat(n)).into_bytes() + }; + assert_all(vec![ + check(Format::Rtf, "groups", wrap("{", "x", "}")), + check(Format::Rtf, "unclosed", wrap("{", "x", "")), + check(Format::Rtf, "destinations", wrap("{\\*\\foo ", "x", "}")), + check( + Format::Rtf, + "objdata", + wrap("{\\object{\\*\\objdata ", "41", "}}"), + ), + check( + Format::Rtf, + "fields", + wrap("{\\field{\\*\\fldinst ", "x", "}}"), + ), + check(Format::Rtf, "pict", wrap("{\\pict ", "41", "}")), + check( + Format::Rtf, + "shapes", + wrap("{\\shp{\\*\\shpinst ", "x", "}}"), + ), + check(Format::Rtf, "tables", wrap("\\trowd{\\intbl ", "x", "}")), + ]); +} + +#[cfg(feature = "email")] +#[test] +fn mime_parts() { + let n = LEVELS / 5; + // multipart inside multipart, each with a boundary of its own. + let mut nested = String::new(); + for i in 0..n { + nested.push_str(&format!( + "Content-Type: multipart/mixed; boundary=\"b{i}\"\r\n\r\n--b{i}\r\n" + )); + } + nested.push_str("Content-Type: text/plain\r\n\r\nx\r\n"); + // multipart inside multipart with the one boundary. + let mut same = String::from("Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n"); + for _ in 0..n * 4 { + same.push_str("--b\r\nContent-Type: multipart/mixed; boundary=\"b\"\r\n\r\n"); + } + let rfc822 = "Content-Type: message/rfc822\r\n\r\n".repeat(n * 4); + let rfc822_headers = format!( + "From: a@b\r\n{}\r\nx", + "Content-Type: message/rfc822\r\n\r\nFrom: a@b\r\n".repeat(n * 4) + ); + let comments = format!( + "From: a@b\r\nSubject: {}x{}\r\n\r\nx", + "(".repeat(n * 8), + ")".repeat(n * 8) + ); + let groups = format!("To: {}a@b;\r\n\r\nx", "g:".repeat(n * 8)); + let quoted = format!("Subject: =?utf-8?q?{}?=\r\n\r\nx", "=3D".repeat(n * 8)); + assert_all( + [ + ("multipart", nested), + ("same boundary", same), + ("message/rfc822", rfc822), + ("rfc822 with headers", rfc822_headers), + ("comments", comments), + ("group addresses", groups), + ("encoded words", quoted), + ] + .into_iter() + .map(|(name, s)| check(Format::Email, name, s.into_bytes())) + .collect(), + ); +} + +#[cfg(feature = "xdp")] +#[test] +fn xdp_elements() { + let n = LEVELS * 2; + let head = ""; + let nest = |open: &str, close: &str, inner: &str| { + format!( + "{head}{}{inner}{}", + open.repeat(n), + close.repeat(n) + ) + .into_bytes() + }; + assert_all(vec![ + check(Format::Xdp, "elements", nest("", "", "")), + check(Format::Xdp, "unclosed", nest("", "", "")), + check( + Format::Xdp, + "chunks", + nest("", "", "QQ=="), + ), + check(Format::Xdp, "comments", nest("bc" + ), + "abc\n" + ); + // A raw-text element ends at its own end tag only. + assert_eq!(html("z"), "z\n"); + assert_eq!(html("yes"), "yes\n"); + // A script that opens a comment and another script ends at the second end tag. + assert_eq!(html("z"), "z\n"); +} + +#[test] +fn entities_are_decoded_whatever_their_name() { + assert_eq!( + html( + "& <b> ♥ A B  x &zzzz; ∳" + ), + "& \u{2665} A B x &zzzz; \u{2233}\n" + ); + // The specification's legacy names need no semicolon. + assert_eq!(html("¬it"), "\u{ac}it\n"); +} + +#[test] +fn a_link_is_its_label_and_where_it_leads() { + assert_eq!( + html("Click here now"), + "Click here (https://evil.example/x?a=1&b=2) now\n" + ); + // The label that is the target says it once; a fragment leads nowhere. + assert_eq!( + html("https://a.example/ top"), + "https://a.example/ top\n" + ); + // Whatever the target is. + assert_eq!( + html("run"), + "run (javascript:alert(1))\n" + ); + // A label naming one site over a target on another is all there to see. + let t = html("https://www.paypal.com/login"); + assert!( + t.contains("paypal.com/login") && t.contains("(https://evil.example/steal)"), + "{t}" + ); + // An unclosed link ends with the document. + assert_eq!(html("y"), "y (x)\n"); +} + +#[test] +fn an_image_is_its_alt_text() { + assert_eq!(html("a\"ab"), "a a diagram b\n"); +} + +#[test] +fn title_and_textarea_are_text_as_written() { + assert_eq!( + html("T & U"), + "T & U\nx\n" + ); +} + +#[test] +fn a_greater_than_in_a_quoted_attribute_does_not_end_the_tag() { + assert_eq!(html(" b\" href=\"u\">t"), "t (u)\n"); +} + +#[test] +fn bytes_that_are_not_utf8_are_read_as_windows_1252() { + let t = extract( + Kind::Html, + b"

    caf\xe9 \x80

    ", + 1 << 20, + &Limits::default(), + ) + .text; + assert_eq!(t, "caf\u{e9} \u{20ac}\n"); + let utf16: Vec = [0xff, 0xfe] + .into_iter() + .chain("

    hi

    ".encode_utf16().flat_map(u16::to_le_bytes)) + .collect(); + assert_eq!( + extract(Kind::Html, &utf16, 1 << 20, &Limits::default()).text, + "hi\n" + ); +} + +#[test] +fn the_text_is_cut_at_the_cap_and_says_so() { + let e = extract( + Kind::Html, + "

    word

    ".repeat(1000).as_bytes(), + 100, + &Limits::default(), + ); + assert!(e.truncated); + assert!(e.text.len() <= 101, "{}", e.text.len()); + assert!(!extract(Kind::Html, b"

    a

    ", 100, &Limits::default()).truncated); +} + +#[test] +fn hostile_html_is_bounded_in_time_and_stack() { + let inputs = [ + "".repeat(300_000), + "PDF, images, DWG and DXF drawings, Word, Excel, PowerPoint and CSV, text (Markdown, JSON, XML, source code), IFC, STL, OBJ, PLY and glTF models, video, audio, and what is inside an archive; and, converted in the browser, RTF, the 1997 Office +formats and OpenDocument. One package; each format's engine is +loaded only when a file of that format is opened, and only the engines a +host lists are ever bundled. **[Live demo](https://exav.org/viewer/demo/)** · **[Documentation](https://exav.org/viewer/)** @@ -14,8 +13,9 @@ bundled. checks every message the frame sends. See [Security](https://exav.org/viewer/security/). - Formats browsers do not draw (TIFF, BMP variants, ICO, PNM, QOI, DDS, - farbfeld, HDR, JPEG 2000, JBIG2), DWG/DXF and IFC/STL models are decoded - by exav's own Rust decoders, compiled to WebAssembly and run in workers. So are the + farbfeld, HDR, JPEG 2000, JBIG2, JPEG XL, camera RAW files, shown by the + preview they embed), DWG/DXF drawings and IFC, STL, OBJ, PLY and glTF + models are decoded by exav's own Rust decoders, compiled to WebAssembly and run in workers. So are the JPEG 2000, JBIG2 and fax images of PDFs: pdf.js is given them in place of its OpenJPEG and PDFium decoders (C and C++), which are not copied. The modules import nothing but wasm-bindgen's plumbing: no network, no global @@ -25,6 +25,15 @@ bundled. Content-Security-Policy and offline. - Archives open in place, an archive inside an archive included, with the same plugins. +- Whatever draws something can be exported as a picture: a page, a region of + it in the format's own coordinates (a fraction of a page, pixels of an + image, drawing units, a 3D camera), or what the user sees now + (`controllers.viewport`), drawn again from the document's data at the size + asked for, as an `ImageBitmap` or an image file (`pictures.raster`, + `pictures.toBlob`), in the sandboxed frame too. See + [Pictures of pages](https://exav.org/viewer/api/#pictures-of-pages). +- A session keeps its zoom and place when the same document comes again with + other bytes (`session.replace`), in the sandboxed frame too. - A framework-free core with a plugin contract, and a default React UI on top of it whose every piece can be restyled or replaced. @@ -39,17 +48,96 @@ then the engines of the formats you open (all optional peer dependencies): | Format | Plugin | Install beside it | |---|---|---| | PDF | `pdf()` from `@exav/viewer/pdf` | `pdfjs-dist` (5 or 6) | -| Images | `image({ wasmDecoders: true })` from `@exav/viewer/image` | nothing | +| Images, SVG, camera RAW (DNG, CR2, CR3, NEF, ARW, ORF, RW2, RAF, PEF, SRW, 3FR, IIQ, X3F and more: the largest embedded preview) | `image({ wasmDecoders: true })` from `@exav/viewer/image` | nothing | | DWG, DXF | `dwg()`, `dxf()` from `@exav/viewer/cad` | nothing | | Word, Excel, PowerPoint, CSV | `docx()`, `xlsx()`, `pptx()`, `csv()` from `@exav/viewer/office` | `@silurus/ooxml` | +| RTF, DOC, ODT | `rtf()`, `doc()`, `odt()` from `@exav/viewer/office`, beside `docx()` | `@silurus/ooxml` (read by exav-render's `docs`, a prototype; an external converter and `pdf()` do better) | +| XLS, ODS, Numbers | `xls()`, `ods()`, `numbers()` from `@exav/viewer/office`, beside `xlsx()` | `@silurus/ooxml` (an external converter for the files exav-render's `sheets` does not recognise) | +| PPT, ODP, Keynote, ODG, Visio (VSDX) | `ppt()`, `odp()`, `keynote()`, `odg()`, `visio()` from `@exav/viewer/office`, beside `pptx()` | `@silurus/ooxml` (an external converter and `pdf()` for the files exav-render's `slides` does not recognise, such as the binary .vsd) | +| Binary Visio (VSD), CorelDRAW, Publisher, PageMaker, FreeHand, QuarkXPress, Zoner Draw, WPG | `visio()`, `coreldraw()`, `publisher()`, `pagemaker()`, `freehand()`, `quarkxpress()`, `zonerdraw()`, `wpg()` from `@exav/viewer/office`, beside `pdf()` | an external converter (see below) | +| Text, Markdown, JSON, XML, source code | `text()` from `@exav/viewer/text` | nothing (Markdown is read by a small wasm module, loaded when one is opened) | | IFC | `ifc()` from `@exav/viewer/ifc` | `three` | -| STL | `stl()` from `@exav/viewer/model` | `three` | +| STL, OBJ, PLY, glTF | `stl()`, `obj()`, `ply()`, `gltf()` from `@exav/viewer/model` | `three` | | Video, audio | `video()`, `audio()` from `@exav/viewer/media` | nothing | | Archives | `archive()` from `@exav/viewer/archive` | `@exav/unpack-wasm` | | Default UI | `@exav/viewer/react` | `react`, `react-dom` (19) | IFC models (IFC2X3, IFC4, IFC4X3) are read and meshed by exav's own engine -(exav-render), and three.js draws them. +(exav-render), and three.js draws them. Categories and building storeys +(`controllers.storeys`, for a model with two or more) can be hidden. + +XLS, ODS and Numbers are converted to XLSX, shown by `xlsx()` as a +workbook; PPT, ODP, Keynote, ODG and VSDX (a page to a slide) to PPTX, shown +by `pptx()`; RTF, DOC and ODT to DOCX, shown by `docx()`. All by exav-render, +which the package ships as WebAssembly modules +(`wasm/exav_viewer_sheets_bg.wasm`, `..._slides_...`, `..._docs_...`): no +install is needed, and nothing is fetched besides the module. What no +converter of the package reads (binary Visio, CorelDRAW, Publisher, +PageMaker, FreeHand, QuarkXPress, Zoner Draw and WordPerfect Graphics +files, a CSV or a DIF saved as .xls) goes to an external converter if the +host set one, and ends in the "needs_converter" error, with the format's +name, if not. Each plugin also takes its templates (.dot, .pps, .pot, .xlt, +.ott, .otp, .ots, .otg, Visio's), the flat XML OpenDocument files (.fodt, +.fodp, .fods, .fodg), and Word 6.0/95 and Excel 2.1 to 95 files. What the +bytes are decides, within each kind: an RTF saved as .doc is read as RTF, +an XLSX named .xls is shown unconverted. The sandboxed viewer opens these +formats only when named in `formats` (`BUILTIN_ORDER` names every one). + +A file a converter of the package recognises and cannot convert (encrypted +with a password, too large, damaged, of a version it does not read) ends in +the Office error, whose cause is a `SheetConversionError`, +`SlideConversionError` or `DocConversionError` naming the reason in its +`kind`; it is never handed to the external converter, which gets only what +the package's converters do not recognise. exav-render reads PowerPoint 95 +and 97 to 2003 (2 to 4 are recognised and reported as unsupported), Excel 2 +to 2003, the text formats Excel opens from a .xls file (XML Spreadsheet +2003, HTML tables, SYLK), OpenDocument drawings (a page to a slide, in the +largest page's size) and Keynote and Numbers of every version. Text +documents (DOC, ODT, RTF) are read by `docs`, a prototype, where no external +converter is set; with one, text documents go to it. + +### The external converter + +`rtf({ externalConverter })` and the other plugins take `converter` and +`externalConverter` (a plugin's options, or `conversion` in the sandboxed +viewer's): + +- `converter: "exav"` (the default): the converters of the package, then the + external one for what they do not read. For text documents, the external + converter first when there is one. +- `converter: "external"`: the external converter alone, for every format. +- `externalConverter`: where the converter is. A URL (absolute, rooted, or + relative to the page) of an ES module, or the name of a package copied + beside the viewer's assets, which `exavViewer({ externalConverter: + "" })` of `@exav/viewer/vite` (or `exav-viewer-assets + --external-converter `) copies: its published files go under + `assetBase`, and `manifest.json` names its entry. Default: none. + +The module exports `convert`, and may export `prefetch`: + +```ts +export function convert(bytes: Uint8Array, options: { + hint: string; // what the viewer took the file for: "rtf", "doc", "odt", "ppt", + // "odp", "keynote", "xls", "ods", "numbers", "odg", "visio", + // "coreldraw", "publisher", "pagemaker", "freehand", "quarkxpress", + // "zonerdraw" or "wpg": a hint, the bytes decide + maxMemory?: number; // bytes, when the host set the `maxMemory` option + signal?: AbortSignal; // aborted when the file is closed or `timeoutMs` passes: reject + onProgress?: (p: ConvertProgress) => void; +}): Promise<{ format: "pdf" | "xlsx"; bytes: Uint8Array }>; + +export function prefetch?(hint: string): string[] | Promise; // URLs, relative to the module +``` + +`ConvertProgress` is `{ stage: "download"; loaded; total }` (the module's +code and data arriving, `total` 0 when not known), `{ stage: "start" }`, and +`{ stage: "load" | "export"; done; total }`; the viewer shows them as its +loading and converting steps. A PDF is shown by `pdf()`, an XLSX by +`xlsx()`: register them. A rejection ends in the Office error, its cause an +`ExternalConversionError` (`kind` "failed", with the module's error as its +cause, or "timeout": the viewer stops waiting after `timeoutMs` whether or +not the module looks at its signal). `e2e/fixtures/fake-converter.js` is a +minimal module that implements it. ## Use @@ -103,6 +191,12 @@ import { SandboxedViewerProvider } from "@exav/viewer/react/sandbox"; ``` +The converted formats in the frame: name them, `sandbox={{ url, formats: +BUILTIN_ORDER }}`. For an external converter, install it where the frame +is built and give `exavViewer({ frameDir, externalConverter: "" })` +its name (it is copied beside the frame app, and the frame's +`conversion.externalConverter` names it). + The host reads each file for the frame: a PDF or a ZIP by HTTP range requests, as the frame asks for parts, the rest whole. Video, audio and images can go to the frame by URL instead, from origins written into its @@ -121,11 +215,20 @@ import { pdf } from "@exav/viewer/pdf"; const viewer = createViewer({ plugins: [pdf()] }); const session = viewer.mount(element, { id: "1", name: "report.pdf", source: { url: "/files/report.pdf" } }); -session.status.subscribe((s) => console.log(s.phase)); +// While loading: what it is doing (`step`: "file", "engine", "convert"...) and how far (`progress`, 0 to 1) +session.status.subscribe((s) => console.log(s.phase, "step" in s && s.step, "progress" in s && s.progress)); // later session.destroy(); ``` +`createSandboxedViewer` from `@exav/viewer/frame` gives the same sessions +for files in the frame. A host can draw every control itself from their +controllers (pages, zoom, sheets, a player's controls, thumbnails, +printing) with no React and no stylesheet in its page; the frame takes the +page's colours and hands back the arrow keys and Escape. See +[Integration](https://exav.org/viewer/integration/#a-host-that-draws-its-own-ui) +and `demo/headless.html`. + Other bundlers: copy the assets with the command the package installs, and serve the directory as the viewer's `assetBase` (default `/exav-viewer/`): @@ -136,7 +239,9 @@ npx exav-viewer-assets public/exav-viewer --frame public/exav-frame --frame-orig ``` The files are under versioned directories, so `assetBase` can be served -`Cache-Control: immutable`. The package's own wasm, workers and fonts are +`Cache-Control: immutable`. With an `externalConverter` they include that +package's published files under `converters///`. The +package's own wasm, workers and fonts are referenced with `new URL("...", import.meta.url)`, which Vite, webpack 5 and Parcel follow and emit; Rollup and esbuild need a plugin for it. @@ -159,6 +264,8 @@ MIT. The DWG module contains the NewStroke font (CC0-1.0) and encoding_rs, whose WHATWG data is BSD-3-Clause; the bundled fonts are under the SIL Open Font License. See NOTICE and LICENSES/. The engines installed beside the package keep their own licences: pdf.js is Apache-2.0 (and its ICC engine, -qcms, MIT), the others MIT. The sandboxed frame app +qcms, MIT), the others MIT; an external converter you install keeps its +own. +The sandboxed frame app (`dist/frame/app/`) bundles them, with their licence files in its `licenses/`. diff --git a/crates/exav-viewer/demo/examples/NOTICE.md b/crates/exav-viewer/demo/examples/NOTICE.md new file mode 100644 index 00000000..722c822e --- /dev/null +++ b/crates/exav-viewer/demo/examples/NOTICE.md @@ -0,0 +1,2912 @@ +# Notices of the example files + +The demo of `@exav/viewer` lists the files of this directory's `examples.json`. Each has a free licence +that was read at the page named under "Evidence", or is made by a script of this repository and +given to the public domain (CC0-1.0). This file is written by `generate/make-attributions.mjs`; do not edit it. + +235 files: 145 are committed under `files/` (5.6 MB); 90 are downloaded by `npm run demo:examples` (83.0 MB). + +To rebuild: run the scripts of `generate/` (each lists what it needs; `python3 -I generate/make-images.py`, `make-cad.py`, +`make-models.py`, `make-ifc.py`, `make-text.py`, `make-archives.py`, `make-media.py`, `make-pdf.py`, `make-office.py`), then +`node generate/make-manifest.mjs` and `node generate/make-attributions.mjs`. `node check.mjs` checks the manifest and the +files, `node verify-open.mjs` opens every example with the viewer's own engines, and `node generate/verify-evidence.mjs` +reads the licence pages again. `catalog.json` is where each example is described by hand. + +Where the pictures made here hold text, it is set in Aileron, the font Pillow bundles, which its author gives to the +public domain (CC0-1.0, "No Rights Reserved": https://dotcolon.net/font/aileron/). Drawings name their fonts and +embed none; documents that embed fonts embed the Liberation fonts (SIL OFL 1.1, which lets documents embed them). + +## PDF + +### field-report.pdf + +- What: Field report, six pages (pdf, 104 KB) +- Shows: a table of contents whose lines are links, a two-level outline, roman and arabic page labels, internal and external links, a table, bar, line and pie charts as vectors, a JPEG, standard and embedded fonts (Latin, Greek, Cyrillic), document information +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-pdf.py` +- SHA-256: 75b7649d62fb12034d56478c98baa92beba3c0a2117ce0cfcfffa9233d060a5b + +### application-form.pdf + +- What: Berth application form (AcroForm) (pdf-form, 19 KB) +- Shows: text fields (multiline, maximum length, password, read-only), check boxes, radio buttons, a drop-down list, a list box, some filled in +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-pdf.py` +- SHA-256: 883dff8626a2a95d66d74bd8876ceee621197902de671b7c036d39e21774c50b + +### annotations.pdf + +- What: Annotations without appearance streams (pdf-annotations, 6.1 KB) +- Shows: links to a URL, a page and a named destination, highlight, underline, strike-out, squiggly, note, free text, square, circle, line with arrows, polygon, polyline, ink, stamp, file attachment, an outline and a named destination +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-pdf.py` +- SHA-256: 212ea14f43e151d2d477b73659ef4fdb50735ee337eb76283d065ba9ec1701e9 + +### scan-mixed.pdf + +- What: Scanned letters, one page for each scanner image filter (pdf-scan, 201 KB) +- Shows: four pages: DCTDecode (JPEG), CCITTFaxDecode (Group 4), JBIG2Decode and JPXDecode (JPEG 2000), each with an invisible OCR text layer; the viewer decodes the last three with its own decoders +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-pdf.py` (with opj_compress, jbig2, tiffcp) +- SHA-256: daeb8cab36d82d4e5391041effc7d37dddf2421a87c3853c4b763e4a4d534776 + +### vector-art.pdf + +- What: Vector art (pdf, 4.3 KB) +- Shows: axial and radial shadings, transparency, a clipping path, dashes, caps and joins, Bezier curves, an even-odd fill, text with render modes, rotated and scaled text +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-pdf.py` +- SHA-256: fad466f385e89bc008128072d0b16544144c8a42983afd5d444079e59519e08e + +### handbook-tagged.pdf + +- What: Harbour handbook as a tagged PDF/A-2 (pdf, 188 KB) +- Shows: bookmarks from the headings, hyperlinks, tags, footnotes, a landscape section, embedded Liberation fonts +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: c88a1434c1cbf8a8af467325bb413463276bbe70c84b5f88760638ff5975773d + +### arxiv-rational-cherednik-torus-knots.pdf + +- What: Rational Cherednik Algebras and Torus Knot Invariants (arXiv:2402.18770) (pdf, 421 KB) +- Shows: 28 pages of mathematics, 24 outline entries, 256 links, set in LaTeX +- Author: Xinchun Ma +- Source: https://arxiv.org/abs/2402.18770v1 +- Licence: CC-BY-4.0 +- Evidence: https://arxiv.org/abs/2402.18770v1 +- Words read there: "href="http://creativecommons.org/licenses/by/4.0/" title="Rights to this article"" +- Attribution: Xinchun Ma, Rational Cherednik Algebras and Torus Knot Invariants, arXiv:2402.18770v1, CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Not modified. +- Origin: downloaded from https://arxiv.org/pdf/2402.18770v1 +- SHA-256: 2b22841d8a64b271d7def6fa15aa0b5b98f533ae259e4108eb7104320f07cf10 + +### nist-sp-800-63-4-digital-identity.pdf + +- What: NIST SP 800-63-4, Digital Identity Guidelines (pdf, 838 KB) +- Shows: 96 tagged pages, 54 outline entries, 227 links +- Author: David Temoshok, Diana Proud-Madruga, Yee-Yin Choong et al., NIST +- Source: https://csrc.nist.gov/pubs/sp/800/63/4/final +- Licence: public-domain (US federal government work, 17 U.S.C. 105; the PDF says so itself) +- Evidence: https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-4.pdf +- Words read there: "is not subject to copyright in the United States." +- Origin: downloaded from https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-4.pdf +- SHA-256: 2f5b107de218a0fc3fe7f7e91b2ece5babb001eda5745b7c568a3cc876002f2d + +### scan-cfa-minutes-1971.pdf + +- What: Minutes of the U.S. Commission of Fine Arts, 1970 to 1972 (scan) (pdf-scan, 1.1 MB) +- Shows: 40 scanned pages: JPEG 2000 pictures and JBIG2 masks, an OCR text layer, PDF/A-2B +- Author: U.S. Commission of Fine Arts; scanned by the Internet Archive +- Source: https://archive.org/details/cfaminutes21april1971 +- Licence: public-domain (US federal government work, 17 U.S.C. 105) +- Evidence: https://commons.wikimedia.org/wiki/File:July_8_1970_to_May_19_1972_(IA_cfaminutes21april1971).pdf +- Words read there: "This work is in the public domain in the United States because it is a work prepared by an officer o" +- Origin: downloaded from https://archive.org/download/cfaminutes21april1971/cfaminutes21april1971.pdf +- SHA-256: 2fc5edb8a4f303b89449443dcb5a32249c79b2a8d67253348025d0e7eea5f94b + +### scan-blm-land-survey-records.pdf + +- What: Land and survey records, Bureau of Land Management (scan) (pdf-scan, 2.1 MB) +- Shows: 40 pages scanned at about 500 ppi: JPEG 2000 and JBIG2 images, an OCR text layer, PDF/A-2B +- Author: United States Bureau of Land Management; scanned by the Internet Archive +- Source: https://archive.org/details/landsurveyrecord00unit +- Licence: public-domain (US federal government work, 17 U.S.C. 105) +- Evidence: https://commons.wikimedia.org/wiki/File:Land_%26_survey_records_-_record_preservation_%26_service_through_microfilm_(IA_landsurveyrecord00unit).pdf +- Words read there: "This work is in the public domain in the United States because it is a work prepared by an officer o" +- Origin: downloaded from https://archive.org/download/landsurveyrecord00unit/landsurveyrecord00unit.pdf +- SHA-256: d9a9e6583d0afb1688a043ef59a8d2e236e20c58d4ba76ff625d7c11a181c16c + +### form-uscis-i9.pdf + +- What: USCIS Form I-9, Employment Eligibility Verification (pdf-form, 512 KB) +- Shows: 115 text fields, 5 drop-down lists, 8 check boxes, links; a tagged government form +- Author: U.S. Citizenship and Immigration Services (DHS) +- Source: https://www.uscis.gov/i-9 +- Licence: public-domain (US federal government work, 17 U.S.C. 105) +- Evidence: https://www.uscis.gov/website-policies +- Words read there: "Information presented on this WWW site is considered public information and may be distributed or co" +- Origin: downloaded from https://www.uscis.gov/sites/default/files/document/forms/i-9.pdf +- SHA-256: 780f348c34df694bb0b4dbbfaf9f22b99b9757b80d16a37ba89aadf069597281 + +### map-nps-canyonlands.pdf + +- What: Canyonlands National Park, Island in the Sky: roads and trails (pdf-map, 341 KB) +- Shows: two tagged pages of vector map +- Author: National Park Service +- Source: https://home.nps.gov/cany/planyourvisit/maps.htm +- Licence: public-domain (US federal government work, 17 U.S.C. 105) +- Evidence: https://www.nps.gov/aboutus/disclaimer.htm +- Words read there: "material created by the National Park Service and presented on this website, unless otherwise indica" +- Origin: downloaded from https://home.nps.gov/cany/planyourvisit/upload/IslandRoadsandTrailsUpdated24-508forweb.pdf +- SHA-256: b29a639d9bc0d2e37ca41b68c21bcddba7637b1727dce86980c3373715610d55 + +### slides-unige-open-access.pdf + +- What: Open Access publishing at no cost thanks to UNIGE agreements (slides) (pdf, 782 KB) +- Shows: ten slides with outline, links, tags and pictures +- Author: Floriane Muller, Anouk Santos (University of Geneva) +- Source: https://zenodo.org/records/23478694 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/23478694 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: Floriane Muller and Anouk Santos, Open Access publishing at no cost thanks to UNIGE agreements, in 15 minutes, Zenodo, https://doi.org/10.5281/zenodo.23478694, CC BY 4.0. Not modified. +- Origin: downloaded from https://zenodo.org/api/records/23478694/files/2026-10-20_Read_and_publish_en15min_EN_zenodo.pdf/content +- SHA-256: 03356ed569a80f7d7dc1d96fcbfd75a2fbc663c63f6d74f4e89eb2390bb5ee64 + +### paper-ja-labor-law.pdf + +- What: A paper in Japanese on labour law and the constitution (pdf, 860 KB) +- Shows: 25 pages with embedded MS Mincho fonts +- Author: Takashi Fujiki (藤木 è²´å�²) +- Source: https://zenodo.org/records/23469422 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/23469422 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: 藤木 è²´å�², アメリカ労åƒ�法ã�‹ã‚‰ã�¿ã�Ÿæ†²æ³•ã�®å½±éŸ¿, アメリカ憲法ã�®ç�†è«–ã�¨å®Ÿè·µ 創刊å�·, Zenodo, https://doi.org/10.5281/zenodo.23469422, CC BY 4.0. Not modified. +- Origin: downloaded from https://zenodo.org/api/records/23469422/files/%E3%82%A2%E3%83%A1%E3%83%AA%E3%82%AB%E5%8A%B4%E5%83%8D%E6%B3%95%E3%81%8B%E3%82%89%E3%81%BF%E3%81%9F%E6%86%B2%E6%B3%95%E3%81%AE%E5%BD%B1%E9%9F%BF.pdf/content +- SHA-256: 08b2be3ebc781107f71003344ccf8b230229079e978687f1e0da2329bef1cbe9 + +## Word, Excel, PowerPoint + +### handbook.docx + +- What: Harbour handbook (Word) (docx, 25 KB) +- Shows: title page, table of contents with links, outline numbering, character and paragraph styles, bulleted and numbered lists of three levels, tables with merged cells and a repeated header, two pictures (one floating with wrapped text), a shape, footnotes, hyperlinks, bookmarks, fields, a comment, tracked changes, a two-column section, a landscape section with a wide table, headers and footers with page numbers +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: 46c4fb53949dbd25cfc8b1fecbbd282d470b767d16c1fea426394872fcffc95d + +### workbook.xlsx + +- What: Harbour workbook (Excel) (xlsx, 23 KB) +- Shows: three sheets: a budget with formulas (SUM, AVERAGE, INDEX, MATCH, COUNTIF, IF, VLOOKUP, ROUND), currency, percent and date formats, merged title, frozen header rows, a validation list, a comment, a hyperlink and named ranges; 120 rows of sales with an autofilter; a bar, a line and a pie chart +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: 3e3e0247bde87b6fa1c6f9b06f0561f2eae8bc326925b4b28ba2d7d3b8c12792 + +### deck.pptx + +- What: Harbour review (PowerPoint) (pptx, 30 KB) +- Shows: five slides: a gradient title slide, nested bullets, a native bar chart with a callout, a table, a picture with shapes, gradients and arrows; a master with footer and slide number; speaker notes (not shown) and transitions +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: 160962428c7f1d15c8865d51fef7e74ebc8ccc6782f60ea4bf08ed0a805a7edb + +### docx4j-getting-started.docx + +- What: docx4j Getting Started (Word) (docx, 153 KB) +- Shows: a manual with styles, tables, pictures and links +- Author: Jason Harrop and the docx4j contributors +- Source: https://github.com/plutext/docx4j/blob/c95b55be6832ff716131ffdbd7006755db92f696/docs/Docx4j_GettingStarted.docx +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/legals/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: docx4j, Jason Harrop / Plutext, https://github.com/plutext/docx4j, Apache-2.0. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/docs/Docx4j_GettingStarted.docx +- SHA-256: a518cffcbef0b60cf5c95930acd9af9a56484e47376c5f0c4d6e3a15a0799941 + +### docx4j-two-charts.docx + +- What: docx4j chart test: two charts (Word) (docx, 41 KB) +- Shows: two native charts in a document +- Author: Jason Harrop and the docx4j contributors +- Source: https://github.com/plutext/docx4j/blob/c95b55be6832ff716131ffdbd7006755db92f696/docx4j-chart-render/src/test/resources/charts/render/office/p3-two-charts.docx +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/legals/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: docx4j, Jason Harrop / Plutext, https://github.com/plutext/docx4j, Apache-2.0. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/docx4j-chart-render/src/test/resources/charts/render/office/p3-two-charts.docx +- SHA-256: e0bc29916de415c53c03dcee14817bbd1e3eeea8f54a2b76629fe6754e6c641e + +### gsa-policy-review-template.xlsx + +- What: Section508.gov Policy Review Template (Excel) (xlsx, 217 KB) +- Shows: a government workbook with formatted tables +- Author: U.S. General Services Administration and the agencies that wrote the documents (Section508.gov) +- Source: https://github.com/GSA/Assets.Section508.gov/blob/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Policy%20Review%20Template.xlsx +- Licence: CC0-1.0 +- Evidence: https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/LICENSE.md +- Words read there: "this project is in the public domain within the United States of America. Additionally, we waive copyright and" +- Origin: downloaded from https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Policy%20Review%20Template.xlsx +- SHA-256: 840661eba4653879dca24f209bca1619f7b168ef1232b595df5713741b2465bd + +### closedxml-pivot-tables.xlsx + +- What: ClosedXML example: pivot tables (Excel) (xlsx, 30 KB) +- Shows: pivot tables over a data sheet +- Author: ClosedXML contributors +- Source: https://github.com/ClosedXML/ClosedXML/blob/09f2f21a60e821ce7fb18ca3576b272e5dd6d921/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx +- Licence: MIT +- Evidence: https://raw.githubusercontent.com/ClosedXML/ClosedXML/09f2f21a60e821ce7fb18ca3576b272e5dd6d921/LICENSE +- Words read there: "MIT License Copyright (c) 2016 ClosedXML" +- Attribution: Copyright (c) 2016 ClosedXML, MIT, https://github.com/ClosedXML/ClosedXML. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/ClosedXML/ClosedXML/09f2f21a60e821ce7fb18ca3576b272e5dd6d921/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx +- SHA-256: d13f149091c804cfe9b02c81301ea7ce13d20d1f3311cac799151541d0467d02 + +### xlsxwriter-bar-chart.xlsx + +- What: XlsxWriter test file: a bar chart (Excel) (xlsx, 14 KB) +- Shows: a native bar chart saved in Excel +- Author: John McNamara (XlsxWriter) +- Source: https://github.com/jmcnamara/XlsxWriter/blob/5d4606d89a955226d2d0825a0f44309043ae7251/xlsxwriter/test/comparison/xlsx_files/chart_bar14.xlsx +- Licence: BSD-2-Clause +- Evidence: https://raw.githubusercontent.com/jmcnamara/XlsxWriter/5d4606d89a955226d2d0825a0f44309043ae7251/LICENSE.txt +- Words read there: "BSD 2-Clause License Copyright (c) 2013-2025, John McNamara" +- Attribution: Copyright (c) 2013-2025, John McNamara, BSD-2-Clause, https://github.com/jmcnamara/XlsxWriter. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/jmcnamara/XlsxWriter/5d4606d89a955226d2d0825a0f44309043ae7251/xlsxwriter/test/comparison/xlsx_files/chart_bar14.xlsx +- SHA-256: c4ed1e8117b4a0e014b95f42361c7e060728b398d4ea650395d902557429cdd2 + +### python-pptx-chart-types.pptx + +- What: python-pptx test file: one slide for each chart type (PowerPoint) (pptx, 1.3 MB) +- Shows: many native chart types +- Author: Steve Canny (python-pptx) +- Source: https://github.com/scanny/python-pptx/blob/278b47b1dedd5b46ee84c286e77cdfb0bf4594be/features/steps/test_files/cht-chart-type.pptx +- Licence: MIT +- Evidence: https://raw.githubusercontent.com/scanny/python-pptx/278b47b1dedd5b46ee84c286e77cdfb0bf4594be/LICENSE +- Words read there: "The MIT License (MIT) Copyright (c) 2013 Steve Canny" +- Attribution: Copyright (c) 2013 Steve Canny, MIT, https://github.com/scanny/python-pptx. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/scanny/python-pptx/278b47b1dedd5b46ee84c286e77cdfb0bf4594be/features/steps/test_files/cht-chart-type.pptx +- SHA-256: d0a7979d05970aa83fd8406f0829cd0019853a7bc4e16f8cda814586fc2eaccc + +### cdc-lifecycle-508.pptx + +- What: Integrating Section 508 Requirements in the Development Lifecycle (PowerPoint) (pptx, 598 KB) +- Shows: a government deck with pictures and diagrams +- Author: Mark D. Urban, CDC Section 508 Coordinator (US federal government) +- Source: https://github.com/GSA/Assets.Section508.gov/blob/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Integrating%20Section%20508%20Requirements%20in%20the%20Development%20Lifecycle.pptx +- Licence: CC0-1.0 +- Evidence: https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/LICENSE.md +- Words read there: "this project is in the public domain within the United States of America. Additionally, we waive copyright and" +- Origin: downloaded from https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Integrating%20Section%20508%20Requirements%20in%20the%20Development%20Lifecycle.pptx +- SHA-256: 51c7d6af2e6682e5ee66204b813a45488e62b0e6e13cecc9207a982fdeb1720a + +## Other office formats + +### handbook.doc + +- What: Harbour handbook (Word 97 to 2003) (doc, 66 KB) +- Shows: the handbook as a binary .doc +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: 807c9b812188d73b3558ea8b91360d7a39eb637a18172295f206612da92cce39 + +### handbook.odt + +- What: Harbour handbook (OpenDocument text) (odt, 41 KB) +- Shows: the handbook as .odt +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: d28f2667008ebd0e9456f7900eb4af8dbbd10b5af7490a059d6868d468c475ce + +### handbook.fodt + +- What: Harbour handbook (flat OpenDocument text) (fodt, 76 KB) +- Shows: one XML file, pictures in base64; written by hand, not converted +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with ) +- SHA-256: 7c9f74181b5c9ad2b4766d5e3c82d9edf2c1cdd370417d0f8592d00ec71beec9 + +### handbook.rtf + +- What: Harbour handbook (Rich Text Format) (rtf, 94 KB) +- Shows: the handbook as .rtf +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: 04eea9909d09ed87fb621459f1ac6e024b094eb767c1d0f7d536dd76f0b82e43 + +### workbook.xls + +- What: Harbour workbook (Excel 97 to 2003) (xls, 29 KB) +- Shows: the workbook as a binary .xls +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: 8552c433665e927723ecb747c5a0e1696076964f1bfd20ddd30bc3b95cc97e19 + +### workbook.ods + +- What: Harbour workbook (OpenDocument spreadsheet) (ods, 45 KB) +- Shows: the workbook as .ods +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: 32b0cf2984ddef9c2209d4cdb874866a65217d0b6bcdd3417264982ef07d660a + +### workbook.fods + +- What: Harbour workbook (flat OpenDocument spreadsheet) (fods, 118 KB) +- Shows: one XML file; written by hand, not converted +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with ) +- SHA-256: 80f5ad00faf7555d5aab526226204658880127a91f0af62a439d106c7a52e1be + +### deck.ppt + +- What: Harbour review (PowerPoint 97 to 2003) (ppt, 480 KB) +- Shows: the deck as a binary .ppt (its thumbnail makes it large) +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: 8a6285ae02086db5ddb3246c75ef08a3508f672a91ba38b30efae291e119fb1e + +### deck.odp + +- What: Harbour review (OpenDocument presentation) (odp, 71 KB) +- Shows: the deck as .odp +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: e194842a5abfb245477c00eee30736ef7f8460606b62b887bf8392283b41dd07 + +### deck.fodp + +- What: Harbour review (flat OpenDocument presentation) (fodp, 31 KB) +- Shows: one XML file; written by hand, not converted +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with ) +- SHA-256: 07da109d15eceb65d7a0601998ed9508ecd140d5aef311301c96e7aad6faa236 + +### diagram.odg + +- What: Flowchart (OpenDocument drawing) (odg, 26 KB) +- Shows: two pages, layers (one hidden), connectors glued to shapes, groups, gradients, a curve and a polyline, shadows, dashes +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with soffice) +- SHA-256: 8c80d0ef2e536db14a7e5a92fc0092ab4f0976c87a091875899d3a7655f5b56e + +### diagram.fodg + +- What: Flowchart (flat OpenDocument drawing) (fodg, 14 KB) +- Shows: one XML file; written by hand, not converted +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-office.py` (with ) +- SHA-256: 52ed359ccb0acec09a2d7401129021ff3f0ad69e35c770eab33986f063924203 + +### poi-sample.doc + +- What: Apache POI test document (Word 97) (doc, 27 KB) +- Shows: a binary .doc +- Author: Apache POI committers +- Source: https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/SampleDoc.doc +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/SampleDoc.doc +- SHA-256: 717585c0a88f20862c86220163dd03da87cd743970354c6b29add32725f2e833 + +### poi-word95.doc + +- What: Apache POI test document (Word 6 and 95 format) (doc, 100 KB) +- Shows: the pre-1997 Word format +- Author: Nick Burch (Apache POI) +- Source: https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/Word95.doc +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/Word95.doc +- SHA-256: a7b9557e917daf29cfee6baaa90226fbfa7900ffa05cc35bdc03aafdefef5789 + +### dhs-directive-139-02.doc + +- What: DHS Directive 139-02 (Word 97) (doc, 81 KB) +- Shows: a government directive in the binary .doc format +- Author: U.S. Department of Homeland Security +- Source: https://github.com/GSA/Assets.Section508.gov/blob/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Directive_139.2_Final_10-28-2008.doc +- Licence: CC0-1.0 +- Evidence: https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/LICENSE.md +- Words read there: "this project is in the public domain within the United States of America. Additionally, we waive copyright and" +- Origin: downloaded from https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Directive_139.2_Final_10-28-2008.doc +- SHA-256: 48d1c85456d6b515ce8e3db6b43024f90bac69be86a3e78c1ae08406d4cad493 + +### poi-formula-eval.xls + +- What: Apache POI test workbook of formulas (Excel 97) (xls, 174 KB) +- Shows: hundreds of formula cases +- Author: Apache POI committers +- Source: https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/FormulaEvalTestData.xls +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/FormulaEvalTestData.xls +- SHA-256: c2040c1fac1088f1e112abef197ae1f9bcb03547861783b89c1d4281a42049d1 + +### poi-conditional-formats.xls + +- What: Apache POI test workbook of conditional formats (Excel 97) (xls, 44 KB) +- Shows: colour scales, icon sets and data bars +- Author: Nick Burch (Apache POI) +- Source: https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/NewStyleConditionalFormattings.xls +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/NewStyleConditionalFormattings.xls +- SHA-256: 899a29aa6ce35fee0268847ff8b5d9459e6ce34d5e2ff462fd42ee9b80837b87 + +### poi-excel5.xls + +- What: Apache POI test workbook (Excel 5 and 95 format) (xls, 7.5 KB) +- Shows: the BIFF5 format of 1993 to 1995 +- Author: Apache POI committers +- Source: https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/testEXCEL_5.xls +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/testEXCEL_5.xls +- SHA-256: ac0ce3a2eff43e32e56d6b789df6aaa1645ae846d22fa3a2fe3057391d0f1cae + +### poi-sample-show.ppt + +- What: Apache POI test presentation (PowerPoint 97) (ppt, 123 KB) +- Shows: a binary .ppt +- Author: Apache POI committers +- Source: https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/slideshow/SampleShow.ppt +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/slideshow/SampleShow.ppt +- SHA-256: 64275a6685b8f267827178037dcc59de927475e56385725916f4c6039c7ce7f6 + +### tika-rtf-various.rtf + +- What: Apache Tika test document: various RTF features (rtf, 64 KB) +- Shows: fonts, colours, tables and lists in RTF +- Author: Michael McCandless (Apache Tika) +- Source: https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testRTFVarious.rtf +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testRTFVarious.rtf +- SHA-256: cf00c56fe7099d14b820a0d751324e415f2c09af580f2c96f08614a2aec42c1e + +### tdf-thesis-template.odt + +- What: Thesis template in Spanish (OpenDocument text) (odt, 66 KB) +- Shows: styles, a table of contents, headers +- Author: Phorious (Document Foundation wiki user) +- Source: https://wiki.documentfoundation.org/File:Tesis-v1.0.4.odt +- Licence: CC0-1.0 +- Evidence: https://wiki.documentfoundation.org/File:Tesis-v1.0.4.odt +- Words read there: "This file is made available under the Creative Commons CC0 1.0 Universal Public Domain Ded" +- Origin: downloaded from https://wiki.documentfoundation.org/images/5/54/Tesis-v1.0.4.odt +- SHA-256: ec323e9fde67103a7f0039d8a59625bd7e2c5afa9e057563f5064c6b68d442a1 + +### tika-odt-ooo3.odt + +- What: Apache Tika test document written with OpenOffice 3 (OpenDocument text) (odt, 24 KB) +- Shows: an .odt of 2009 +- Author: Apache Tika committers +- Source: https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testODFwithOOo3.odt +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testODFwithOOo3.odt +- SHA-256: b6cd74b570a98fcb6d838dd85ab869604e0711812d9533d5ecde0000b24ba7ef + +### tdf-household-budget.ods + +- What: Household budget book (OpenDocument spreadsheet) (ods, 21 KB) +- Shows: formulas, charts and several sheets +- Author: Hrbrgr (Document Foundation wiki user) +- Source: https://wiki.documentfoundation.org/File:Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods +- Licence: CC-BY-3.0 +- Evidence: https://wiki.documentfoundation.org/File:Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods +- Words read there: "This work is licensed under the Attribution 3.0 Unported License" +- Attribution: Hrbrgr (Document Foundation wiki user), Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods, https://wiki.documentfoundation.org/File:Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods, CC BY 3.0. No changes. +- Origin: downloaded from https://wiki.documentfoundation.org/images/b/bc/Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods +- SHA-256: aacd76666860f7bf879e1518a07b90d535815ce92d076ea26c835061b8aff854 + +### odfdo-flat.fods + +- What: odfdo test sample (flat OpenDocument spreadsheet) (fods, 32 KB) +- Shows: one XML file +- Author: Jérôme Dumonteil (odfdo) +- Source: https://github.com/jdum/odfdo/blob/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/test_flat_lo.fods +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Copyright Jérôme Dumonteil, odfdo, Apache-2.0, https://github.com/jdum/odfdo. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/test_flat_lo.fods +- SHA-256: 4966023bf427dbc9c975dc9b11816b4a90158f7dab452c59104b8800fb27ec36 + +### tdf-presentation-template.odp + +- What: Presentation template (OpenDocument presentation) (odp, 403 KB) +- Shows: master slides, pictures and layouts +- Author: Phorious (Document Foundation wiki user) +- Source: https://wiki.documentfoundation.org/File:Presenta.odp +- Licence: CC0-1.0 +- Evidence: https://wiki.documentfoundation.org/File:Presenta.odp +- Words read there: "This file is made available under the Creative Commons CC0 1.0 Universal Public Domain Ded" +- Origin: downloaded from https://wiki.documentfoundation.org/images/5/51/Presenta.odp +- SHA-256: 147ca9c6dbcdd45470627b84c04824c7f3e07de57485a8190018eea5f23c64b3 + +### odfdo-example.odp + +- What: odfdo test sample (OpenDocument presentation) (odp, 54 KB) +- Shows: slides with text and shapes +- Author: Jérôme Dumonteil (odfdo) +- Source: https://github.com/jdum/odfdo/blob/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/example.odp +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Copyright Jérôme Dumonteil, odfdo, Apache-2.0, https://github.com/jdum/odfdo. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/example.odp +- SHA-256: 9075b87e36bb4618bdc9a8a6138bcaaf31b2b81e089e9006ac906e66a94f912b + +### tdf-diagram-gallery.odg + +- What: A gallery of diagrams (OpenDocument drawing) (odg, 89 KB) +- Shows: several diagram types +- Author: Hrbrgr (Document Foundation wiki user) +- Source: https://wiki.documentfoundation.org/File:Diagramme.odg +- Licence: CC-BY-3.0 +- Evidence: https://wiki.documentfoundation.org/File:Diagramme.odg +- Words read there: "This work is licensed under the Attribution 3.0 Unported License" +- Attribution: Hrbrgr (Document Foundation wiki user), Diagramme.odg, https://wiki.documentfoundation.org/File:Diagramme.odg, CC BY 3.0. No changes. +- Origin: downloaded from https://wiki.documentfoundation.org/images/0/07/Diagramme.odg +- SHA-256: 2e5f43734db7c600256e0670f6ec9da9428eb6b7a074c4742fd506e32c833a5a + +### odfdo-shapes.odg + +- What: odfdo test sample: basic shapes (OpenDocument drawing) (odg, 10 KB) +- Shows: rectangles, ellipses, lines and polygons +- Author: Jérôme Dumonteil (odfdo) +- Source: https://github.com/jdum/odfdo/blob/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/base_shapes.odg +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Copyright Jérôme Dumonteil, odfdo, Apache-2.0, https://github.com/jdum/odfdo. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/base_shapes.odg +- SHA-256: dfe6a6c83ba5caec340889d6c77a899e420060d55f4b08fd9a1fb7b36e0b799f + +### tika-keynote09.key + +- What: Apache Tika test presentation (Keynote '09) (keynote, 217 KB) +- Shows: an XML Keynote document (index.apxl) +- Author: Jukka Zitting (Apache Tika) +- Source: https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote.key +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote.key +- SHA-256: 4a270c2427f11d77f82fae696e3fd6eda945bae0422ae87c5fac6cf2bd06015c + +### tika-keynote2013.key + +- What: Apache Tika test presentation (Keynote 2013, IWA format) (keynote, 268 KB) +- Shows: a Keynote document in the iWork archive format +- Author: Tim Allison (Apache Tika) +- Source: https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote2013.key +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote2013.key +- SHA-256: 8da93f6b566bc9eb22f4791bbfd5f2179162f39bf80968187b164c33d31824db + +### tika-numbers09-charts.numbers + +- What: Apache Tika test spreadsheet with charts (Numbers '09) (numbers, 102 KB) +- Shows: an XML Numbers document with charts +- Author: Michael McCandless (Apache Tika) +- Source: https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testNumbersCharts.numbers +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testNumbersCharts.numbers +- SHA-256: f4430f2aa0ddab186e7e32f15552b58b7b03abada7a978dfbcd51e5548757789 + +### numbers-formulas.numbers + +- What: numbers-parser test spreadsheet of formulas (Numbers) (numbers, 323 KB) +- Shows: formulas across tables +- Author: Jon Connell (numbers-parser) +- Source: https://github.com/masaccio/numbers-parser/blob/a6affe5599c8373f575192ac5777d0c6426da696/tests/data/create-formulas.numbers +- Licence: MIT +- Evidence: https://raw.githubusercontent.com/masaccio/numbers-parser/a6affe5599c8373f575192ac5777d0c6426da696/LICENSE.rst +- Words read there: "Permission is hereby granted, free of charge, to any person obtaining a copy of this softw" +- Attribution: Copyright 2021 Jon Connell, numbers-parser, MIT, https://github.com/masaccio/numbers-parser. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/masaccio/numbers-parser/a6affe5599c8373f575192ac5777d0c6426da696/tests/data/create-formulas.numbers +- SHA-256: 9371c5b1d6ee4dfa17569097f064eba9c67f804d88b48638efbbeeb459d07dd4 + +### poi-network.vsdx + +- What: Apache POI test diagram (Visio): a network (visio, 43 KB) +- Shows: a use-case diagram: actors, ovals and connecting lines +- Author: A reporter of Apache POI bug 60489 (committed by Tim Allison) +- Source: https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/diagram/60489.vsdx +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/diagram/60489.vsdx +- SHA-256: 15494702ecb5554c1dfaee2c09ac35e2ea656e2eed7ba4ba0cf1039e6125fbe1 + +### vsdx-house.vsdx + +- What: vsdx library test drawing (Visio): a house (visio, 20 KB) +- Shows: boxes and house shapes with text +- Author: Dave Howard (vsdx library) +- Source: https://github.com/dave-howard/vsdx/blob/6703e6c2c906bea4051f43525ec9af9dcf735c13/tests/test3_house.vsdx +- Licence: BSD-3-Clause +- Evidence: https://raw.githubusercontent.com/dave-howard/vsdx/6703e6c2c906bea4051f43525ec9af9dcf735c13/LICENSE +- Words read there: "BSD 3-Clause License Copyright (c) 2020, Dave Howard" +- Attribution: Copyright (c) 2020, Dave Howard, BSD-3-Clause, https://github.com/dave-howard/vsdx. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/dave-howard/vsdx/6703e6c2c906bea4051f43525ec9af9dcf735c13/tests/test3_house.vsdx +- SHA-256: e454217e49d1ec2aaa7c9b8f38b148ffc0d028ce44ba096fe3386e9894b1775f + +## Text and code + +### field-guide.md + +- What: Field guide to Markdown (markdown, 2.3 KB) +- Shows: headings of six levels, nested and task lists, quotes, an aligned table, fenced code with languages, footnotes, links and references, inline HTML, Unicode +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 0f65f0bb537b18b6d5b1ec88da73686350e31d6529bb78a682aaa93d755e9f49 + +### field-notes.txt + +- What: Plain text with tabs, a long line and pangrams in nine scripts (text, 1.6 KB) +- Shows: Latin, Greek, Cyrillic, Japanese, Arabic and Hebrew text, box drawing, symbols, no final newline +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 12054040d6aef17fff17a18adf238fee6fd4158eeefd1d1230108fbd0f03df11 + +### recipes.json + +- What: A small recipe book as JSON (json, 2.4 KB) +- Shows: nested objects and arrays, numbers of every kind, null, booleans, escapes, empty values +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 9662568867ec0629cb9c18897f1bff96f2b837cc7f89f0840f25f1e1f3e8f69c + +### config.yaml + +- What: Configuration file, YAML (yaml, 585 B) +- Shows: mappings, lists, block scalars, anchors and aliases, comments +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 398192bf091eeedd240dca6116180ef291ea95d7ee1c20e2e9853505d62854c9 + +### pyproject.toml + +- What: Project file, TOML (toml, 440 B) +- Shows: tables, arrays of tables, inline values +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: abcdc7dd5ef4d56589629b5c88d8b9a7931a84ddebd60c6b19f9f3b45349fb03 + +### feed.xml + +- What: Atom feed (xml, 1.3 KB) +- Shows: namespaces, CDATA, an XHTML content element, entities, comments +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 6cddabc37cc985205a2c04b4863fe3514695a5ef437e710463d15a597eb54560 + +### route.gpx + +- What: Coastal walk, GPX track (xml, 6.3 KB) +- Shows: 60 track points with elevation and time, two waypoints +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: fcafdec4e3a6819fa406e98590dd7ede302bf76cbc6a2080f08542438d08524e + +### places.kml + +- What: Places, KML (xml, 1.0 KB) +- Shows: a point, a line and a polygon, a shared style, HTML in CDATA +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 9bb4a018a5c1f00843e61af1c475c1a735898f73950689b9ce36f9bdeb7879b8 + +### harbour-log.csv + +- What: Harbour log, 300 rows of CSV (csv, 19 KB) +- Shows: nine columns, quoted fields with commas, quotes and line breaks, accents +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: d4e07a9e0b1aec65f189c8897fa27e1daf24189c3c204fb9527f2bf75d22e314 + +### tides.tsv + +- What: Tide table, tab-separated values (csv, 833 B) +- Shows: 28 rows, six columns +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: eff347351015e329e7904aebc8f334de5ab16cfda7a6b4c6b2575d25ac06fd68 + +### prix.csv + +- What: Price list in the European style (csv, 255 B) +- Shows: semicolon separator, decimal commas, accented headers +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: d490f1d9c26d7218e9937591baf5688d9846303fb6942a5e3c60417c1647b91d + +### life.py + +- What: Conway's Game of Life, Python (source, 1.0 KB) +- Shows: docstrings, comprehensions, f-strings, a main guard +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 2cfc1e34f435d1ee59d3b01e5567bf61034df38a7257e9fed4f1fbdf9436e19e + +### lru.rs + +- What: A least-recently-used cache, Rust (source, 1.6 KB) +- Shows: generics, trait bounds, closures, a test module +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 1e2181c4c51e88e4255f391fce16a9d657560408b728df079274804674ab1acb + +### tokens.ts + +- What: Arithmetic tokenizer and evaluator, TypeScript (source, 2.2 KB) +- Shows: union types, a sticky regular expression, the shunting-yard algorithm +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 3587b167a990c8ec02b14477721b4259431f39b432556a6cd86afc8cd2f525c2 + +### bits.c + +- What: Bit tricks on 32-bit words, C (source, 1.4 KB) +- Shows: static functions, hexadecimal literals, printf formats +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: dc7eab8de40430eb6ed22610c37fd2b13df11e69cc70f6d9e2718514e589a6d5 + +### report.sql + +- What: Monthly report with window functions, SQL (source, 1.3 KB) +- Shows: DDL, a CTE, lag, rank and sum over windows, joins +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 99688fcab08ea4876cee7a5d3352abbbd23d230c8c17bc4bbebeea276cf2bd9a + +### deploy.sh + +- What: Build and deploy script, Bash (source, 989 B) +- Shows: strict mode, traps, functions, process substitution, a case statement +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: a304fad73e5636d1caf340cfeca167e01905df2a31fc7abca661e8b8131375c7 + +### style.css + +- What: A small stylesheet, CSS (source, 1.1 KB) +- Shows: custom properties, grid, clamp, color-mix, a dark mode and a media query +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 7918765d12e41edd33e5a0eb2b7dba28c5429c3a517f086fcc8722f0306920c0 + +### page.html + +- What: A page, shown as its source, HTML (html, 1.1 KB) +- Shows: semantic elements, a table, a form, a style and a script that do not run +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: 82dd9087ee3afdb2c5443f2a9fb8100500a1633f2e0bdffc2630a8be8387c678 + +### changes.diff + +- What: A unified diff of two files (diff, 879 B) +- Shows: two files, three hunks, added and removed lines +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: e0d7a274666b4f411cd8a5b7318736c37ad7993db25b884c4c9100e13a1391d6 + +### subtitles.srt + +- What: Subtitles, SubRip (text, 338 B) +- Shows: six cues with a tag +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: b4e25a1b12f662df624ae81c2c8bd89a8951e338688d2ea2cce55134c5de3161 + +### server.log + +- What: A server log of 120 lines (text, 8.9 KB) +- Shows: INFO, WARN and ERROR lines +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-text.py` +- SHA-256: d7e982445e5d36a398533f671c0e7d0f758b47ff406798631924b037a64f6d95 + +## Images + +### scene.png + +- What: Dusk landscape, PNG (png, 26 KB) +- Shows: 8-bit RGB, optimised +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 4a0f00c07bb229932be134ede00f78851c8b1c4d3fa3eaa32164bb97c424f3fe + +### scene-alpha.png + +- What: Landscape fading to transparent, PNG (png, 48 KB) +- Shows: RGBA: the viewer shows its chequerboard through the alpha +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: c06a1651cfa68c3c729ba2a94eb0e6a3ccb306a24b3e4029d909a0da3e705a69 + +### scene-indexed.png + +- What: Landscape in 32 colours, PNG (png, 5.0 KB) +- Shows: palette (indexed) PNG +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: ebd709f812945dd233203f4dbea12c22af3450bd67be52dbc6668f35f55a0e58 + +### gradient-grey16.png + +- What: Grey ramp, 16-bit PNG (png, 47 KB) +- Shows: 16-bit greyscale +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 594a376de8d26c143d65c012f82aafd3775e8bd6698cd168ce957638c636379d + +### bounce.png + +- What: Bouncing ball, animated PNG (apng, 40 KB) +- Shows: 24 frames, APNG +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: dbe78676dee3a09eb44aafd14980b8999f11bf576e1152d2183193a9c18ff131 + +### scene.jpg + +- What: Dusk landscape, JPEG (jpeg, 24 KB) +- Shows: baseline 4:2:0 with EXIF description and copyright +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 42f3881913d6401dc2df511f6a975caeb834f965299c152f5535aa3ae6a5e02e + +### scene-progressive.jpg + +- What: Dusk landscape, progressive JPEG (jpeg, 20 KB) +- Shows: progressive scans, optimised Huffman tables +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 70c6c3352c093ac5da448706cb7873dc1fa991f84119d37f80acf63550078f65 + +### scene-orientation6.jpg + +- What: JPEG stored on its side with EXIF orientation 6 (jpeg, 22 KB) +- Shows: shown upright through the EXIF orientation +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 02e8b993e435a3de434c7efbe688757efa2fef452b1747c7382732b63e8e971b + +### scene-grey.jpg + +- What: Landscape in grey, JPEG (jpeg, 17 KB) +- Shows: single-component JPEG +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 546838a80bc5b7b042fdc593b8e95fbaaf39165347dcba32c45d341f2f5dabea + +### scene-cmyk.jpg + +- What: Landscape as a CMYK JPEG (jpeg, 51 KB) +- Shows: four components (Adobe inverted CMYK) +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 2c85449ef1574bc35fc3f54cf8aa724e518180aa86488ecef2be603816bfac01 + +### bounce.gif + +- What: Bouncing ball, animated GIF (gif, 100 KB) +- Shows: 24 frames with a palette each +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 386425b57344417f2205ac176a3a72eaed208ad67e57f7cd304ab845bf27d83b + +### scene.gif + +- What: Dusk landscape, dithered GIF (gif, 25 KB) +- Shows: 256 colours, Floyd-Steinberg dithering +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 9680147c7fabe6ba25e464c9cd41fe6e45b73502e68e811ea7037922f8c33255 + +### scene.webp + +- What: Dusk landscape, lossy WebP (webp, 8.8 KB) +- Shows: VP8 +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: c5a026e0341b0952e99af9ff4fa34de68e7ecc2da3326421e79a66639fef4787 + +### scene-lossless.webp + +- What: Dusk landscape, lossless WebP (webp, 18 KB) +- Shows: VP8L +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 7455a0e9ae36e7e9fb3f5b6ee094fcbece393dd9444d37cee3db785e36b01e79 + +### scene-alpha.webp + +- What: Landscape fading to transparent, WebP (webp, 18 KB) +- Shows: lossy colour with an alpha plane (VP8X, ALPH) +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: d62a7a8f0b2655221b84eec04c8adfd3d5f37a59425a706fa0eea900cc05b525 + +### bounce.webp + +- What: Bouncing ball, animated WebP (webp, 20 KB) +- Shows: 24 frames +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: edb4905dd92651bd42dbc3c15873913ec6bdca9a04afe83740565e082d90b84c + +### scene-lzw.tif + +- What: Dusk landscape, TIFF with LZW (tiff, 94 KB) +- Shows: RGB, LZW, 300 dpi +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: b3a41a6e8e4644e2b2291a50e499fd9988b735932c6c27def20587c1f565bcaa + +### scene-jpeg.tif + +- What: Dusk landscape, TIFF with JPEG compression (tiff, 56 KB) +- Shows: JPEG in TIFF (compression 7) +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: c5add42f305af0ca4dcf2cd554dc973611ce1334e5e91ba2b38ec2c58a35e89b + +### scene-cmyk-packbits.tif + +- What: Landscape as a CMYK TIFF (tiff, 49 KB) +- Shows: four inks, PackBits +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 57d7dbd0c807d1bcd7064367051ff75cdedddc4109e85cf67450c6601ec48fbc + +### gradient-grey16.tif + +- What: Grey ramp, 16-bit TIFF (tiff, 91 KB) +- Shows: 16-bit greyscale, Deflate +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 94c41cab542120478f4a7b97e694e8ddddd2520b022f9266a59bcf0e727baeea + +### scene-pages.tif + +- What: Three-page TIFF (tiff, 83 KB) +- Shows: pages of 512, 256 and 128 pixels, the last in grey, Deflate +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 9a8267875af78208d587777b341eb152d166b6acc10aede6d198d8e788ebd7a6 + +### fax-g4.tif + +- What: Fax cover sheet and register, CCITT Group 4 TIFF (tiff-fax, 22 KB) +- Shows: 1-bit, 1728 by 2200 at 204 by 196 dpi, T.6 +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: c4efa355dc63ce59863446b62b960397f34ab3d3c04a212450d0c2bca5f3904a + +### fax-g4-3pages.tif + +- What: Three-page fax, CCITT Group 4 TIFF (tiff-fax, 66 KB) +- Shows: three pages, T.6 +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: a94bc11060a7f2553803580c0feb779d1e59b032fd9f4406fbd4972765842cbb + +### scene-24.bmp + +- What: Dusk landscape, 24-bit BMP (bmp, 56 KB) +- Shows: BI_RGB, bottom-up +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: f3281d18d1f2944c7cb5f60eac3593facfbb3b77a0e5505e1ac88a9fdbefbfaa + +### scene-8.bmp + +- What: Dusk landscape, 8-bit BMP (bmp, 20 KB) +- Shows: 256-colour palette +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: d8ac5f35b9b76cd509191ec7f5c4a48f8236c5ca4dcc88197810060b16f47ad2 + +### scene-32.bmp + +- What: Dusk landscape, 32-bit BMP (bmp, 75 KB) +- Shows: four channels +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 1d99799f83adc1486eb1072502893ed39d91220d9e132a0716d56d9e05285d94 + +### scene-1bit.bmp + +- What: Dusk landscape, 1-bit BMP (bmp, 24 KB) +- Shows: two-colour palette +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: cc5152076963ba9beca3d0a5ed8a4ebd5f082c127e780ef68a955ce4b71e8786 + +### scene-rle8.bmp + +- What: Dusk landscape, 8-bit BMP with RLE compression (bmp, 11 KB) +- Shows: BI_RLE8 with encoded and absolute runs +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 2b1c69a7597006677e38cd8e32ad11f85acb0602ef228ec5c080d03350fc7936 + +### icon.ico + +- What: Icon with seven sizes (ico, 25 KB) +- Shows: 16 to 256 pixels, PNG-coded images +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 436e2c802c34d1c83b4492308ca660d4944eeb44fe32f6de87d3869e1c9e89ea + +### icon-legacy.ico + +- What: Icon in the legacy bitmap coding (ico, 14 KB) +- Shows: 32-bit and 8-bit DIBs with AND masks, and one PNG image +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 0ded5b411cd2cf1ebd15cdecfa12a1e9f4a53b74da39fa263f935329efe399ec + +### scene.ppm + +- What: Dusk landscape, PPM (P6) (pnm, 36 KB) +- Shows: binary pixmap with a comment +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 43949e7d7636ed5a7d552ad6fac28eb72487a70a1f63db7d944fb7e587612b40 + +### scene-ascii.ppm + +- What: Dusk landscape, ASCII PPM (P3) (pnm, 17 KB) +- Shows: text pixmap, 48 by 36 +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 996f21e5c7c05ed1c586b9dc9882ad6fba9f1153ba23c49844c640eef647bf8a + +### scene-grey16.pgm + +- What: Landscape in grey, 16-bit PGM (P5) (pnm, 24 KB) +- Shows: maxval 65535 +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 059c3e7b9e22b0aa4247552139886564f9b36d66051ffc7957efd972f557b52c + +### scene.pbm + +- What: Landscape as a bitmap, PBM (P4) (pnm, 6.0 KB) +- Shows: 1 bit a pixel +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 648c9687dc2c226306c64aa945bf81170bbff5d8c9f88b0630b07b294d4e2edb + +### scene-alpha.pam + +- What: Landscape with alpha, PAM (P7) (pnm, 48 KB) +- Shows: RGB_ALPHA tuples +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 806cee6e77cbd0226f6ec3ab18de4259612a89c1df5ff5e27e37d4d5c932c515 + +### scene.qoi + +- What: Dusk landscape, QOI (qoi, 38 KB) +- Shows: three channels +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: d04c2ff91df0173bf20c495eb19555dc4bc9342b9ec7b61f653a4742c00b6188 + +### scene-alpha.qoi + +- What: Landscape with alpha, QOI (qoi, 89 KB) +- Shows: four channels +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 16a53b8756ab8dd663400cfa04dde6a598b6c608d345efd131c0d714cde8affb + +### scene.ff + +- What: Landscape with alpha, farbfeld (farbfeld, 54 KB) +- Shows: 16-bit RGBA, big-endian +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: b6d250a92f39b7183b42191933d84da7e6ef1a33a971c8e7c2f8ccc85ad060d0 + +### sunset.hdr + +- What: Sunset with a sun far brighter than the sky, Radiance HDR (hdr, 54 KB) +- Shows: RGBE with run-length scanlines; the sun is 60 times white +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: f65ffee0666792f829c6692ca1ef4a0a553304d06e28682f18a47de5efed7e9b + +### sunset-flat.hdr + +- What: Sunset, uncompressed Radiance HDR (hdr, 48 KB) +- Shows: flat RGBE scanlines +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: a401df61e76cfdf88731294a4ba5ce1ba607ee294b5010a1312925240f5d2131 + +### scene-bc1.dds + +- What: Dusk landscape, DDS with BC1 (DXT1) (dds, 32 KB) +- Shows: full mipmap chain +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 77d26e97cc0f532481e3e585321305aca44efd7694307f312d75b1c4bfecf8f8 + +### scene-bc3.dds + +- What: Landscape with alpha, DDS with BC3 (DXT5) (dds, 64 KB) +- Shows: full mipmap chain, alpha blocks +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 9028c95d163d7283b357e3beb6a531a192087fcb76e7aeb1aead81195e4f4a92 + +### scene.jp2 + +- What: Dusk landscape, JPEG 2000 (JP2) (jpeg2000, 80 KB) +- Shows: lossy, 3 quality layers, 5 resolutions, 256-pixel tiles, RPCL progression +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: f90f58bb1e3ef0bfd48b9b55bffc293ee4f1fe506a6717f0052319a34621bf9a + +### scene.j2k + +- What: Dusk landscape, bare JPEG 2000 codestream (jpeg2000, 48 KB) +- Shows: no JP2 boxes +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 949564fa2b811a1987a12989095289d673c2a4937f5014b0b9c1d7bfbe660b9c + +### scene-grey.jp2 + +- What: Landscape in grey, JPEG 2000 (jpeg2000, 19 KB) +- Shows: one component +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: eb50ae4528e774c7ab9c427509faee07473249a89d35403c33afb2e5e9abf983 + +### scene-alpha.jp2 + +- What: Landscape with alpha, JPEG 2000 (jpeg2000, 77 KB) +- Shows: four components with an alpha channel +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: bc1adbddf1bd608b71b1cac9dd1a47f47cde2c046a4202f1642cd15efa647825 + +### page-generic.jb2 + +- What: Fax page as a JBIG2 file, generic region (jbig2, 8.4 KB) +- Shows: 1-bit page of 1240 by 1579, arithmetic coding +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 1447cbbc1ebab320062a78fa40f81ccf768aed3d9aea2cfa0583ebce2fb3e088 + +### page-symbol.jb2 + +- What: Fax page as a JBIG2 file, symbol coding (jbig2, 5.4 KB) +- Shows: text region over a symbol dictionary +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 8fba2e640c71101dd5ab376008ea177ee0e8f2070c49250d31186c198991da9e + +### scene-lossy.jxl + +- What: Dusk landscape, JPEG XL, lossy (jpeg-xl, 23 KB) +- Shows: VarDCT at distance 1 +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: dc1e17bd77c26a49a3c61fcd8b299c79b71395550a4e1d42bc69af6aa33a200f + +### scene-lossless.jxl + +- What: Dusk landscape, JPEG XL, lossless (jpeg-xl, 26 KB) +- Shows: modular mode +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 5f2ce3702bb3fe54bb31feb6f1a9e92347a9acd290a6a7730a3074fe5fb088fe + +### scene-alpha.jxl + +- What: Landscape with alpha, JPEG XL (jpeg-xl, 35 KB) +- Shows: lossless with an extra alpha channel +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: d18698258dcc59292483265284a96099bc2c1f23f53f4893d778062070588166 + +### bounce.jxl + +- What: Bouncing ball, animated JPEG XL (jpeg-xl, 60 KB) +- Shows: 24 frames +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: 679c04f1bec63d699c6baeb1d42dfa4ff205a40327f208f650115f7292e27f89 + +### monet-gardeners-house-antibes.jpg + +- What: Claude Monet, The Gardener's House at Antibes (1888) (jpeg, 395 KB) +- Shows: a painting, JPEG from the museum's web size +- Author: Claude Monet, Cleveland Museum of Art (open access) +- Source: https://clevelandart.org/art/1916.1044 +- Licence: CC0-1.0 +- Evidence: https://clevelandart.org/art/1916.1044 +- Words read there: ""share_license_status":"CC0"" +- Origin: downloaded from https://openaccess-cdn.clevelandart.org/1916.1044/1916.1044_web.jpg +- SHA-256: 95142eba48d54f01352eb4ef6f04260cb3df3bcb1d9c86000c1284a7817674ca + +### queen-mab-cave.jpg + +- What: Queen Mab's Cave, in the manner of Turner (jpeg, 213 KB) +- Shows: a painting +- Author: Imitator of J. M. W. Turner, Cleveland Museum of Art (open access) +- Source: https://clevelandart.org/art/1916.1036 +- Licence: CC0-1.0 +- Evidence: https://clevelandart.org/art/1916.1036 +- Words read there: ""share_license_status":"CC0"" +- Origin: downloaded from https://openaccess-cdn.clevelandart.org/1916.1036/1916.1036_web.jpg +- SHA-256: b84e005332288229d808172a7610b501d9977c0b4668e333faea5ff780157223 + +### rembrandt-st-francis.jpg + +- What: Rembrandt, Saint Francis Beneath a Tree Praying (etching) (jpeg, 362 KB) +- Shows: an etching, fine line work +- Author: Rembrandt van Rijn, Cleveland Museum of Art (open access) +- Source: https://clevelandart.org/art/2024.64 +- Licence: CC0-1.0 +- Evidence: https://clevelandart.org/art/2024.64 +- Words read there: ""share_license_status":"CC0"" +- Origin: downloaded from https://openaccess-cdn.clevelandart.org/2024.64/2024.64_web.jpg +- SHA-256: 74b37aa3738914dd66c07533b3758f7694fefbaa918d2f23c263205cf987db7b + +### nolli-rome-plate.jpg + +- What: Nolli's map of Rome (1748), a plate (jpeg, 315 KB) +- Shows: an engraved city plan +- Author: Giovanni Battista Nolli et al., Cleveland Museum of Art (open access) +- Source: https://clevelandart.org/art/2020.276.3 +- Licence: CC0-1.0 +- Evidence: https://clevelandart.org/art/2020.276.3 +- Words read there: ""share_license_status":"CC0"" +- Origin: downloaded from https://openaccess-cdn.clevelandart.org/2020.276.3/2020.276.3_web.jpg +- SHA-256: 7643c9e59f7706ebe62518a51daf7d72129acc8092dde7afc65516d3d11b6c77 + +### pallid-swift.jpg + +- What: Pallid swift in flight (photograph) (jpeg, 348 KB) +- Shows: a camera JPEG with EXIF data (Canon EOS R6 Mark II) +- Author: Hobbyfotowiki +- Source: https://commons.wikimedia.org/wiki/File:Pallid_swift_(in_flight).jpg +- Licence: CC0-1.0 +- Evidence: https://commons.wikimedia.org/wiki/File:Pallid_swift_(in_flight).jpg +- Words read there: "This file is made available under the Creative Commons CC0 1.0 Universal Public Domain Dedication." +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/d/df/Pallid_swift_%28in_flight%29.jpg +- SHA-256: 9d0f79f12dcc236f549cdefb66dc468f25335d444ebd26e98805efdd94afecbd + +### cyclotron-patent.png + +- What: Drawing from Lawrence's cyclotron patent (png, 202 KB) +- Shows: a black and white technical drawing +- Author: Ernest O. Lawrence (US Patent Office) +- Source: https://commons.wikimedia.org/wiki/File:Cyclotron_patent_p1.png +- Licence: public-domain (US patent text and drawings published before 1989) +- Evidence: https://commons.wikimedia.org/wiki/File:Cyclotron_patent_p1.png +- Words read there: "The text and illustrations of US patents published before March 1, 1989 are in the public domain unl" +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/4/42/Cyclotron_patent_p1.png +- SHA-256: 0be9b59ae21bbadeb8a13fafde5e0bcfb6d470ba516244eab0ec1c74afd7394f + +### mature-flower-diagram.svg + +- What: Diagram of a mature flower (svg, 121 KB) +- Shows: 85 texts, about 90 gradient references, many paths +- Author: Mariana Ruiz Villarreal (LadyofHats) +- Source: https://commons.wikimedia.org/wiki/File:Mature_flower_diagram.svg +- Licence: public-domain (released by its author) +- Evidence: https://commons.wikimedia.org/wiki/File:Mature_flower_diagram.svg +- Words read there: "This work has been released into the public domain by its author, LadyofHats." +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/7/7f/Mature_flower_diagram.svg +- SHA-256: b40bd4e188c0a99e51e994b4f39f931d3fca28093ecb6454aa2bd93c575ea999 + +### entamoeba-life-cycle.svg + +- What: Life cycle of Entamoeba histolytica (svg, 267 KB) +- Shows: 547 paths, text and arrows +- Author: Mariana Ruiz Villarreal (LadyofHats) +- Source: https://commons.wikimedia.org/wiki/File:Entamoeba_histolytica_life_cycle-en.svg +- Licence: public-domain (released by its author) +- Evidence: https://commons.wikimedia.org/wiki/File:Entamoeba_histolytica_life_cycle-en.svg +- Words read there: "This work has been released into the public domain by its author, LadyofHats." +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/a/a1/Entamoeba_histolytica_life_cycle-en.svg +- SHA-256: 68a4a92289d81f0e7e9361abbba2da9a8c8b4e440e3ad9de5c420646eb133eaa + +### sieve-of-eratosthenes.svg + +- What: Sieve of Eratosthenes, animated (svg, 22 KB) +- Shows: SMIL animation with 179 animate elements +- Author: Ricordisamoa, with later edits by Jc86035, HereToHelp and Glrx +- Source: https://commons.wikimedia.org/wiki/File:Sieve_of_Eratosthenes_animation.svg +- Licence: public-domain (ineligible for copyright, Wikimedia Commons PD-ineligible) +- Evidence: https://commons.wikimedia.org/wiki/File:Sieve_of_Eratosthenes_animation.svg +- Words read there: "This work is ineligible for copyright and therefore in the public domain because it consists entirel" +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/0/0b/Sieve_of_Eratosthenes_animation.svg +- SHA-256: d8f1d6d0a4b5710ebe1e7766eab30b766b10dc9380c1058ccb39baccd7d51586 + +### muybridge-horse.gif + +- What: Muybridge's horse in motion, animated GIF (gif, 555 KB) +- Shows: 15 frames +- Author: Eadweard Muybridge (photographs, 1887); animation by Waugsberg +- Source: https://commons.wikimedia.org/wiki/File:Muybridge_race_horse_animated.gif +- Licence: public-domain (the author died in 1904) +- Evidence: https://commons.wikimedia.org/wiki/File:Muybridge_race_horse_animated.gif +- Words read there: "The author died in 1904, so this work is in the public domain in its country of origin and other cou" +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/d/dd/Muybridge_race_horse_animated.gif +- SHA-256: eb40bebc1f039e7c2a121673c3b55d4aa73582f858053dbbcca9ebce9e2b479d + +### geneva-mechanism.gif + +- What: Geneva mechanism with six spokes, animated GIF (gif, 224 KB) +- Shows: 101 frames +- Author: Mike1024 +- Source: https://commons.wikimedia.org/wiki/File:Geneva_mechanism_6spoke_animation.gif +- Licence: public-domain (released by its author) +- Evidence: https://commons.wikimedia.org/wiki/File:Geneva_mechanism_6spoke_animation.gif +- Words read there: "I, the copyright holder of this work, release this work into the public domain." +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/9/9b/Geneva_mechanism_6spoke_animation.gif +- SHA-256: d7df5c9984109c2ca60f7aab1a51db1f0b578cddcdede5d43be9f67536e2a1ee + +### abundance-nemo-g4-6pages.tif + +- What: Les grans et merveilleux faictz du seigneur Nemo (scan, six pages, CCITT Group 4) (tiff-fax, 323 KB) +- Shows: a multi-page bilevel TIFF scanned from a 16th-century book +- Author: Jean d'Abundance (16th century); scanned by the Bibliothèque nationale de France +- Source: https://commons.wikimedia.org/wiki/File:Abundance_-_Les_grans_et_merveilleux_faictz_du_seigneur_Nemo%2C_avec_les_privil%C3%A8ges_qu%E2%80%99il_a.tif +- Licence: public-domain (a mechanical scan of a public domain book, Wikimedia Commons PD-scan) +- Evidence: https://commons.wikimedia.org/wiki/File:Abundance_-_Les_grans_et_merveilleux_faictz_du_seigneur_Nemo%2C_avec_les_privil%C3%A8ges_qu%E2%80%99il_a.tif +- Words read there: "This file is in the public domain because it is a mere mechanical scan or photocopy of a public doma" +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/b/b5/Abundance_-_Les_grans_et_merveilleux_faictz_du_seigneur_Nemo%2C_avec_les_privil%C3%A8ges_qu%E2%80%99il_a.tif +- SHA-256: 38bab27c6d559e11468d6c7e4222f0bb1bee4ba0044540adcebfc012930e00d1 + +## Camera RAW + +### canon-eos-40d-sraw.cr2 + +- What: Canon EOS 40D, sRAW (CR2) (raw-cr2, 5.5 MB) +- Shows: TIFF-based CR2 with a 1936 by 1288 JPEG preview +- Author: Uploader to raw.pixls.us (the site gives no name) +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/2102/nice/Canon%20-%20EOS%2040D%20-%20sRAW2%20%28sRAW%29%20%283%3A2%29.CR2 +- SHA-256: ba644e7dd2abe74eca260e67f0206ff113bf0f62e710f8130611e964d6be5bf1 + +### canon-eos-r6.cr3 + +- What: Canon EOS R6, C-RAW (CR3) (raw-cr3, 5.0 MB) +- Shows: ISO base media CR3 with a 3408 by 2272 JPEG in its second track; its EXIF names Johannes Kimmel as artist and states CC0 +- Author: Uploader to raw.pixls.us; EXIF artist Johannes Kimmel +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/4659/nice/Canon%20-%20EOS%20R6%20-%203%3A2.CR3 +- SHA-256: 74abb0a113d075ad9887a058082f40dd2a938c4813a08474d82356f11a027778 + +### canon-powershot-g1.crw + +- What: Canon PowerShot G1 (CRW) (raw-crw, 2.2 MB) +- Shows: the 2000 CIFF/HEAP container with a 640 by 480 JPEG +- Author: Uploader to raw.pixls.us (the site gives no name) +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/2073/nice/Canon%20-%20PowerShot%20G1%20-%20RAW%20%284%3A3%29.crw +- SHA-256: f39be5acf14057ce04527fd8d0574e57ec8ed5fed082d7bfaff763075a44ee8a + +### nikon-d70s.nef + +- What: Nikon D70s (NEF) (raw-nef, 5.0 MB) +- Shows: TIFF-based NEF with a 3008 by 2000 JPEG in a SubIFD +- Author: Uploader to raw.pixls.us (the site gives no name) +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/1117/nice/Nikon%20-%20D70s%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283%3A2%29.NEF +- SHA-256: 6c46f9854033e7623ebbb5de8c951df6762ca44f05d45035ccc5cb7e9f4e5692 + +### sony-ilce-7s.arw + +- What: Sony A7S (ARW) (raw-arw, 5.9 MB) +- Shows: TIFF-based ARW with a 1616 by 1080 JPEG preview +- Author: Uploader to raw.pixls.us (the site gives no name) +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/1582/nice/Sony%20-%20ILCE-7S%20-%2014bit%2014bit%20compressed%20%283%3A2%29.ARW +- SHA-256: a35ebb2fbec929daa5beb20d1ce5c15a8aac7b1a7a231455387f3df8a7442e07 + +### olympus-c5060wz.orf + +- What: Olympus C5060WZ (ORF) (raw-orf, 7.3 MB) +- Shows: the only picture inside is a 160 by 120 thumbnail +- Author: Uploader to raw.pixls.us (the site gives no name) +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/1799/nice/Olympus%20-%20C5060WZ%20-%2012bit%20%284%3A3%29.ORF +- SHA-256: 3fc12bc3f6320cfab456111d7805b446e329fdd1907680be55e2e163e5e084bd + +### panasonic-dmc-lx7.rw2 + +- What: Panasonic Lumix DMC-LX7 (RW2) (raw-rw2, 3.2 MB) +- Shows: RW2 with a 1920 by 1280 JPEG +- Author: Uploader to raw.pixls.us (the site gives no name) +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/7010/nice/Panasonic%20-%20DMC-LX7%20-%203%3A2.RW2 +- SHA-256: 18dfc3877e944020299635a8c2fad61fca2485b1c9b67645a495ee870a86e599 + +### fujifilm-finepix-s5000.raf + +- What: Fujifilm FinePix S5000 (RAF) (raw-raf, 6.5 MB) +- Shows: RAF container with a 1280 by 960 JPEG +- Author: Uploader to raw.pixls.us (the site gives no name) +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/2726/nice/Fujifilm%20-%20FinePix%20S5000%20-%204%3A3.RAF +- SHA-256: dabd5e74521a6980156be9fd4b88d0c37b0fe4d0e0e6f5c12db8cffff1b76297 + +### adobe-dng-canon-5d3.dng + +- What: Canon EOS 5D Mark III converted to DNG (lossy JPEG) (raw-dng, 2.4 MB) +- Shows: DNG with previews of 256, 1024 and 3960 pixels; converted by Adobe DNG Converter, not straight from a camera +- Author: Uploader to raw.pixls.us; EXIF artist Simon Harhues +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/1033/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossy%20JPEG%20compression%2C%20rgb%20%283%3A2%29.DNG +- SHA-256: b22f1e36331f679abb8b13e433b9bbde3988723adf10fee7aa0dda6381016a98 + +## CAD + +### house-plan.dxf + +- What: House, ground floor plan (DXF 2018) (dxf, 85 KB) +- Shows: walls cut at openings with pattern hatches, door blocks with attributes, window and furniture blocks, axes with bubbles, room labels, linear, aligned, radius and angular dimensions, stair, north arrow, 17 layers, paper-space sheet with a viewport and a title block +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: ad78cc54ad3c52483040b71dcb5cafda6f3660bdc9f229e21b22ece2cc773815 + +### house-plan-r2000.dxf + +- What: House, ground floor plan (DXF 2000) (dxf, 82 KB) +- Shows: the same plan written as an AutoCAD 2000 file +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: eabe480065538e478a22424b5d3b0427efd321e098f7e9cabe4ad332984ab033 + +### house-elevations.dxf + +- What: House, four elevations and a section in 2D (dxf, 95 KB) +- Shows: gabled roofs with roof-tile hatches, plaster hatches, ground hatching, windows with transparent glass, steps, level marks, height dimensions, a hatched section with a stair +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: 4ddb32c976e6709e39a4184a09c772ecff99324dcefd03e40a2cf7d89f62661f + +### hatch-gallery.dxf + +- What: Hatch gallery (dxf, 142 KB) +- Shows: about a hundred predefined patterns, nine gradient types, islands in the three hatch styles, boundaries of lines, arcs, ellipses and splines, pattern angle and scale +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: 0d9978ab422cfea65f91149502727f7132a3192450eedf52f90c9075f36b8cf4 + +### dimensions.dxf + +- What: Dimension types (dxf, 55 KB) +- Shows: linear, aligned, diameter, radius, arc length, angular (two lines and three points), ordinate, leaders, a multileader, tolerances, text override, three dimension styles +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: 515ccb26e3d707c2dc12192f147a4003dc44118d17da04d6f162250aa5935d87 + +### linetypes.dxf + +- What: Linetypes, line weights and polylines (dxf, 35 KB) +- Shows: the standard linetypes, custom ones with text inside, 12 line weights, per-entity scale, linetype generation, polylines with widths, circles, arcs, ellipses and splines in dashed styles +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: 0c8f9b53c105c230bad51a7fbad1af8972725fea449d638015c94467229c7cc0 + +### text-styles.dxf + +- What: Text styles and MTEXT (dxf, 30 KB) +- Shows: ten text styles (width and oblique factors, mirrored, upside down), justifications, rotation, special codes, Unicode, MTEXT colours, fonts, stacked fractions, underline, columns, background, nine attachment points +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: e2c312052780d94fd25ad4205370645b143ceecde87d57dac2d471582cddeb3e + +### blocks.dxf + +- What: Blocks, nested inserts and arrays (dxf, 30 KB) +- Shows: a block inside a block inside a block with attributes, mirrored, scaled and rotated inserts, a 3 by 8 array, BYBLOCK colours +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: 419ffd35c1dd1d4ded3d70039dfa49852fa85cce935911b10a7a00557b967844 + +### bracket.dxf + +- What: Mechanical part: an L bracket in three views (dxf, 42 KB) +- Shows: centre and hidden lines, a section with hatching, dimensions with tolerances, diameter and radius dimensions, a leader, an A4 sheet with a title block +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: cd5d9676a8bb77c0702757674e17723202e3a614c38ca96ae8bd8f9c2ad1cfa9 + +### site-plan.dxf + +- What: Site plan with contour lines (dxf, 84 KB) +- Shows: closed spline contours, wide polyline roads, parcels with areas, hatched buildings, a gradient pond, spot heights, trees, scale bar, legend and north arrow +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: c5bb9b26f9dbd785754d18854c1faef18fd360a8411f2244c3553d4c75a8baeb + +### legacy-r12.dxf + +- What: Gear in the R12 format (dxf, 14 KB) +- Shows: POLYLINE, CIRCLE, ARC, TEXT, SOLID, 3DFACE and INSERT entities of the 1992 format +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-cad.py` +- SHA-256: 926e991c1389fcdf73d8aff413eb8b0c122943bf95f1667b31ba78b7db2a0723 + +### barraca-valenciana-0100-r2010.dwg + +- What: Barraca Valenciana, ground floor plan (DWG 2010, Revit export) (dwg, 278 KB) +- Shows: walls, doors, glazing, stairs, furniture, hatches, areas, 24 layers, 56 blocks, extra layouts with title block and viewports +- Author: Ã’scar Selfa +- Source: https://zenodo.org/records/14293897 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/14293897 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: Ã’scar Selfa, Barraca Valenciana de Llauradors: Model BIM i Estudi de la Tipologia Arquitectònica, Zenodo, doi:10.5281/zenodo.14293897, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/records/14293897/files/BRC-OSP-XXX-P00-PLA-ARQ-0100-tipologiaGen%C3%A8rica-A1-P2.dwg?download=1 +- SHA-256: fcd3eef78cbd568e3f8a8262378670495a3ea6b8e091cb162fe8ef9c3c9add13 + +### barraca-valenciana-0300-r2010.dwg + +- What: Barraca Valenciana, elevations (DWG 2010, Revit export) (dwg, 1.0 MB) +- Shows: elevations drawn as 2D linework with hatches, topography and planting, no 3D +- Author: Ã’scar Selfa +- Source: https://zenodo.org/records/14293897 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/14293897 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: Ã’scar Selfa, Barraca Valenciana de Llauradors: Model BIM i Estudi de la Tipologia Arquitectònica, Zenodo, doi:10.5281/zenodo.14293897, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/records/14293897/files/BRC-OSP-XXX-ZZZ-PLA-ARQ-0300-tipologiaGen%C3%A8rica-A1-P2.dwg?download=1 +- SHA-256: 93a211cfb797525a94b5b41aeae3ecf33e61442eea07cadae056463cc400a3e5 + +### barraca-valenciana-0400-r2010.dwg + +- What: Barraca Valenciana, sections and details (DWG 2010, Revit export) (dwg, 499 KB) +- Shows: 42 layers, hatches, arcs, splines, ellipses, 61 blocks +- Author: Ã’scar Selfa +- Source: https://zenodo.org/records/14293897 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/14293897 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: Ã’scar Selfa, Barraca Valenciana de Llauradors: Model BIM i Estudi de la Tipologia Arquitectònica, Zenodo, doi:10.5281/zenodo.14293897, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/records/14293897/files/BRC-OSP-XXX-ZZZ-PLA-ARQ-0400-tipologiaGen%C3%A8rica-A1-P2.dwg?download=1 +- SHA-256: a09c492dc34ead8df619d6d618049298afffb050a8436c470055923a90f4b61c + +### building-survey-plans-r2000.dwg + +- What: Survey of a small building: plans, elevations and details (DWG 2000) (dwg, 2.0 MB) +- Shows: Italian drawing with room names and areas, over 120 layers, 890 blocks, 567 dimensions, 492 hatches +- Author: Giovanni De Gasperis, Sante Dino Facchini, Asif Saeed +- Source: https://zenodo.org/records/15076037 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/15076037 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: De Gasperis, G., Facchini, S. D., Saeed, A., Building Ledger Dossier: Seismic Prevention and Building Documentation Tracking through a Digital Twin Approach (4_Architectural_Building_Survey.dwg), Zenodo, doi:10.5281/zenodo.15076037, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/records/15076037/files/4_Architectural_Building_Survey.dwg?download=1 +- SHA-256: 2f096b44cd5b60ded2ad8b12c2f46116db8b1fda1c980f7bb02660a85782ab1d + +### golden-tile-palace-r2004.dwg + +- What: Golden Tile Palace: front elevation and section (DWG 2004) (dwg, 746 KB) +- Shows: elevation and section with level marks and Chinese titles, 4393 polylines, 841 arcs, 328 splines, 26 dimensions, 24 hatches +- Author: Jing Zhang, Yunying Ren, Xiaofan An +- Source: https://figshare.com/articles/dataset/Figure_of_surveying_and_mapping_of_Golden_Tile_Palace_/21374221 +- Licence: CC-BY-4.0 +- Evidence: https://api.figshare.com/v2/articles/21374221 +- Words read there: ""name": "CC BY 4.0"" +- Attribution: Jing Zhang, Yunying Ren, Xiaofan An, Figure of surveying and mapping of Golden Tile Palace, supplementary file S1 of PLOS ONE doi:10.1371/journal.pone.0262155, figshare, CC BY 4.0. No changes. +- Origin: downloaded from https://ndownloader.figshare.com/files/37930432 +- SHA-256: 14d128397f89c1af652b2cd3c9d523db98ae539b1bc061d4f06fba6d21501ae9 + +### residential-site-plan-r2007.dwg + +- What: Site plan of an old residential community in Beijing (DWG 2007) (dwg, 926 KB) +- Shows: 58 layers, 79 blocks, buildings, roads, 2865 polylines, 656 texts, 125 hatches, 39 dimensions +- Author: Yijun Fan +- Source: https://figshare.com/articles/dataset/Drainage_System_Layout_of_Old_Urban_Residential_Communities_In_Beijing/28188725 +- Licence: CC-BY-4.0 +- Evidence: https://api.figshare.com/v2/articles/28188725 +- Words read there: ""name": "CC BY 4.0"" +- Attribution: Yijun Fan, Drainage System Layout of Old Urban Residential Communities In Beijing (Site Plan.dwg), figshare, doi:10.6084/m9.figshare.28188725.v1, CC BY 4.0. No changes. +- Origin: downloaded from https://ndownloader.figshare.com/files/51611078 +- SHA-256: 37b03757172e5360a03cc26da473cde84e85dbd61914b67741c94a40f7f0db2f + +### hospital-floor-plan-r2013.dwg + +- What: Furnished hospital floor plan (DWG 2013) (dwg, 1.2 MB) +- Shows: section and level marks, 109 dimensions, 429 block definitions of furniture and plants, 19 layers +- Author: Toka Ehab +- Source: https://zenodo.org/records/10520508 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/10520508 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: Toka Ehab, Nanotechnology Applications in Interior Design of hospitals to enhance thermal comfort and decrease the infection spread for the occupants (Elite hospital plan.dwg), Zenodo, doi:10.5281/zenodo.10520508, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/records/10520508/files/Elite%20hospital%20plan.dwg?download=1 +- SHA-256: b618207b01a1759142e038e1edf762ab8efbe11804bb2e84fc6f472e7e040fb9 + +### commercial-building-hvac-plans-r2018.dwg + +- What: Air-conditioning plans of a commercial building (DWG 2018) (dwg, 495 KB) +- Shows: 48 layers, 320 blocks, 316 dimensions, 125 hatches, 112 wipeouts +- Author: Kevin Anthony do Bem +- Source: https://zenodo.org/records/10407941 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/10407941 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: Kevin Anthony do Bem, Projeto de Climatização de um Edifício de Comércio e Serviços (F04 Plantas- Rede aerolica.dwg), Zenodo, doi:10.5281/zenodo.10407941, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/records/10407941/files/F04%20Plantas-%20Rede%20aerolica.dwg?download=1 +- SHA-256: bcf3b92e0afdf29bb75e7632e8193f881918ddabb4de67a1331b5eda34ee9be2 + +### ezdxf-dim-linear-tutorial-r2013.dwg + +- What: ezdxf documentation: linear dimension tutorial (DWG 2013) (dwg, 36 KB) +- Shows: 16 dimensions, 16 multileaders, 13 dimension styles, 27 text styles +- Author: Manfred Moitzi (ezdxf) +- Source: https://github.com/mozman/ezdxf/blob/748e211c9a0b977b9e15845a6bcad8e1b9743e26/docs/graphics/dim_linear_tutorial.dwg +- Licence: MIT +- Evidence: https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/LICENSE +- Words read there: "Copyright (c) 2020 Manfred Moitzi" +- Attribution: Copyright (c) 2020 Manfred Moitzi, ezdxf, MIT License. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/docs/graphics/dim_linear_tutorial.dwg +- SHA-256: 88037b595bd46d81c5c98ce7aa428ce6b8506d1aa4602e976811cfb154005155 + +### hospital-ward-wing-plan-section.dxf + +- What: Hospital ward wing, plan and section (DXF 2010) (dxf, 300 KB) +- Shows: 542 polylines, 17 layers of the A-* scheme, 3 blocks, a paper-space viewport +- Author: Innerscene (original model) +- Source: https://www.innerscene.com/tools/library/drawings/hospital-ward-wing-1200-square-metres-plan-and-section-73a3f4dd +- Licence: CC0-1.0 +- Evidence: https://www.innerscene.com/tools/library/drawings/hospital-ward-wing-1200-square-metres-plan-and-section-73a3f4dd +- Words read there: "Original Innerscene model, released CC0." +- Origin: downloaded from https://www.innerscene.com/api/library/hospital-ward-wing-1200-square-metres-plan-and-section-73a3f4dd/download +- SHA-256: 4fabf62d6d7bbc7c8bdc5f3c9d292d4980c3ec504281b0bd77f487d3c6bb9ba3 + +### grocery-store-plan-section.dxf + +- What: Grocery store, plan and section (DXF 2010) (dxf, 128 KB) +- Shows: 234 lines, 150 polylines, 17 layers, 3 blocks, a viewport +- Author: Innerscene (original model) +- Source: https://www.innerscene.com/tools/library/drawings/grocery-store-600-square-metres-plan-and-section-2a67d041 +- Licence: CC0-1.0 +- Evidence: https://www.innerscene.com/tools/library/drawings/grocery-store-600-square-metres-plan-and-section-2a67d041 +- Words read there: "Original Innerscene model, released CC0." +- Origin: downloaded from https://www.innerscene.com/api/library/grocery-store-600-square-metres-plan-and-section-2a67d041/download +- SHA-256: 0b875304e1769e0f7c9377abf270f1fb4d103547cc27a3795a57c34fb4619d30 + +### ezdxf-uncommon-entities.dxf + +- What: ezdxf example: uncommon entities (DXF 2018) (dxf, 335 KB) +- Shows: 13 dimensions, 2 multileaders, large radial dimensions, rays, construction lines, splines, 33 blocks, 2 extra layouts +- Author: Manfred Moitzi (ezdxf) +- Source: https://github.com/mozman/ezdxf/blob/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/uncommon.dxf +- Licence: MIT +- Evidence: https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/LICENSE +- Words read there: "Copyright (c) 2020 Manfred Moitzi" +- Attribution: Copyright (c) 2020 Manfred Moitzi, ezdxf, MIT License. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/uncommon.dxf +- SHA-256: 5efdd671e3b6d8c45bd0688b1d6479d523f2290f0b390f1584348bf46e34c965 + +### ezdxf-hatches-1.dxf + +- What: ezdxf example: solid hatches (DXF 2018) (dxf, 127 KB) +- Shows: ten solid hatches over polylines and arcs +- Author: Manfred Moitzi (ezdxf) +- Source: https://github.com/mozman/ezdxf/blob/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/hatches_1.dxf +- Licence: MIT +- Evidence: https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/LICENSE +- Words read there: "Copyright (c) 2020 Manfred Moitzi" +- Attribution: Copyright (c) 2020 Manfred Moitzi, ezdxf, MIT License. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/hatches_1.dxf +- SHA-256: 2dc2930b4f7d5fb16698437e4f6972986e580a81b2b5d36a780b4311f85fb326 + +## 3D + +### gear.stl + +- What: Spur gear, binary STL with facet colours (stl, 42 KB) +- Shows: 864 facets, orange teeth and blue hub through the VisCAM colour attribute +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-models.py` +- SHA-256: aac8dd0d87fed7b77061d32a5bbe61ac90cbfef16130b7c35730cdc1e77e93c9 + +### bracket-ascii.stl + +- What: L bracket, ASCII STL (stl, 8.8 KB) +- Shows: text STL, three overlapping boxes +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-models.py` +- SHA-256: dd4b8c55140214fe71a8200d4fe2ae7b51f53e6ab6184e82e79b3d2d12c708f9 + +### knot.obj + +- What: Trefoil knot and base, OBJ with vertex colours (obj, 98 KB) +- Shows: two objects (knot, base), a colour and a normal per vertex, quads, a rainbow along the knot +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-models.py` +- SHA-256: a2eaba271264d700edc372eaaa6944d342fe9b823c98e698b1445846bded3bf1 + +### terrain.ply + +- What: Height field, binary PLY with colours (ply, 117 KB) +- Shows: 2304 vertices with normals and a hypsometric colour each, 4418 triangles +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-models.py` +- SHA-256: 22c2f25d47c43841c1ed1327c6fb9e75725925ca1e3c61dc413c23319dcfde52 + +### icosphere-ascii.ply + +- What: Icosphere, ASCII PLY with colours (ply, 14 KB) +- Shows: text PLY with normals and RGBA per vertex +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-models.py` +- SHA-256: dbe5ae97077d8177503d5bab7d83fbc3f0a943181384fbffc68db7a4930f19be + +### arm.glb + +- What: Articulated arm in a glass dome, GLB (glb, 28 KB) +- Shows: node tree with rotations and scales, five materials, a metallic and a transparent one, vertex colours on the floor +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-models.py` +- SHA-256: 146ba6e29d9b8bf4ec57c72bde686a5119b80cfe4365f05f0f0b7004b066a7b7 + +### table.gltf + +- What: Table and two chairs, glTF with an embedded buffer (gltf, 7.6 KB) +- Shows: 17 nodes, a data URI buffer, shared meshes instanced under several nodes +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-models.py` +- SHA-256: ae656d2f656968fa44870ffc539d2383b85725289feb90e658881c5825bb7ef4 + +### house.ifc + +- What: Two-storey house (IFC4) (ifc, 65 KB) +- Shows: walls with openings filled by windows and doors, slabs, a gable roof, porch columns, a stair of eleven steps, a table and chairs sharing a mapped representation, spaces, a property set, a material and colours +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-ifc.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-ifc.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-ifc.py` +- SHA-256: d65c93ae607a5b03316662719f5995f9de3bfd94366383ab683838bbccfa4731 + +### bsi-building-architecture-ifc2x3.ifc + +- What: buildingSMART Simple-Scene Building-Architecture (IFC2X3) (ifc, 90 KB) +- Shows: a small SketchUp house: walls, slabs, a roof, spaces, materials with colours, quantity and property sets; faceted breps +- Author: buildingSMART International Ltd. +- Source: https://github.com/buildingSMART/Sample-Test-Files +- Licence: CC-BY-4.0 +- Evidence: https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/LICENSE +- Words read there: "This work is licensed under the Creative Commons Attribution 4.0 International License." +- Attribution: buildingSMART International, Sample-Test-Files, https://github.com/buildingSMART/Sample-Test-Files, licensed CC BY 4.0. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/IFC%202.3.0.1%20%28IFC%202x3%20TC1%29/Simple-Scene/Building-Architecture.ifc +- SHA-256: c4db65ba847f6b369a95d6c54fa11f4750cbe6d59f021934e8923d8d578e5885 + +### bsi-building-architecture-ifc4x3.ifc + +- What: buildingSMART Simple-Scene Building-Architecture (IFC4X3_ADD2) (ifc, 139 KB) +- Shows: the same house in IFC 4.3, with triangulated faces +- Author: buildingSMART International Ltd. +- Source: https://github.com/buildingSMART/Sample-Test-Files +- Licence: CC-BY-4.0 +- Evidence: https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/LICENSE +- Words read there: "This work is licensed under the Creative Commons Attribution 4.0 International License." +- Attribution: buildingSMART International, Sample-Test-Files, https://github.com/buildingSMART/Sample-Test-Files, licensed CC BY 4.0. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/IFC%204.3.2.0%20%28IFC%204.3%20ADD2%29/Simple-Scene/Building-Architecture.ifc +- SHA-256: 96e7103812b15b65eb1fd7802d36b9962833485dce5ead4f440bbf529530881d + +### bsi-wall-with-opening-and-window-ifc4.ifc + +- What: buildingSMART reference view: a wall with an opening and a window (IFC4) (ifc, 12 KB) +- Shows: a wall with a material layer set, an opening element and a window with its type +- Author: Peter Bonsma, RDF Ltd. (file header), distributed by buildingSMART International Ltd. +- Source: https://github.com/buildingSMART/Sample-Test-Files +- Licence: CC-BY-4.0 +- Evidence: https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/LICENSE +- Words read there: "This work is licensed under the Creative Commons Attribution 4.0 International License." +- Attribution: buildingSMART International, Sample-Test-Files, https://github.com/buildingSMART/Sample-Test-Files, licensed CC BY 4.0. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/IFC%204.0.2.1%20%28IFC%204%20ADD2%20TC1%29/ISO%20Spec%20-%20ReferenceView_V1.2/wall-with-opening-and-window.ifc +- SHA-256: 73b0e45d931d5dc13bfee5fdc7bd80f796526445458b2de74c4168d209097832 + +### duplex-apartment-ifc2x3.ifc + +- What: Duplex Apartment (IFC2X3, Revit export) (ifc, 2.3 MB) +- Shows: four storeys: 57 walls, 24 windows, 14 doors, 2 stairs, 21 spaces, furniture, 18 materials +- Author: Provided to the US Army Construction Engineering Research Laboratory, republished by buildingSMART International +- Source: https://github.com/buildingsmart-community/Community-Sample-Test-Files/tree/7ddf57a201f88a0c213d5322b02ed15e94a60a40/IFC%202.3.0.1%20(IFC%202x3)/Duplex%20Apartment +- Licence: CC-BY-4.0 +- Evidence: https://raw.githubusercontent.com/buildingsmart-community/Community-Sample-Test-Files/7ddf57a201f88a0c213d5322b02ed15e94a60a40/IFC%202.3.0.1%20%28IFC%202x3%29/Duplex%20Apartment/README.md +- Words read there: "Creative Commons (CC BY 4.0). You are free (1) to copy and resitribute this material any way you like" +- Attribution: BSI (2020) Duplex Apartment Test Files, buildingSMART International, https://github.com/buildingsmart-community/Community-Sample-Test-Files, CC BY 4.0. No changes. +- Origin: downloaded from https://media.githubusercontent.com/media/buildingsmart-community/Community-Sample-Test-Files/7ddf57a201f88a0c213d5322b02ed15e94a60a40/IFC%202.3.0.1%20%28IFC%202x3%29/Duplex%20Apartment/Duplex_A_20110907.ifc +- SHA-256: b347a2c8aa8fff6db896a4417a9c50c22ac0ccd7c5cfc22b99b8d29336c606ed + +### barraca-valenciana-ifc4.ifc + +- What: Barraca Valenciana de Llauradors, BIM model (IFC4, Revit export) (ifc, 1.5 MB) +- Shows: a traditional farmhouse: 2 storeys, 16 walls, 5 slabs, 2 roofs, a stair, 27 beams, 28 materials +- Author: Ã’scar Selfa +- Source: https://zenodo.org/records/14293897 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/14293897 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: Ã’scar Selfa, Barraca Valenciana de Llauradors: Model BIM i Estudi de la Tipologia Arquitectònica, Zenodo, doi:10.5281/zenodo.14293897, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/records/14293897/files/BRC-OSP-XXX-ZZZ-M3D-ARQ-0001-tipologiaGen%C3%A8rica-S3-0001.ifc?download=1 +- SHA-256: b6f8828e9bd2491dc452b2ab78f9f9e28f39bd81a5f2f4e2cdb2d81668287193 + +### office-archetype-1970-ifc4x3.ifc + +- What: Office archetype of 1970 (IFC4X3_ADD2) (ifc, 1.4 MB) +- Shows: four storeys: 88 walls, 167 windows, 49 doors, 16 curtain walls, 48 spaces +- Author: Verena Dannapfel, Moritz Müller, Rita Streblow, Dirk Müller et al., RWTH Aachen University +- Source: https://zenodo.org/records/21820756 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/records/21820756 +- Words read there: "Creative Commons Attribution 4.0 International" +- Attribution: Dannapfel, V., Müller, M., Streblow, R., Müller, D. et al., Archetypes for German Office Buildings of Construction Years 1970, 1998 and 2026, Zenodo, doi:10.5281/zenodo.21820756, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/records/21820756/files/2026-07_Klassiqua_Office_Archetype_Construction_Year_1970.ifc?download=1 +- SHA-256: a5f12304d33fe5da9174c8ea9827d2f322706646b3da2766dff0ab278c65df3c + +### fox.glb + +- What: Fox, skinned and animated (GLB) (glb, 159 KB) +- Shows: a low-poly fox with a skin, three animations and a texture +- Author: PixelMannen (model, CC0), tomkranis (rigging and animation, CC BY 4.0), glTF conversion by Asobo Studio and the Khronos Group +- Source: https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/Fox +- Licence: CC-BY-4.0 +- Evidence: https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/Fox/README.md +- Words read there: "© 2014, tomkranis. [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode)" +- Attribution: Fox: model by PixelMannen (CC0, https://opengameart.org/content/fox-and-shiba), rigging and animation by tomkranis (CC BY 4.0, https://sketchfab.com/3d-models/low-poly-fox-by-pixelmannen-animated-371dea88d7e04a76af5763f2a36866bc), glTF conversion by Asobo Studio and the Khronos Group. Unmodified. +- Origin: downloaded from https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/Fox/glTF-Binary/Fox.glb +- SHA-256: d97044e701822bac5a62696459b27d7b375aada5de8574ed4362edbba94771f7 + +### riggedfigure.gltf + +- What: Rigged Figure (glTF with an embedded buffer) (gltf, 98 KB) +- Shows: a skinned figure, a data URI buffer +- Author: Cesium +- Source: https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/RiggedFigure +- Licence: CC-BY-4.0 +- Evidence: https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/RiggedFigure/README.md +- Words read there: "© 2017, Cesium. [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode)" +- Attribution: Rigged Figure by Cesium, CC BY 4.0, https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/RiggedFigure. Unmodified. +- Origin: downloaded from https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/RiggedFigure/glTF-Embedded/RiggedFigure.gltf +- SHA-256: 354572cfef17b136b437ac9749da0fa316fd6e42ee7d62fe50a696b25f5047e9 + +### vertexcolortest.glb + +- What: Vertex Color Test (GLB) (glb, 26 KB) +- Shows: COLOR_0 vertex colours, tangents, two textures +- Author: Ed Mackey, Analytical Graphics, Inc. (Cesium) +- Source: https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/VertexColorTest +- Licence: CC-BY-4.0 +- Evidence: https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/VertexColorTest/README.md +- Words read there: "© 2018, Analytical Graphics, Inc.. [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode)" +- Attribution: Vertex Color Test by Ed Mackey, Copyright 2018 Analytical Graphics, Inc., CC BY 4.0, https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/VertexColorTest. Unmodified. +- Origin: downloaded from https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/VertexColorTest/glTF-Binary/VertexColorTest.glb +- SHA-256: 58006bdcff8084339da0f6e24400160890638c16dbcb83c362ccaf150e8c6e10 + +### metalroughspheres.glb + +- What: Metal-Rough spheres (GLB) (glb, 284 KB) +- Shows: 119 nodes and 98 materials of growing metalness and roughness +- Author: Krill Gavrilov / Draw Harness script (Open CASCADE Technology) +- Source: https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/MetalRoughSpheresNoTextures +- Licence: CC0-1.0 +- Evidence: https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/MetalRoughSpheresNoTextures/README.md +- Words read there: "© 2019, Public. [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode)" +- Origin: downloaded from https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/MetalRoughSpheresNoTextures/glTF-Binary/MetalRoughSpheresNoTextures.glb +- SHA-256: 5e677f260ec0f366967a6e9545de2f3dab2a160e307b9bcb9d050cd2028f63f8 + +### iridescencesuzanne.glb + +- What: Iridescence Suzanne (GLB) (glb, 496 KB) +- Shows: Suzanne with the iridescence, transmission and volume extensions +- Author: Mathias Kanzler (UX3D), Pascal Schoen +- Source: https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/IridescenceSuzanne +- Licence: CC0-1.0 +- Evidence: https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/IridescenceSuzanne/README.md +- Words read there: "© 2022, UX3D. [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode)" +- Origin: downloaded from https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/IridescenceSuzanne/glTF-Binary/IridescenceSuzanne.glb +- SHA-256: 866762602d60a0942b7608bfabf4e8686a29033636224f4b55c5e56abe8abf8f + +### ratchet-cam.stl + +- What: Apollo 11 hatch, stationary ratchet cam (binary STL) (stl, 75 KB) +- Shows: 1536 triangles, a mechanical part +- Author: NASA Johnson Space Center, transferred to the Smithsonian Institution; model published on Wikimedia Commons +- Source: https://commons.wikimedia.org/wiki/File:Stationary_Ratchet_Cam_v11_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl +- Licence: CC0-1.0 +- Evidence: https://commons.wikimedia.org/wiki/File:Stationary_Ratchet_Cam_v11_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl +- Words read there: "CC0 1.0 Universal Public Domain Dedication" +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/e/e0/Stationary_Ratchet_Cam_v11_%28Hatch%2C_Crew%2C_Apollo_11_by_NASA_and_Smithsonian_Institution%29.stl +- SHA-256: b42d79fb357413b118b973f3fa40b76ae2999451537cd991bebce18b486c581f + +### ratchet-spur-gear.stl + +- What: Apollo 11 hatch, stationary ratchet spur gear (binary STL) (stl, 148 KB) +- Shows: 3026 triangles, a gear with 19 teeth +- Author: NASA Johnson Space Center, transferred to the Smithsonian Institution; model published on Wikimedia Commons +- Source: https://commons.wikimedia.org/wiki/File:N=19,_P=28_-_Stat._Ratchet_Spur_Gear_v13_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl +- Licence: CC0-1.0 +- Evidence: https://commons.wikimedia.org/wiki/File:N=19,_P=28_-_Stat._Ratchet_Spur_Gear_v13_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl +- Words read there: "CC0 1.0 Universal Public Domain Dedication" +- Origin: downloaded from https://upload.wikimedia.org/wikipedia/commons/c/cb/N%3D19%2C_P%3D28_-_Stat._Ratchet_Spur_Gear_v13_%28Hatch%2C_Crew%2C_Apollo_11_by_NASA_and_Smithsonian_Institution%29.stl +- SHA-256: c99eca56ab40ed6b2e9dfc49b88c23c6fbef982ff18813ba593d3e138ef1f51d + +### petri-dish-holder-base.stl + +- What: Petri dish holder base of a spore capture device (ASCII STL) (stl, 148 KB) +- Shows: 566 facets as text +- Author: Kevin A. Adkins, Kaiyu Li, M. N. Blasko, J. L. Cabrera, B. H. Neal, James et al. +- Source: https://zenodo.org/records/12193898 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/api/records/12193898 +- Words read there: ""license": {"id": "cc-by-4.0"}" +- Attribution: Components of the spore capture device in A simple mechanism for uncrewed aircraft bioaerosol sampling in the lower atmosphere (Inspire1Petri_Dish_Holder_Base.stl), Kevin A. Adkins et al., Zenodo 2024, doi:10.5281/zenodo.12193898, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/api/records/12193898/files/Inspire1Petri_Dish_Holder_Base.stl/content +- SHA-256: d654bf60064e79675acd9694726282c70251905d77391ad45c29d3ba3ecaf93e + +### chickpea-mesh.ply + +- What: Photogrammetric mesh of a chickpea plant (binary PLY with colours) (ply, 198 KB) +- Shows: 8788 triangles, RGBA per vertex +- Author: William T. Salter, Arjina Shrestha, Margaret M. Barbour +- Source: https://zenodo.org/records/4018242 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/api/records/4018242 +- Words read there: ""license": {"id": "cc-by-4.0"}" +- Attribution: 3D photogrammetric reconstructions of individual chickpea plants (PBASlasher_S3s_Wk1_meshed.ply), William T. Salter, Arjina Shrestha and Margaret M. Barbour, Zenodo 2020, doi:10.5281/zenodo.4018242, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/api/records/4018242/files/PBASlasher_S3s_Wk1_meshed.ply/content +- SHA-256: ef9cd2141897781c8e3e6a72ac316056b991d4d0fae99e053c4695fceea8cb75 + +### ruthwell-lamb-of-god.ply + +- What: Lamb of God panel of the Ruthwell Cross (binary PLY with normals and colours) (ply, 873 KB) +- Shows: 33020 triangles, a scanned stone carving +- Author: Gurpreet Singh +- Source: https://zenodo.org/records/3348342 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/api/records/3348342 +- Words read there: ""license": {"id": "cc-by-4.0"}" +- Attribution: Small Panel PLY (Lamb of God panel from the Ruthwell Cross), Gurpreet Singh, Zenodo 2019, doi:10.5281/zenodo.3348342, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/api/records/3348342/files/Small_Lamb_Of_God_North_Ruthwell_Cross.ply/content +- SHA-256: 0d40a4305885932596afce091891a8f4b212b0c012b742db10d4487351c2457a + +## Audio and video + +### canon.wav + +- What: A four-voice canon, WAV (wav, 131 KB) +- Shows: 8 kHz mono 16-bit, 8.4 seconds, synthesised +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-media.py` (with ffmpeg) +- SHA-256: c26b3aa385a70bb6785c7eb36b0bf3fe04a379fe46294d77d29ad153d0cc1028 + +### canon.mp3 + +- What: The canon as MP3 (mp3, 25 KB) +- Shows: 24 kbit/s +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-media.py` (with ffmpeg) +- SHA-256: c9f569a2833d11af90232c2f950c865f02bfbcc5822fc35cccb7466dd9c46c49 + +### canon.flac + +- What: The canon as FLAC (flac, 73 KB) +- Shows: lossless, level 8 +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-media.py` (with ffmpeg) +- SHA-256: c3dd10ace8ccaacb09f74ca73619e0d991ff94ce3d25fdc20ee9fb4597bc8a63 + +### canon.ogg + +- What: The canon as Ogg Vorbis (ogg, 14 KB) +- Shows: quality 2 +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-media.py` (with ffmpeg) +- SHA-256: 39c1dabbcadc817bfa1fc76839b1ee6940bab7187dffd4c495a5f61362cb91ad + +### canon.opus + +- What: The canon as Opus (opus, 16 KB) +- Shows: 16 kbit/s +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-media.py` (with ffmpeg) +- SHA-256: e18075a4586929741c6bf264c92e8f7f35086f5e4ab2f3be58845835556bc9ae + +### canon.m4a + +- What: The canon as AAC in MP4 (m4a, 24 KB) +- Shows: 24 kbit/s +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-media.py` (with ffmpeg) +- SHA-256: edd56750fd23edd327a46ea907c9d34d53fcff6e04f2d0516a1083ea2c7306e5 + +### testcard.mp4 + +- What: Test card with a clock, H.264 and AAC in MP4 (mp4, 41 KB) +- Shows: 256 by 192, 12 frames a second, 4 seconds, with the canon as sound +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-media.py` (with ffmpeg) +- SHA-256: ad088934ae59962bafc0b9b56a57556623d5e2b7821869e2dc91047573213a12 + +### testcard.webm + +- What: Test card with a clock, VP9 and Opus in WebM (webm, 41 KB) +- Shows: 256 by 192, 12 frames a second, 4 seconds, with the canon as sound +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-media.py` (with ffmpeg) +- SHA-256: dca14e93bb8e3448a5d9a61fbbe402a7cf2b0c8f476a13b98f5649d079a1b7b8 + +## Archives + +### sample.zip + +- What: Zip with folders, stored and deflated members, Unicode names and a comment (zip, 15 KB) +- Shows: 9 files in 5 folders, an empty folder +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: ad98593f9dc85d5dad309140ce703f96b36da749e48ea6648ce10c53a418250d + +### sample.tar + +- What: Tar (ustar) with folders and a symbolic link (tar, 50 KB) +- Shows: 16 entries +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: b31804242af3545fd922934e81a2aade8fd09a7e2648fe26b41cd32dc37e1fb8 + +### sample.tar.gz + +- What: Tar compressed with gzip (tar.gz, 15 KB) +- Shows: the tar opens inside the gzip +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: 5c423a4e7e0d96e025bb871034a88308f1d32861f60826074c29aa121fcdecca + +### sample.tar.bz2 + +- What: Tar compressed with bzip2 (tar.bz2, 14 KB) +- Shows: the tar opens inside the bzip2 stream +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: 99b6742b5c54a36261f06a24032c22c11ed84b01e40e2267ef338b15ca181fcf + +### sample.tar.xz + +- What: Tar compressed with xz (tar.xz, 13 KB) +- Shows: the tar opens inside the xz stream +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: 762ce440ee2dfd2f4d22a6d29f2535e7079ad21d461d7fce3167cd62a9f356bf + +### sample.tar.zst + +- What: Tar compressed with Zstandard (tar.zst, 14 KB) +- Shows: level 19 +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with zstd) +- SHA-256: d134793d16db00229eb192be488ef248a7c3e1226d9d27b67d8a763cd9b0641d + +### server.log.gz + +- What: A log compressed with gzip (gz, 1.3 KB) +- Shows: one member, no tar +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: 9f80faaae63eb4ae0a3a68c1346966049cb6ad5eba17e9c8600eb404ced75922 + +### server.log.bz2 + +- What: A log compressed with bzip2 (bz2, 1.1 KB) +- Shows: one member, no tar +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: 4b235dc9fc1a2840be3dd7ed792c7e4bc373851130e7122de3e798e7061b39c4 + +### server.log.xz + +- What: A log compressed with xz (xz, 820 B) +- Shows: one member, no tar +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: 7dec383660d188a36f28cae361ebb809914849ddf10a508fc47503f367c1e86c + +### sample.7z + +- What: 7z with LZMA2 (7z, 13 KB) +- Shows: a solid archive of 9 files +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with 7z) +- SHA-256: 7a2df3dd03ae207f3be0175190e3795662753e701d36555cd06e7295a72483c9 + +### sample.rar + +- What: RAR 5 with stored members (rar, 34 KB) +- Shows: folders, CRC32 and Unix modes, written by the script from the format's documentation (no RAR program) +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: 71aa4939bc7f2e4a98cc484e7353b829ad8a5019b1dee3911e8df3548e317c90 + +### sample.iso + +- What: ISO 9660 image with Joliet and Rock Ridge (iso, 130 KB) +- Shows: volume EXAV_EXAMPLE, long and Unicode names +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with genisoimage) +- SHA-256: baa0c331dc2f1f8e3f04c171282faf7753a38d380e82e0ca894c70a04f08d4c0 + +### matryoshka.zip + +- What: Nested archives: a zip holding a tar.gz holding a 7z holding a zip (zip, 7.1 KB) +- Shows: four levels, opened in place one after the other +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with 7z) +- SHA-256: 0c4d4171bd6cd565a17ff675763cebf6c8d8c9408ebd6c738d436feab6cb42e9 + +### encrypted-aes.zip + +- What: Zip encrypted with AES-256, password: exav (zip, 16 KB) +- Shows: names are listed, each member says it is encrypted +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with 7z) +- SHA-256: e6cd983b8b1694a5df2c2f7d7790a249e4e9c529cb0d796e6d05de7a4bb03bd0 + +### encrypted-zipcrypto.zip + +- What: Zip encrypted with the traditional ZipCrypto, password: exav (zip, 16 KB) +- Shows: names are listed, each member says it is encrypted +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with 7z) +- SHA-256: 21053827c2871cda2d2f6259dc1d3848e318b40c0e71858220d601fc3c90767c + +### encrypted.7z + +- What: 7z encrypted with AES-256, password: exav (7z, 13 KB) +- Shows: file names visible, contents encrypted +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with 7z) +- SHA-256: c2606ee4ee72de0d9ba1461ba976c5ac20de157b6b17771372bd6acf221cc6ce + +### example.jar + +- What: A jar (a zip with a manifest) (jar, 793 B) +- Shows: META-INF/MANIFEST.MF, a source file, a properties file +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: 44aeabd990d5a5c75f88846f5310ad44a9b56deadde592ec3d932cf13476ff45 + +### pages.cbz + +- What: Comic book archive (zip of pages) (cbz, 10 KB) +- Shows: two page images and ComicInfo.xml +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ) +- SHA-256: 82dc884486eec4ca4fbeb3c016d6431848d0a2ccd843ab6e65972503524b0723 + +### sample.cab + +- What: Microsoft cabinet (cab, 6.1 KB) +- Shows: four files, MSZIP +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with gcab) +- SHA-256: 5f12d6c186b5cc81f70dc4b3d4a4d7b4b7bdebb74f002ada9067b4738589de00 + +### exav-example.deb + +- What: Debian package (deb, 2.2 KB) +- Shows: an ar archive of debian-binary, control.tar.xz and data.tar.xz +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with dpkg-deb) +- SHA-256: d73cac532f34c9948bc596e1241189b66d4d4497af030d766bbb451b4ab1adea + +### libexample.a + +- What: Unix ar archive (ar, 1.9 KB) +- Shows: two members +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with ar) +- SHA-256: 59396e78830b3119a01419e202810446dc15c312cf4b91727170b181d0afaf3e + +### sample.cpio + +- What: cpio archive (newc) (cpio, 7.0 KB) +- Shows: four files +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-archives.py` (with cpio) +- SHA-256: aab3c3fdd392a978d0a516e5a420bbed6b97c203966c1ed275d7246a93ab9827 + +## Unsupported + +### fax-g3-1d.tif + +- What: Fax page, CCITT Group 3 (T.4, one-dimensional) TIFF (tiff-fax, 60 KB) +- Shows: kept to show the error: the TIFF decoder reads Group 4 only +- Author: exav project +- Source: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Licence: CC0-1.0 +- Evidence: https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py +- Words read there: "Output: CC0-1.0, a public domain dedication by the exav project." +- Origin: generated by `generate/make-images.py` +- SHA-256: ee3c004c31ac0c49f045db053347a219bcce795a074931ad15463fc86fd208ed + +### minolta-dimage-5.mrw + +- What: Minolta DiMAGE 5 (MRW) (raw-mrw, 6.1 MB) +- Shows: MRW with a 640 by 480 preview whose first byte is stored as zero: the viewer finds no preview in it (kept to show the message) +- Author: Uploader to raw.pixls.us (the site gives no name) +- Source: https://raw.pixls.us/#repo +- Licence: CC0-1.0 +- Evidence: https://raw.pixls.us/json/getrepository.php?set=all +- Words read there: "Creative Commons 0 - Public Domain" +- Origin: downloaded from https://raw.pixls.us/getfile.php/7795/nice/Minolta%20-%20DiMAGE%205%20-%204%3A3.MRW +- SHA-256: d60bfd80bcb1f7b9c88b14a5e46ea27885bbf92d990ab30b3939288678fea0d9 + +### chickpea-pointcloud-ascii.ply + +- What: Point cloud of a chickpea plant (ASCII PLY without faces) (ply, 3.3 MB) +- Shows: 53195 coloured points and no faces: kept to show what the viewer says about a point cloud +- Author: William T. Salter, Arjina Shrestha, Margaret M. Barbour +- Source: https://zenodo.org/records/4018242 +- Licence: CC-BY-4.0 +- Evidence: https://zenodo.org/api/records/4018242 +- Words read there: ""license": {"id": "cc-by-4.0"}" +- Attribution: 3D photogrammetric reconstructions of individual chickpea plants (SonSla_3_groundtruth_pointcloud.ply), William T. Salter, Arjina Shrestha and Margaret M. Barbour, Zenodo 2020, doi:10.5281/zenodo.4018242, CC BY 4.0. No changes. +- Origin: downloaded from https://zenodo.org/api/records/4018242/files/SonSla_3_groundtruth_pointcloud.ply/content +- SHA-256: 53a08f6e1c831ac58c42342ee257c97730bcf6997e246c88198478f9aeba4fe0 + +### tika-pages09-layout.pages + +- What: Apache Tika test document (Pages '09) (pages, 65 KB) +- Shows: kept to show what the viewer says about Pages files, which it does not list yet +- Author: Michael McCandless (Apache Tika) +- Source: https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testPagesLayout.pages +- Licence: Apache-2.0 +- Evidence: https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt +- Words read there: "Version 2.0, January 2004" +- Attribution: Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes. +- Origin: downloaded from https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testPagesLayout.pages +- SHA-256: 6157fe7a95f7bc7d42251872f990d2f2d589f7f5e25a52b58e25c14cc447a4b2 + diff --git a/crates/exav-viewer/demo/examples/check.mjs b/crates/exav-viewer/demo/examples/check.mjs new file mode 100644 index 00000000..4faf29ec --- /dev/null +++ b/crates/exav-viewer/demo/examples/check.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Checks demo/examples/examples.json against the files: +// +// node demo/examples/check.mjs # the manifest and files/ +// node demo/examples/check.mjs --public # also demo/public/examples/ (after `npm run demo:examples`) +// +// Fails (exit 1) on a missing field, a licence that is not allowed, a missing +// attribution, a file whose size or SHA-256 differs, a file in files/ that the +// manifest does not list, a group the demo does not know, or a script that is +// not there. +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pkg = path.join(here, "..", ".."); +const repo = path.join(pkg, "..", ".."); +const withPublic = process.argv.includes("--public"); + +const manifest = JSON.parse(fs.readFileSync(path.join(here, "examples.json"), "utf8")); +const problems = []; +const bad = (file, msg) => problems.push(`${file}: ${msg}`); + +// Public domain, CC0, CC-BY, and MIT, BSD and Apache test files. No share-alike or copyleft licence. +const SPDX = /^(CC0-1\.0|CC-BY-(1\.0|2\.0|2\.5|3\.0|4\.0)|MIT|BSD-2-Clause|BSD-3-Clause|Apache-2\.0)$/; +const NEEDS_ATTRIBUTION = /^(CC-BY|MIT|BSD|Apache)/; +const REFUSED = /\b(GPL|LGPL|AGPL|MPL|NC|ND|non-?commercial|no derivatives)\b/i; + +const groups = new Set([...fs.readFileSync(path.join(pkg, "demo", "src", "showcase.ts"), "utf8").matchAll(/^\s+(?:"([\w-]+)"|(\w+)): \{ en: /gm)].map((m) => m[1] ?? m[2])); + +const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex"); +const isUrl = (s) => typeof s === "string" && /^https:\/\/[^\s]+$/.test(s); +const names = new Set(); +const paths = new Set(); +let committedBytes = 0; +let fetchedBytes = 0; + +for (const e of manifest.examples) { + const f = e.file ?? "(no file)"; + for (const k of ["file", "group", "family", "title", "author", "source", "evidence", "evidence_quote", "licence", "sha256"]) { + if (typeof e[k] !== "string" || !e[k].trim()) bad(f, `no ${k}`); + } + if (typeof e.attribution !== "string") bad(f, "no attribution (use \"\" when the licence asks for none)"); + if (!Number.isInteger(e.bytes) || e.bytes <= 0) bad(f, "no bytes"); + if (typeof e.committed !== "boolean") bad(f, "no committed flag"); + if (names.has(e.file)) bad(f, "listed twice"); + names.add(e.file); + if (e.file && e.file !== path.basename(e.file)) bad(f, "not a plain file name"); + if (e.group && !groups.has(e.group)) bad(f, `group ${e.group} is not in demo/src/showcase.ts`); + if (!isUrl(e.source)) bad(f, "source is not an https URL"); + if (!isUrl(e.evidence)) bad(f, "evidence is not an https URL"); + if (e.sha256 && !/^[0-9a-f]{64}$/.test(e.sha256)) bad(f, "sha256 is not 64 hex digits"); + + const lic = e.licence ?? ""; + if (!SPDX.test(lic) && !/^public-domain \(.{8,}\)$/.test(lic)) bad(f, `licence "${lic}" is not allowed (use an SPDX id of the list, or "public-domain (reason)")`); + if (REFUSED.test(lic)) bad(f, `licence "${lic}" is refused`); + if (NEEDS_ATTRIBUTION.test(lic) && !(e.attribution ?? "").trim()) bad(f, `${lic} needs an attribution line`); + if (e.attribution && /—|load-bearing|genuinely/i.test(e.attribution + e.title)) bad(f, "dash or AI wording in the credit"); + + const origins = ["generated", "fetched", "fixture"].filter((k) => e[k] !== undefined); + if (!origins.length) bad(f, "none of generated, fetched, fixture"); + if (e.generated) { + if (typeof e.generated.script !== "string" || !Array.isArray(e.generated.args)) bad(f, "generated needs a script and its args"); + else if (!fs.existsSync(path.join(here, e.generated.script))) bad(f, `script ${e.generated.script} is missing`); + } + if (e.fetched !== undefined && !isUrl(e.fetched)) bad(f, "fetched is not an https URL"); + if (e.fixture !== undefined && !fs.existsSync(path.join(repo, e.fixture))) bad(f, `fixture ${e.fixture} is not in the repository`); + if (!e.committed && !isUrl(e.fetched)) bad(f, "not committed and not fetched: nothing to serve"); + + if (e.committed) { + if (!e.path) { + bad(f, "committed without a path"); + continue; + } + paths.add(path.normalize(e.path)); + const p = path.join(here, "files", e.path); + if (!fs.existsSync(p)) { + bad(f, `files/${e.path} is missing`); + continue; + } + const data = fs.readFileSync(p); + if (data.length !== e.bytes) bad(f, `files/${e.path} is ${data.length} bytes, the manifest says ${e.bytes}`); + if (sha256(data) !== e.sha256) bad(f, `files/${e.path} has sha256 ${sha256(data)}, the manifest says ${e.sha256}`); + if (path.basename(e.path) !== e.file) bad(f, "path and file name differ"); + if (data.length > 1_000_000) bad(f, `committed file of ${data.length} bytes: over 1 MB goes by download`); + committedBytes += data.length; + } else { + if (e.bytes > 12_000_000) bad(f, `fetched file of ${e.bytes} bytes is too large`); + fetchedBytes += e.bytes; + } + + if (withPublic) { + const p = path.join(pkg, "demo", "public", "examples", e.file); + if (fs.existsSync(p)) { + const data = fs.readFileSync(p); + if (data.length !== e.bytes || sha256(data) !== e.sha256) bad(f, `demo/public/examples/${e.file} does not match the manifest`); + } else if (e.committed) { + bad(f, `demo/public/examples/${e.file} is missing: run npm run demo:examples`); + } + } +} + +// Every file under files/ is listed. +const listed = (dir, base = "") => + fs.readdirSync(path.join(dir, base), { withFileTypes: true }).flatMap((d) => (d.isDirectory() ? listed(dir, path.join(base, d.name)) : [path.join(base, d.name)])); +const filesDir = path.join(here, "files"); +if (fs.existsSync(filesDir)) for (const p of listed(filesDir)) if (!paths.has(path.normalize(p))) bad(p, "in files/ but not in the manifest"); + +if (problems.length) { + console.error(`${problems.length} problem${problems.length === 1 ? "" : "s"} in demo/examples/examples.json:\n ${problems.join("\n ")}`); + process.exit(1); +} +console.log(`${manifest.examples.length} examples ok: ${committedBytes} bytes committed, ${fetchedBytes} bytes fetched`); diff --git a/crates/exav-viewer/demo/examples/examples.json b/crates/exav-viewer/demo/examples/examples.json new file mode 100644 index 00000000..e9a3418f --- /dev/null +++ b/crates/exav-viewer/demo/examples/examples.json @@ -0,0 +1,4703 @@ +{ + "version": 1, + "examples": [ + { + "file": "scene.png", + "group": "images", + "family": "png", + "title": "Dusk landscape, PNG", + "features": "8-bit RGB, optimised", + "path": "images/scene.png", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 26756, + "sha256": "4a0f00c07bb229932be134ede00f78851c8b1c4d3fa3eaa32164bb97c424f3fe" + }, + { + "file": "scene-alpha.png", + "group": "images", + "family": "png", + "title": "Landscape fading to transparent, PNG", + "features": "RGBA: the viewer shows its chequerboard through the alpha", + "path": "images/scene-alpha.png", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 48816, + "sha256": "c06a1651cfa68c3c729ba2a94eb0e6a3ccb306a24b3e4029d909a0da3e705a69" + }, + { + "file": "scene-indexed.png", + "group": "images", + "family": "png", + "title": "Landscape in 32 colours, PNG", + "features": "palette (indexed) PNG", + "path": "images/scene-indexed.png", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 5152, + "sha256": "ebd709f812945dd233203f4dbea12c22af3450bd67be52dbc6668f35f55a0e58" + }, + { + "file": "gradient-grey16.png", + "group": "images", + "family": "png", + "title": "Grey ramp, 16-bit PNG", + "features": "16-bit greyscale", + "path": "images/gradient-grey16.png", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 48383, + "sha256": "594a376de8d26c143d65c012f82aafd3775e8bd6698cd168ce957638c636379d" + }, + { + "file": "bounce.png", + "group": "images", + "family": "apng", + "title": "Bouncing ball, animated PNG", + "features": "24 frames, APNG", + "path": "images/bounce.png", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 41428, + "sha256": "dbe78676dee3a09eb44aafd14980b8999f11bf576e1152d2183193a9c18ff131" + }, + { + "file": "scene.jpg", + "group": "images", + "family": "jpeg", + "title": "Dusk landscape, JPEG", + "features": "baseline 4:2:0 with EXIF description and copyright", + "path": "images/scene.jpg", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 24631, + "sha256": "42f3881913d6401dc2df511f6a975caeb834f965299c152f5535aa3ae6a5e02e" + }, + { + "file": "scene-progressive.jpg", + "group": "images", + "family": "jpeg", + "title": "Dusk landscape, progressive JPEG", + "features": "progressive scans, optimised Huffman tables", + "path": "images/scene-progressive.jpg", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 20456, + "sha256": "70c6c3352c093ac5da448706cb7873dc1fa991f84119d37f80acf63550078f65" + }, + { + "file": "scene-orientation6.jpg", + "group": "images", + "family": "jpeg", + "title": "JPEG stored on its side with EXIF orientation 6", + "features": "shown upright through the EXIF orientation", + "path": "images/scene-orientation6.jpg", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 22193, + "sha256": "02e8b993e435a3de434c7efbe688757efa2fef452b1747c7382732b63e8e971b" + }, + { + "file": "scene-grey.jpg", + "group": "images", + "family": "jpeg", + "title": "Landscape in grey, JPEG", + "features": "single-component JPEG", + "path": "images/scene-grey.jpg", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 17273, + "sha256": "546838a80bc5b7b042fdc593b8e95fbaaf39165347dcba32c45d341f2f5dabea" + }, + { + "file": "scene-cmyk.jpg", + "group": "images", + "family": "jpeg", + "title": "Landscape as a CMYK JPEG", + "features": "four components (Adobe inverted CMYK)", + "path": "images/scene-cmyk.jpg", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 52705, + "sha256": "2c85449ef1574bc35fc3f54cf8aa724e518180aa86488ecef2be603816bfac01" + }, + { + "file": "bounce.gif", + "group": "images", + "family": "gif", + "title": "Bouncing ball, animated GIF", + "features": "24 frames with a palette each", + "path": "images/bounce.gif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 102524, + "sha256": "386425b57344417f2205ac176a3a72eaed208ad67e57f7cd304ab845bf27d83b" + }, + { + "file": "scene.gif", + "group": "images", + "family": "gif", + "title": "Dusk landscape, dithered GIF", + "features": "256 colours, Floyd-Steinberg dithering", + "path": "images/scene.gif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 25539, + "sha256": "9680147c7fabe6ba25e464c9cd41fe6e45b73502e68e811ea7037922f8c33255" + }, + { + "file": "scene.webp", + "group": "images", + "family": "webp", + "title": "Dusk landscape, lossy WebP", + "features": "VP8", + "path": "images/scene.webp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 9040, + "sha256": "c5a026e0341b0952e99af9ff4fa34de68e7ecc2da3326421e79a66639fef4787" + }, + { + "file": "scene-lossless.webp", + "group": "images", + "family": "webp", + "title": "Dusk landscape, lossless WebP", + "features": "VP8L", + "path": "images/scene-lossless.webp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 18644, + "sha256": "7455a0e9ae36e7e9fb3f5b6ee094fcbece393dd9444d37cee3db785e36b01e79" + }, + { + "file": "scene-alpha.webp", + "group": "images", + "family": "webp", + "title": "Landscape fading to transparent, WebP", + "features": "lossy colour with an alpha plane (VP8X, ALPH)", + "path": "images/scene-alpha.webp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 18784, + "sha256": "d62a7a8f0b2655221b84eec04c8adfd3d5f37a59425a706fa0eea900cc05b525" + }, + { + "file": "bounce.webp", + "group": "images", + "family": "webp", + "title": "Bouncing ball, animated WebP", + "features": "24 frames", + "path": "images/bounce.webp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 20748, + "sha256": "edb4905dd92651bd42dbc3c15873913ec6bdca9a04afe83740565e082d90b84c" + }, + { + "file": "scene-lzw.tif", + "group": "images", + "family": "tiff", + "title": "Dusk landscape, TIFF with LZW", + "features": "RGB, LZW, 300 dpi", + "path": "images/scene-lzw.tif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 96668, + "sha256": "b3a41a6e8e4644e2b2291a50e499fd9988b735932c6c27def20587c1f565bcaa" + }, + { + "file": "scene-jpeg.tif", + "group": "images", + "family": "tiff", + "title": "Dusk landscape, TIFF with JPEG compression", + "features": "JPEG in TIFF (compression 7)", + "path": "images/scene-jpeg.tif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 57205, + "sha256": "c5add42f305af0ca4dcf2cd554dc973611ce1334e5e91ba2b38ec2c58a35e89b" + }, + { + "file": "scene-cmyk-packbits.tif", + "group": "images", + "family": "tiff", + "title": "Landscape as a CMYK TIFF", + "features": "four inks, PackBits", + "path": "images/scene-cmyk-packbits.tif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 49730, + "sha256": "57d7dbd0c807d1bcd7064367051ff75cdedddc4109e85cf67450c6601ec48fbc" + }, + { + "file": "gradient-grey16.tif", + "group": "images", + "family": "tiff", + "title": "Grey ramp, 16-bit TIFF", + "features": "16-bit greyscale, Deflate", + "path": "images/gradient-grey16.tif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 93424, + "sha256": "94c41cab542120478f4a7b97e694e8ddddd2520b022f9266a59bcf0e727baeea" + }, + { + "file": "scene-pages.tif", + "group": "images", + "family": "tiff", + "title": "Three-page TIFF", + "features": "pages of 512, 256 and 128 pixels, the last in grey, Deflate", + "path": "images/scene-pages.tif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 84896, + "sha256": "9a8267875af78208d587777b341eb152d166b6acc10aede6d198d8e788ebd7a6" + }, + { + "file": "fax-g4.tif", + "group": "images", + "family": "tiff-fax", + "title": "Fax cover sheet and register, CCITT Group 4 TIFF", + "features": "1-bit, 1728 by 2200 at 204 by 196 dpi, T.6", + "path": "images/fax-g4.tif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 22702, + "sha256": "c4efa355dc63ce59863446b62b960397f34ab3d3c04a212450d0c2bca5f3904a" + }, + { + "file": "fax-g4-3pages.tif", + "group": "images", + "family": "tiff-fax", + "title": "Three-page fax, CCITT Group 4 TIFF", + "features": "three pages, T.6", + "path": "images/fax-g4-3pages.tif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 68092, + "sha256": "a94bc11060a7f2553803580c0feb779d1e59b032fd9f4406fbd4972765842cbb" + }, + { + "file": "fax-g3-1d.tif", + "group": "unsupported", + "family": "tiff-fax", + "title": "Fax page, CCITT Group 3 (T.4, one-dimensional) TIFF", + "features": "kept to show the error: the TIFF decoder reads Group 4 only", + "path": "images/fax-g3-1d.tif", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 61464, + "sha256": "ee3c004c31ac0c49f045db053347a219bcce795a074931ad15463fc86fd208ed" + }, + { + "file": "scene-24.bmp", + "group": "images", + "family": "bmp", + "title": "Dusk landscape, 24-bit BMP", + "features": "BI_RGB, bottom-up", + "path": "images/scene-24.bmp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 57654, + "sha256": "f3281d18d1f2944c7cb5f60eac3593facfbb3b77a0e5505e1ac88a9fdbefbfaa" + }, + { + "file": "scene-8.bmp", + "group": "images", + "family": "bmp", + "title": "Dusk landscape, 8-bit BMP", + "features": "256-colour palette", + "path": "images/scene-8.bmp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 20278, + "sha256": "d8ac5f35b9b76cd509191ec7f5c4a48f8236c5ca4dcc88197810060b16f47ad2" + }, + { + "file": "scene-32.bmp", + "group": "images", + "family": "bmp", + "title": "Dusk landscape, 32-bit BMP", + "features": "four channels", + "path": "images/scene-32.bmp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 76854, + "sha256": "1d99799f83adc1486eb1072502893ed39d91220d9e132a0716d56d9e05285d94" + }, + { + "file": "scene-1bit.bmp", + "group": "images", + "family": "bmp", + "title": "Dusk landscape, 1-bit BMP", + "features": "two-colour palette", + "path": "images/scene-1bit.bmp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 24638, + "sha256": "cc5152076963ba9beca3d0a5ed8a4ebd5f082c127e780ef68a955ce4b71e8786" + }, + { + "file": "scene-rle8.bmp", + "group": "images", + "family": "bmp", + "title": "Dusk landscape, 8-bit BMP with RLE compression", + "features": "BI_RLE8 with encoded and absolute runs", + "path": "images/scene-rle8.bmp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 10838, + "sha256": "2b1c69a7597006677e38cd8e32ad11f85acb0602ef228ec5c080d03350fc7936" + }, + { + "file": "icon.ico", + "group": "images", + "family": "ico", + "title": "Icon with seven sizes", + "features": "16 to 256 pixels, PNG-coded images", + "path": "images/icon.ico", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 25530, + "sha256": "436e2c802c34d1c83b4492308ca660d4944eeb44fe32f6de87d3869e1c9e89ea" + }, + { + "file": "icon-legacy.ico", + "group": "images", + "family": "ico", + "title": "Icon in the legacy bitmap coding", + "features": "32-bit and 8-bit DIBs with AND masks, and one PNG image", + "path": "images/icon-legacy.ico", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 14193, + "sha256": "0ded5b411cd2cf1ebd15cdecfa12a1e9f4a53b74da39fa263f935329efe399ec" + }, + { + "file": "scene.ppm", + "group": "images", + "family": "pnm", + "title": "Dusk landscape, PPM (P6)", + "features": "binary pixmap with a comment", + "path": "images/scene.ppm", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 36893, + "sha256": "43949e7d7636ed5a7d552ad6fac28eb72487a70a1f63db7d944fb7e587612b40" + }, + { + "file": "scene-ascii.ppm", + "group": "images", + "family": "pnm", + "title": "Dusk landscape, ASCII PPM (P3)", + "features": "text pixmap, 48 by 36", + "path": "images/scene-ascii.ppm", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 17916, + "sha256": "996f21e5c7c05ed1c586b9dc9882ad6fba9f1153ba23c49844c640eef647bf8a" + }, + { + "file": "scene-grey16.pgm", + "group": "images", + "family": "pnm", + "title": "Landscape in grey, 16-bit PGM (P5)", + "features": "maxval 65535", + "path": "images/scene-grey16.pgm", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 24592, + "sha256": "059c3e7b9e22b0aa4247552139886564f9b36d66051ffc7957efd972f557b52c" + }, + { + "file": "scene.pbm", + "group": "images", + "family": "pnm", + "title": "Landscape as a bitmap, PBM (P4)", + "features": "1 bit a pixel", + "path": "images/scene.pbm", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 6155, + "sha256": "648c9687dc2c226306c64aa945bf81170bbff5d8c9f88b0630b07b294d4e2edb" + }, + { + "file": "scene-alpha.pam", + "group": "images", + "family": "pnm", + "title": "Landscape with alpha, PAM (P7)", + "features": "RGB_ALPHA tuples", + "path": "images/scene-alpha.pam", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 49220, + "sha256": "806cee6e77cbd0226f6ec3ab18de4259612a89c1df5ff5e27e37d4d5c932c515" + }, + { + "file": "scene.qoi", + "group": "images", + "family": "qoi", + "title": "Dusk landscape, QOI", + "features": "three channels", + "path": "images/scene.qoi", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 38960, + "sha256": "d04c2ff91df0173bf20c495eb19555dc4bc9342b9ec7b61f653a4742c00b6188" + }, + { + "file": "scene-alpha.qoi", + "group": "images", + "family": "qoi", + "title": "Landscape with alpha, QOI", + "features": "four channels", + "path": "images/scene-alpha.qoi", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 90838, + "sha256": "16a53b8756ab8dd663400cfa04dde6a598b6c608d345efd131c0d714cde8affb" + }, + { + "file": "scene.ff", + "group": "images", + "family": "farbfeld", + "title": "Landscape with alpha, farbfeld", + "features": "16-bit RGBA, big-endian", + "path": "images/scene.ff", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 55312, + "sha256": "b6d250a92f39b7183b42191933d84da7e6ef1a33a971c8e7c2f8ccc85ad060d0" + }, + { + "file": "sunset.hdr", + "group": "images", + "family": "hdr", + "title": "Sunset with a sun far brighter than the sky, Radiance HDR", + "features": "RGBE with run-length scanlines; the sun is 60 times white", + "path": "images/sunset.hdr", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 55026, + "sha256": "f65ffee0666792f829c6692ca1ef4a0a553304d06e28682f18a47de5efed7e9b" + }, + { + "file": "sunset-flat.hdr", + "group": "images", + "family": "hdr", + "title": "Sunset, uncompressed Radiance HDR", + "features": "flat RGBE scanlines", + "path": "images/sunset-flat.hdr", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 49248, + "sha256": "a401df61e76cfdf88731294a4ba5ce1ba607ee294b5010a1312925240f5d2131" + }, + { + "file": "scene-bc1.dds", + "group": "images", + "family": "dds", + "title": "Dusk landscape, DDS with BC1 (DXT1)", + "features": "full mipmap chain", + "path": "images/scene-bc1.dds", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 32904, + "sha256": "77d26e97cc0f532481e3e585321305aca44efd7694307f312d75b1c4bfecf8f8" + }, + { + "file": "scene-bc3.dds", + "group": "images", + "family": "dds", + "title": "Landscape with alpha, DDS with BC3 (DXT5)", + "features": "full mipmap chain, alpha blocks", + "path": "images/scene-bc3.dds", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 65680, + "sha256": "9028c95d163d7283b357e3beb6a531a192087fcb76e7aeb1aead81195e4f4a92" + }, + { + "file": "scene.jp2", + "group": "images", + "family": "jpeg2000", + "title": "Dusk landscape, JPEG 2000 (JP2)", + "features": "lossy, 3 quality layers, 5 resolutions, 256-pixel tiles, RPCL progression", + "path": "images/scene.jp2", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 81825, + "sha256": "f90f58bb1e3ef0bfd48b9b55bffc293ee4f1fe506a6717f0052319a34621bf9a" + }, + { + "file": "scene.j2k", + "group": "images", + "family": "jpeg2000", + "title": "Dusk landscape, bare JPEG 2000 codestream", + "features": "no JP2 boxes", + "path": "images/scene.j2k", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 49050, + "sha256": "949564fa2b811a1987a12989095289d673c2a4937f5014b0b9c1d7bfbe660b9c" + }, + { + "file": "scene-grey.jp2", + "group": "images", + "family": "jpeg2000", + "title": "Landscape in grey, JPEG 2000", + "features": "one component", + "path": "images/scene-grey.jp2", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 19629, + "sha256": "eb50ae4528e774c7ab9c427509faee07473249a89d35403c33afb2e5e9abf983" + }, + { + "file": "scene-alpha.jp2", + "group": "images", + "family": "jpeg2000", + "title": "Landscape with alpha, JPEG 2000", + "features": "four components with an alpha channel", + "path": "images/scene-alpha.jp2", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 78619, + "sha256": "bc1adbddf1bd608b71b1cac9dd1a47f47cde2c046a4202f1642cd15efa647825" + }, + { + "file": "page-generic.jb2", + "group": "images", + "family": "jbig2", + "title": "Fax page as a JBIG2 file, generic region", + "features": "1-bit page of 1240 by 1579, arithmetic coding", + "path": "images/page-generic.jb2", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 8595, + "sha256": "1447cbbc1ebab320062a78fa40f81ccf768aed3d9aea2cfa0583ebce2fb3e088" + }, + { + "file": "page-symbol.jb2", + "group": "images", + "family": "jbig2", + "title": "Fax page as a JBIG2 file, symbol coding", + "features": "text region over a symbol dictionary", + "path": "images/page-symbol.jb2", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 5577, + "sha256": "8fba2e640c71101dd5ab376008ea177ee0e8f2070c49250d31186c198991da9e" + }, + { + "file": "scene-lossy.jxl", + "group": "images", + "family": "jpeg-xl", + "title": "Dusk landscape, JPEG XL, lossy", + "features": "VarDCT at distance 1", + "path": "images/scene-lossy.jxl", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 23664, + "sha256": "dc1e17bd77c26a49a3c61fcd8b299c79b71395550a4e1d42bc69af6aa33a200f" + }, + { + "file": "scene-lossless.jxl", + "group": "images", + "family": "jpeg-xl", + "title": "Dusk landscape, JPEG XL, lossless", + "features": "modular mode", + "path": "images/scene-lossless.jxl", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 26223, + "sha256": "5f2ce3702bb3fe54bb31feb6f1a9e92347a9acd290a6a7730a3074fe5fb088fe" + }, + { + "file": "scene-alpha.jxl", + "group": "images", + "family": "jpeg-xl", + "title": "Landscape with alpha, JPEG XL", + "features": "lossless with an extra alpha channel", + "path": "images/scene-alpha.jxl", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 35576, + "sha256": "d18698258dcc59292483265284a96099bc2c1f23f53f4893d778062070588166" + }, + { + "file": "bounce.jxl", + "group": "images", + "family": "jpeg-xl", + "title": "Bouncing ball, animated JPEG XL", + "features": "24 frames", + "path": "images/bounce.jxl", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-images.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-images.py", + "args": [] + }, + "bytes": 61211, + "sha256": "679c04f1bec63d699c6baeb1d42dfa4ff205a40327f208f650115f7292e27f89" + }, + { + "file": "house-plan.dxf", + "group": "cad", + "family": "dxf", + "title": "House, ground floor plan (DXF 2018)", + "features": "walls cut at openings with pattern hatches, door blocks with attributes, window and furniture blocks, axes with bubbles, room labels, linear, aligned, radius and angular dimensions, stair, north arrow, 17 layers, paper-space sheet with a viewport and a title block", + "path": "cad/house-plan.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 86949, + "sha256": "ad78cc54ad3c52483040b71dcb5cafda6f3660bdc9f229e21b22ece2cc773815" + }, + { + "file": "house-plan-r2000.dxf", + "group": "cad", + "family": "dxf", + "title": "House, ground floor plan (DXF 2000)", + "features": "the same plan written as an AutoCAD 2000 file", + "path": "cad/house-plan-r2000.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 84233, + "sha256": "eabe480065538e478a22424b5d3b0427efd321e098f7e9cabe4ad332984ab033" + }, + { + "file": "house-elevations.dxf", + "group": "cad", + "family": "dxf", + "title": "House, four elevations and a section in 2D", + "features": "gabled roofs with roof-tile hatches, plaster hatches, ground hatching, windows with transparent glass, steps, level marks, height dimensions, a hatched section with a stair", + "path": "cad/house-elevations.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 97170, + "sha256": "4ddb32c976e6709e39a4184a09c772ecff99324dcefd03e40a2cf7d89f62661f" + }, + { + "file": "hatch-gallery.dxf", + "group": "cad", + "family": "dxf", + "title": "Hatch gallery", + "features": "about a hundred predefined patterns, nine gradient types, islands in the three hatch styles, boundaries of lines, arcs, ellipses and splines, pattern angle and scale", + "path": "cad/hatch-gallery.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 145226, + "sha256": "0d9978ab422cfea65f91149502727f7132a3192450eedf52f90c9075f36b8cf4" + }, + { + "file": "dimensions.dxf", + "group": "cad", + "family": "dxf", + "title": "Dimension types", + "features": "linear, aligned, diameter, radius, arc length, angular (two lines and three points), ordinate, leaders, a multileader, tolerances, text override, three dimension styles", + "path": "cad/dimensions.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 56099, + "sha256": "515ccb26e3d707c2dc12192f147a4003dc44118d17da04d6f162250aa5935d87" + }, + { + "file": "linetypes.dxf", + "group": "cad", + "family": "dxf", + "title": "Linetypes, line weights and polylines", + "features": "the standard linetypes, custom ones with text inside, 12 line weights, per-entity scale, linetype generation, polylines with widths, circles, arcs, ellipses and splines in dashed styles", + "path": "cad/linetypes.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 36080, + "sha256": "0c8f9b53c105c230bad51a7fbad1af8972725fea449d638015c94467229c7cc0" + }, + { + "file": "text-styles.dxf", + "group": "cad", + "family": "dxf", + "title": "Text styles and MTEXT", + "features": "ten text styles (width and oblique factors, mirrored, upside down), justifications, rotation, special codes, Unicode, MTEXT colours, fonts, stacked fractions, underline, columns, background, nine attachment points", + "path": "cad/text-styles.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 30737, + "sha256": "e2c312052780d94fd25ad4205370645b143ceecde87d57dac2d471582cddeb3e" + }, + { + "file": "blocks.dxf", + "group": "cad", + "family": "dxf", + "title": "Blocks, nested inserts and arrays", + "features": "a block inside a block inside a block with attributes, mirrored, scaled and rotated inserts, a 3 by 8 array, BYBLOCK colours", + "path": "cad/blocks.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 30901, + "sha256": "419ffd35c1dd1d4ded3d70039dfa49852fa85cce935911b10a7a00557b967844" + }, + { + "file": "bracket.dxf", + "group": "cad", + "family": "dxf", + "title": "Mechanical part: an L bracket in three views", + "features": "centre and hidden lines, a section with hatching, dimensions with tolerances, diameter and radius dimensions, a leader, an A4 sheet with a title block", + "path": "cad/bracket.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 42633, + "sha256": "cd5d9676a8bb77c0702757674e17723202e3a614c38ca96ae8bd8f9c2ad1cfa9" + }, + { + "file": "site-plan.dxf", + "group": "cad", + "family": "dxf", + "title": "Site plan with contour lines", + "features": "closed spline contours, wide polyline roads, parcels with areas, hatched buildings, a gradient pond, spot heights, trees, scale bar, legend and north arrow", + "path": "cad/site-plan.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 85531, + "sha256": "c5bb9b26f9dbd785754d18854c1faef18fd360a8411f2244c3553d4c75a8baeb" + }, + { + "file": "legacy-r12.dxf", + "group": "cad", + "family": "dxf", + "title": "Gear in the R12 format", + "features": "POLYLINE, CIRCLE, ARC, TEXT, SOLID, 3DFACE and INSERT entities of the 1992 format", + "path": "cad/legacy-r12.dxf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-cad.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-cad.py", + "args": [] + }, + "bytes": 13965, + "sha256": "926e991c1389fcdf73d8aff413eb8b0c122943bf95f1667b31ba78b7db2a0723" + }, + { + "file": "gear.stl", + "group": "3d", + "family": "stl", + "title": "Spur gear, binary STL with facet colours", + "features": "864 facets, orange teeth and blue hub through the VisCAM colour attribute", + "path": "models/gear.stl", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-models.py", + "args": [] + }, + "bytes": 43284, + "sha256": "aac8dd0d87fed7b77061d32a5bbe61ac90cbfef16130b7c35730cdc1e77e93c9" + }, + { + "file": "bracket-ascii.stl", + "group": "3d", + "family": "stl", + "title": "L bracket, ASCII STL", + "features": "text STL, three overlapping boxes", + "path": "models/bracket-ascii.stl", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-models.py", + "args": [] + }, + "bytes": 8980, + "sha256": "dd4b8c55140214fe71a8200d4fe2ae7b51f53e6ab6184e82e79b3d2d12c708f9" + }, + { + "file": "knot.obj", + "group": "3d", + "family": "obj", + "title": "Trefoil knot and base, OBJ with vertex colours", + "features": "two objects (knot, base), a colour and a normal per vertex, quads, a rainbow along the knot", + "path": "models/knot.obj", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-models.py", + "args": [] + }, + "bytes": 100272, + "sha256": "a2eaba271264d700edc372eaaa6944d342fe9b823c98e698b1445846bded3bf1" + }, + { + "file": "terrain.ply", + "group": "3d", + "family": "ply", + "title": "Height field, binary PLY with colours", + "features": "2304 vertices with normals and a hypsometric colour each, 4418 triangles", + "path": "models/terrain.ply", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-models.py", + "args": [] + }, + "bytes": 119979, + "sha256": "22c2f25d47c43841c1ed1327c6fb9e75725925ca1e3c61dc413c23319dcfde52" + }, + { + "file": "icosphere-ascii.ply", + "group": "3d", + "family": "ply", + "title": "Icosphere, ASCII PLY with colours", + "features": "text PLY with normals and RGBA per vertex", + "path": "models/icosphere-ascii.ply", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-models.py", + "args": [] + }, + "bytes": 14774, + "sha256": "dbe5ae97077d8177503d5bab7d83fbc3f0a943181384fbffc68db7a4930f19be" + }, + { + "file": "arm.glb", + "group": "3d", + "family": "glb", + "title": "Articulated arm in a glass dome, GLB", + "features": "node tree with rotations and scales, five materials, a metallic and a transparent one, vertex colours on the floor", + "path": "models/arm.glb", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-models.py", + "args": [] + }, + "bytes": 29036, + "sha256": "146ba6e29d9b8bf4ec57c72bde686a5119b80cfe4365f05f0f0b7004b066a7b7" + }, + { + "file": "table.gltf", + "group": "3d", + "family": "gltf", + "title": "Table and two chairs, glTF with an embedded buffer", + "features": "17 nodes, a data URI buffer, shared meshes instanced under several nodes", + "path": "models/table.gltf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-models.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-models.py", + "args": [] + }, + "bytes": 7768, + "sha256": "ae656d2f656968fa44870ffc539d2383b85725289feb90e658881c5825bb7ef4" + }, + { + "file": "field-report.pdf", + "group": "pdf", + "family": "pdf", + "title": "Field report, six pages", + "features": "a table of contents whose lines are links, a two-level outline, roman and arabic page labels, internal and external links, a table, bar, line and pie charts as vectors, a JPEG, standard and embedded fonts (Latin, Greek, Cyrillic), document information", + "path": "pdf/field-report.pdf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-pdf.py", + "args": [] + }, + "bytes": 107001, + "sha256": "75b7649d62fb12034d56478c98baa92beba3c0a2117ce0cfcfffa9233d060a5b" + }, + { + "file": "application-form.pdf", + "group": "pdf", + "family": "pdf-form", + "title": "Berth application form (AcroForm)", + "features": "text fields (multiline, maximum length, password, read-only), check boxes, radio buttons, a drop-down list, a list box, some filled in", + "path": "pdf/application-form.pdf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-pdf.py", + "args": [] + }, + "bytes": 19127, + "sha256": "883dff8626a2a95d66d74bd8876ceee621197902de671b7c036d39e21774c50b" + }, + { + "file": "annotations.pdf", + "group": "pdf", + "family": "pdf-annotations", + "title": "Annotations without appearance streams", + "features": "links to a URL, a page and a named destination, highlight, underline, strike-out, squiggly, note, free text, square, circle, line with arrows, polygon, polyline, ink, stamp, file attachment, an outline and a named destination", + "path": "pdf/annotations.pdf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-pdf.py", + "args": [] + }, + "bytes": 6245, + "sha256": "212ea14f43e151d2d477b73659ef4fdb50735ee337eb76283d065ba9ec1701e9" + }, + { + "file": "scan-mixed.pdf", + "group": "pdf", + "family": "pdf-scan", + "title": "Scanned letters, one page for each scanner image filter", + "features": "four pages: DCTDecode (JPEG), CCITTFaxDecode (Group 4), JBIG2Decode and JPXDecode (JPEG 2000), each with an invisible OCR text layer; the viewer decodes the last three with its own decoders", + "path": "pdf/scan-mixed.pdf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-pdf.py", + "args": [], + "tools": [ + "opj_compress", + "jbig2", + "tiffcp" + ] + }, + "bytes": 206092, + "sha256": "daeb8cab36d82d4e5391041effc7d37dddf2421a87c3853c4b763e4a4d534776" + }, + { + "file": "vector-art.pdf", + "group": "pdf", + "family": "pdf", + "title": "Vector art", + "features": "axial and radial shadings, transparency, a clipping path, dashes, caps and joins, Bezier curves, an even-odd fill, text with render modes, rotated and scaled text", + "path": "pdf/vector-art.pdf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-pdf.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-pdf.py", + "args": [] + }, + "bytes": 4368, + "sha256": "fad466f385e89bc008128072d0b16544144c8a42983afd5d444079e59519e08e" + }, + { + "file": "handbook.docx", + "group": "office", + "family": "docx", + "title": "Harbour handbook (Word)", + "features": "title page, table of contents with links, outline numbering, character and paragraph styles, bulleted and numbered lists of three levels, tables with merged cells and a repeated header, two pictures (one floating with wrapped text), a shape, footnotes, hyperlinks, bookmarks, fields, a comment, tracked changes, a two-column section, a landscape section with a wide table, headers and footers with page numbers", + "path": "office/handbook.docx", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 25180, + "sha256": "46c4fb53949dbd25cfc8b1fecbbd282d470b767d16c1fea426394872fcffc95d" + }, + { + "file": "workbook.xlsx", + "group": "office", + "family": "xlsx", + "title": "Harbour workbook (Excel)", + "features": "three sheets: a budget with formulas (SUM, AVERAGE, INDEX, MATCH, COUNTIF, IF, VLOOKUP, ROUND), currency, percent and date formats, merged title, frozen header rows, a validation list, a comment, a hyperlink and named ranges; 120 rows of sales with an autofilter; a bar, a line and a pie chart", + "path": "office/workbook.xlsx", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 23363, + "sha256": "3e3e0247bde87b6fa1c6f9b06f0561f2eae8bc326925b4b28ba2d7d3b8c12792" + }, + { + "file": "deck.pptx", + "group": "office", + "family": "pptx", + "title": "Harbour review (PowerPoint)", + "features": "five slides: a gradient title slide, nested bullets, a native bar chart with a callout, a table, a picture with shapes, gradients and arrows; a master with footer and slide number; speaker notes (not shown) and transitions", + "path": "office/deck.pptx", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 30490, + "sha256": "160962428c7f1d15c8865d51fef7e74ebc8ccc6782f60ea4bf08ed0a805a7edb" + }, + { + "file": "handbook-tagged.pdf", + "group": "pdf", + "family": "pdf", + "title": "Harbour handbook as a tagged PDF/A-2", + "features": "bookmarks from the headings, hyperlinks, tags, footnotes, a landscape section, embedded Liberation fonts", + "path": "pdf/handbook-tagged.pdf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 192239, + "sha256": "c88a1434c1cbf8a8af467325bb413463276bbe70c84b5f88760638ff5975773d" + }, + { + "file": "handbook.doc", + "group": "converted", + "family": "doc", + "title": "Harbour handbook (Word 97 to 2003)", + "features": "the handbook as a binary .doc", + "path": "converted/handbook.doc", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 67584, + "sha256": "807c9b812188d73b3558ea8b91360d7a39eb637a18172295f206612da92cce39" + }, + { + "file": "handbook.odt", + "group": "converted", + "family": "odt", + "title": "Harbour handbook (OpenDocument text)", + "features": "the handbook as .odt", + "path": "converted/handbook.odt", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 42008, + "sha256": "d28f2667008ebd0e9456f7900eb4af8dbbd10b5af7490a059d6868d468c475ce" + }, + { + "file": "handbook.fodt", + "group": "converted", + "family": "fodt", + "title": "Harbour handbook (flat OpenDocument text)", + "features": "one XML file, pictures in base64; written by hand, not converted", + "path": "converted/handbook.fodt", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [] + }, + "bytes": 78095, + "sha256": "7c9f74181b5c9ad2b4766d5e3c82d9edf2c1cdd370417d0f8592d00ec71beec9" + }, + { + "file": "handbook.rtf", + "group": "converted", + "family": "rtf", + "title": "Harbour handbook (Rich Text Format)", + "features": "the handbook as .rtf", + "path": "converted/handbook.rtf", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 95772, + "sha256": "04eea9909d09ed87fb621459f1ac6e024b094eb767c1d0f7d536dd76f0b82e43" + }, + { + "file": "workbook.xls", + "group": "converted", + "family": "xls", + "title": "Harbour workbook (Excel 97 to 2003)", + "features": "the workbook as a binary .xls", + "path": "converted/workbook.xls", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 29696, + "sha256": "8552c433665e927723ecb747c5a0e1696076964f1bfd20ddd30bc3b95cc97e19" + }, + { + "file": "workbook.ods", + "group": "converted", + "family": "ods", + "title": "Harbour workbook (OpenDocument spreadsheet)", + "features": "the workbook as .ods", + "path": "converted/workbook.ods", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 45583, + "sha256": "32b0cf2984ddef9c2209d4cdb874866a65217d0b6bcdd3417264982ef07d660a" + }, + { + "file": "workbook.fods", + "group": "converted", + "family": "fods", + "title": "Harbour workbook (flat OpenDocument spreadsheet)", + "features": "one XML file; written by hand, not converted", + "path": "converted/workbook.fods", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [] + }, + "bytes": 120795, + "sha256": "80f5ad00faf7555d5aab526226204658880127a91f0af62a439d106c7a52e1be" + }, + { + "file": "deck.ppt", + "group": "converted", + "family": "ppt", + "title": "Harbour review (PowerPoint 97 to 2003)", + "features": "the deck as a binary .ppt (its thumbnail makes it large)", + "path": "converted/deck.ppt", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 491520, + "sha256": "8a6285ae02086db5ddb3246c75ef08a3508f672a91ba38b30efae291e119fb1e" + }, + { + "file": "deck.odp", + "group": "converted", + "family": "odp", + "title": "Harbour review (OpenDocument presentation)", + "features": "the deck as .odp", + "path": "converted/deck.odp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 72325, + "sha256": "e194842a5abfb245477c00eee30736ef7f8460606b62b887bf8392283b41dd07" + }, + { + "file": "deck.fodp", + "group": "converted", + "family": "fodp", + "title": "Harbour review (flat OpenDocument presentation)", + "features": "one XML file; written by hand, not converted", + "path": "converted/deck.fodp", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [] + }, + "bytes": 31777, + "sha256": "07da109d15eceb65d7a0601998ed9508ecd140d5aef311301c96e7aad6faa236" + }, + { + "file": "diagram.odg", + "group": "converted", + "family": "odg", + "title": "Flowchart (OpenDocument drawing)", + "features": "two pages, layers (one hidden), connectors glued to shapes, groups, gradients, a curve and a polyline, shadows, dashes", + "path": "converted/diagram.odg", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [ + "soffice" + ] + }, + "bytes": 26758, + "sha256": "8c80d0ef2e536db14a7e5a92fc0092ab4f0976c87a091875899d3a7655f5b56e" + }, + { + "file": "diagram.fodg", + "group": "converted", + "family": "fodg", + "title": "Flowchart (flat OpenDocument drawing)", + "features": "one XML file; written by hand, not converted", + "path": "converted/diagram.fodg", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-office.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-office.py", + "args": [], + "tools": [] + }, + "bytes": 14205, + "sha256": "52ed359ccb0acec09a2d7401129021ff3f0ad69e35c770eab33986f063924203" + }, + { + "file": "field-guide.md", + "group": "text", + "family": "markdown", + "title": "Field guide to Markdown", + "features": "headings of six levels, nested and task lists, quotes, an aligned table, fenced code with languages, footnotes, links and references, inline HTML, Unicode", + "path": "text/field-guide.md", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 2368, + "sha256": "0f65f0bb537b18b6d5b1ec88da73686350e31d6529bb78a682aaa93d755e9f49" + }, + { + "file": "field-notes.txt", + "group": "text", + "family": "text", + "title": "Plain text with tabs, a long line and pangrams in nine scripts", + "features": "Latin, Greek, Cyrillic, Japanese, Arabic and Hebrew text, box drawing, symbols, no final newline", + "path": "text/field-notes.txt", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 1632, + "sha256": "12054040d6aef17fff17a18adf238fee6fd4158eeefd1d1230108fbd0f03df11" + }, + { + "file": "recipes.json", + "group": "text", + "family": "json", + "title": "A small recipe book as JSON", + "features": "nested objects and arrays, numbers of every kind, null, booleans, escapes, empty values", + "path": "text/recipes.json", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 2457, + "sha256": "9662568867ec0629cb9c18897f1bff96f2b837cc7f89f0840f25f1e1f3e8f69c" + }, + { + "file": "config.yaml", + "group": "text", + "family": "yaml", + "title": "Configuration file, YAML", + "features": "mappings, lists, block scalars, anchors and aliases, comments", + "path": "text/config.yaml", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 585, + "sha256": "398192bf091eeedd240dca6116180ef291ea95d7ee1c20e2e9853505d62854c9" + }, + { + "file": "pyproject.toml", + "group": "text", + "family": "toml", + "title": "Project file, TOML", + "features": "tables, arrays of tables, inline values", + "path": "text/pyproject.toml", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 440, + "sha256": "abcdc7dd5ef4d56589629b5c88d8b9a7931a84ddebd60c6b19f9f3b45349fb03" + }, + { + "file": "feed.xml", + "group": "text", + "family": "xml", + "title": "Atom feed", + "features": "namespaces, CDATA, an XHTML content element, entities, comments", + "path": "text/feed.xml", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 1330, + "sha256": "6cddabc37cc985205a2c04b4863fe3514695a5ef437e710463d15a597eb54560" + }, + { + "file": "route.gpx", + "group": "text", + "family": "xml", + "title": "Coastal walk, GPX track", + "features": "60 track points with elevation and time, two waypoints", + "path": "text/route.gpx", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 6491, + "sha256": "fcafdec4e3a6819fa406e98590dd7ede302bf76cbc6a2080f08542438d08524e" + }, + { + "file": "places.kml", + "group": "text", + "family": "xml", + "title": "Places, KML", + "features": "a point, a line and a polygon, a shared style, HTML in CDATA", + "path": "text/places.kml", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 1035, + "sha256": "9bb4a018a5c1f00843e61af1c475c1a735898f73950689b9ce36f9bdeb7879b8" + }, + { + "file": "harbour-log.csv", + "group": "text", + "family": "csv", + "title": "Harbour log, 300 rows of CSV", + "features": "nine columns, quoted fields with commas, quotes and line breaks, accents", + "path": "text/harbour-log.csv", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 19962, + "sha256": "d4e07a9e0b1aec65f189c8897fa27e1daf24189c3c204fb9527f2bf75d22e314" + }, + { + "file": "tides.tsv", + "group": "text", + "family": "csv", + "title": "Tide table, tab-separated values", + "features": "28 rows, six columns", + "path": "text/tides.tsv", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 833, + "sha256": "eff347351015e329e7904aebc8f334de5ab16cfda7a6b4c6b2575d25ac06fd68" + }, + { + "file": "prix.csv", + "group": "text", + "family": "csv", + "title": "Price list in the European style", + "features": "semicolon separator, decimal commas, accented headers", + "path": "text/prix.csv", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 255, + "sha256": "d490f1d9c26d7218e9937591baf5688d9846303fb6942a5e3c60417c1647b91d" + }, + { + "file": "life.py", + "group": "text", + "family": "source", + "title": "Conway's Game of Life, Python", + "features": "docstrings, comprehensions, f-strings, a main guard", + "path": "text/life.py", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 1069, + "sha256": "2cfc1e34f435d1ee59d3b01e5567bf61034df38a7257e9fed4f1fbdf9436e19e" + }, + { + "file": "lru.rs", + "group": "text", + "family": "source", + "title": "A least-recently-used cache, Rust", + "features": "generics, trait bounds, closures, a test module", + "path": "text/lru.rs", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 1592, + "sha256": "1e2181c4c51e88e4255f391fce16a9d657560408b728df079274804674ab1acb" + }, + { + "file": "tokens.ts", + "group": "text", + "family": "source", + "title": "Arithmetic tokenizer and evaluator, TypeScript", + "features": "union types, a sticky regular expression, the shunting-yard algorithm", + "path": "text/tokens.ts", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 2262, + "sha256": "3587b167a990c8ec02b14477721b4259431f39b432556a6cd86afc8cd2f525c2" + }, + { + "file": "bits.c", + "group": "text", + "family": "source", + "title": "Bit tricks on 32-bit words, C", + "features": "static functions, hexadecimal literals, printf formats", + "path": "text/bits.c", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 1463, + "sha256": "dc7eab8de40430eb6ed22610c37fd2b13df11e69cc70f6d9e2718514e589a6d5" + }, + { + "file": "report.sql", + "group": "text", + "family": "source", + "title": "Monthly report with window functions, SQL", + "features": "DDL, a CTE, lag, rank and sum over windows, joins", + "path": "text/report.sql", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 1287, + "sha256": "99688fcab08ea4876cee7a5d3352abbbd23d230c8c17bc4bbebeea276cf2bd9a" + }, + { + "file": "deploy.sh", + "group": "text", + "family": "source", + "title": "Build and deploy script, Bash", + "features": "strict mode, traps, functions, process substitution, a case statement", + "path": "text/deploy.sh", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 989, + "sha256": "a304fad73e5636d1caf340cfeca167e01905df2a31fc7abca661e8b8131375c7" + }, + { + "file": "style.css", + "group": "text", + "family": "source", + "title": "A small stylesheet, CSS", + "features": "custom properties, grid, clamp, color-mix, a dark mode and a media query", + "path": "text/style.css", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 1165, + "sha256": "7918765d12e41edd33e5a0eb2b7dba28c5429c3a517f086fcc8722f0306920c0" + }, + { + "file": "page.html", + "group": "text", + "family": "html", + "title": "A page, shown as its source, HTML", + "features": "semantic elements, a table, a form, a style and a script that do not run", + "path": "text/page.html", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 1116, + "sha256": "82dd9087ee3afdb2c5443f2a9fb8100500a1633f2e0bdffc2630a8be8387c678" + }, + { + "file": "changes.diff", + "group": "text", + "family": "diff", + "title": "A unified diff of two files", + "features": "two files, three hunks, added and removed lines", + "path": "text/changes.diff", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 879, + "sha256": "e0d7a274666b4f411cd8a5b7318736c37ad7993db25b884c4c9100e13a1391d6" + }, + { + "file": "subtitles.srt", + "group": "text", + "family": "text", + "title": "Subtitles, SubRip", + "features": "six cues with a tag", + "path": "text/subtitles.srt", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 338, + "sha256": "b4e25a1b12f662df624ae81c2c8bd89a8951e338688d2ea2cce55134c5de3161" + }, + { + "file": "server.log", + "group": "text", + "family": "text", + "title": "A server log of 120 lines", + "features": "INFO, WARN and ERROR lines", + "path": "text/server.log", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-text.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-text.py", + "args": [] + }, + "bytes": 9084, + "sha256": "d7e982445e5d36a398533f671c0e7d0f758b47ff406798631924b037a64f6d95" + }, + { + "file": "sample.zip", + "group": "archives", + "family": "zip", + "title": "Zip with folders, stored and deflated members, Unicode names and a comment", + "features": "9 files in 5 folders, an empty folder", + "path": "archives/sample.zip", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 15824, + "sha256": "ad98593f9dc85d5dad309140ce703f96b36da749e48ea6648ce10c53a418250d" + }, + { + "file": "sample.tar", + "group": "archives", + "family": "tar", + "title": "Tar (ustar) with folders and a symbolic link", + "features": "16 entries", + "path": "archives/sample.tar", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 51200, + "sha256": "b31804242af3545fd922934e81a2aade8fd09a7e2648fe26b41cd32dc37e1fb8" + }, + { + "file": "sample.tar.gz", + "group": "archives", + "family": "tar.gz", + "title": "Tar compressed with gzip", + "features": "the tar opens inside the gzip", + "path": "archives/sample.tar.gz", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 15648, + "sha256": "5c423a4e7e0d96e025bb871034a88308f1d32861f60826074c29aa121fcdecca" + }, + { + "file": "sample.tar.bz2", + "group": "archives", + "family": "tar.bz2", + "title": "Tar compressed with bzip2", + "features": "the tar opens inside the bzip2 stream", + "path": "archives/sample.tar.bz2", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 14648, + "sha256": "99b6742b5c54a36261f06a24032c22c11ed84b01e40e2267ef338b15ca181fcf" + }, + { + "file": "sample.tar.xz", + "group": "archives", + "family": "tar.xz", + "title": "Tar compressed with xz", + "features": "the tar opens inside the xz stream", + "path": "archives/sample.tar.xz", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 13772, + "sha256": "762ce440ee2dfd2f4d22a6d29f2535e7079ad21d461d7fce3167cd62a9f356bf" + }, + { + "file": "sample.tar.zst", + "group": "archives", + "family": "tar.zst", + "title": "Tar compressed with Zstandard", + "features": "level 19", + "path": "archives/sample.tar.zst", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "zstd" + ] + }, + "bytes": 14331, + "sha256": "d134793d16db00229eb192be488ef248a7c3e1226d9d27b67d8a763cd9b0641d" + }, + { + "file": "server.log.gz", + "group": "archives", + "family": "gz", + "title": "A log compressed with gzip", + "features": "one member, no tar", + "path": "archives/server.log.gz", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 1331, + "sha256": "9f80faaae63eb4ae0a3a68c1346966049cb6ad5eba17e9c8600eb404ced75922" + }, + { + "file": "server.log.bz2", + "group": "archives", + "family": "bz2", + "title": "A log compressed with bzip2", + "features": "one member, no tar", + "path": "archives/server.log.bz2", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 1155, + "sha256": "4b235dc9fc1a2840be3dd7ed792c7e4bc373851130e7122de3e798e7061b39c4" + }, + { + "file": "server.log.xz", + "group": "archives", + "family": "xz", + "title": "A log compressed with xz", + "features": "one member, no tar", + "path": "archives/server.log.xz", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 820, + "sha256": "7dec383660d188a36f28cae361ebb809914849ddf10a508fc47503f367c1e86c" + }, + { + "file": "sample.7z", + "group": "archives", + "family": "7z", + "title": "7z with LZMA2", + "features": "a solid archive of 9 files", + "path": "archives/sample.7z", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "7z" + ] + }, + "bytes": 13642, + "sha256": "7a2df3dd03ae207f3be0175190e3795662753e701d36555cd06e7295a72483c9" + }, + { + "file": "sample.rar", + "group": "archives", + "family": "rar", + "title": "RAR 5 with stored members", + "features": "folders, CRC32 and Unix modes, written by the script from the format's documentation (no RAR program)", + "path": "archives/sample.rar", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 35056, + "sha256": "71aa4939bc7f2e4a98cc484e7353b829ad8a5019b1dee3911e8df3548e317c90" + }, + { + "file": "sample.iso", + "group": "archives", + "family": "iso", + "title": "ISO 9660 image with Joliet and Rock Ridge", + "features": "volume EXAV_EXAMPLE, long and Unicode names", + "path": "archives/sample.iso", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "genisoimage" + ] + }, + "bytes": 133120, + "sha256": "baa0c331dc2f1f8e3f04c171282faf7753a38d380e82e0ca894c70a04f08d4c0" + }, + { + "file": "matryoshka.zip", + "group": "archives", + "family": "zip", + "title": "Nested archives: a zip holding a tar.gz holding a 7z holding a zip", + "features": "four levels, opened in place one after the other", + "path": "archives/matryoshka.zip", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "7z" + ] + }, + "bytes": 7232, + "sha256": "0c4d4171bd6cd565a17ff675763cebf6c8d8c9408ebd6c738d436feab6cb42e9" + }, + { + "file": "encrypted-aes.zip", + "group": "archives", + "family": "zip", + "title": "Zip encrypted with AES-256, password: exav", + "features": "names are listed, each member says it is encrypted", + "path": "archives/encrypted-aes.zip", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "7z" + ] + }, + "bytes": 16604, + "sha256": "e6cd983b8b1694a5df2c2f7d7790a249e4e9c529cb0d796e6d05de7a4bb03bd0" + }, + { + "file": "encrypted-zipcrypto.zip", + "group": "archives", + "family": "zip", + "title": "Zip encrypted with the traditional ZipCrypto, password: exav", + "features": "names are listed, each member says it is encrypted", + "path": "archives/encrypted-zipcrypto.zip", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "7z" + ] + }, + "bytes": 16262, + "sha256": "21053827c2871cda2d2f6259dc1d3848e318b40c0e71858220d601fc3c90767c" + }, + { + "file": "encrypted.7z", + "group": "archives", + "family": "7z", + "title": "7z encrypted with AES-256, password: exav", + "features": "file names visible, contents encrypted", + "path": "archives/encrypted.7z", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "7z" + ] + }, + "bytes": 13726, + "sha256": "c2606ee4ee72de0d9ba1461ba976c5ac20de157b6b17771372bd6acf221cc6ce" + }, + { + "file": "example.jar", + "group": "archives", + "family": "jar", + "title": "A jar (a zip with a manifest)", + "features": "META-INF/MANIFEST.MF, a source file, a properties file", + "path": "archives/example.jar", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 793, + "sha256": "44aeabd990d5a5c75f88846f5310ad44a9b56deadde592ec3d932cf13476ff45" + }, + { + "file": "pages.cbz", + "group": "archives", + "family": "cbz", + "title": "Comic book archive (zip of pages)", + "features": "two page images and ComicInfo.xml", + "path": "archives/pages.cbz", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [] + }, + "bytes": 10720, + "sha256": "82dc884486eec4ca4fbeb3c016d6431848d0a2ccd843ab6e65972503524b0723" + }, + { + "file": "sample.cab", + "group": "archives", + "family": "cab", + "title": "Microsoft cabinet", + "features": "four files, MSZIP", + "path": "archives/sample.cab", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "gcab" + ] + }, + "bytes": 6284, + "sha256": "5f12d6c186b5cc81f70dc4b3d4a4d7b4b7bdebb74f002ada9067b4738589de00" + }, + { + "file": "exav-example.deb", + "group": "archives", + "family": "deb", + "title": "Debian package", + "features": "an ar archive of debian-binary, control.tar.xz and data.tar.xz", + "path": "archives/exav-example.deb", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "dpkg-deb" + ] + }, + "bytes": 2256, + "sha256": "d73cac532f34c9948bc596e1241189b66d4d4497af030d766bbb451b4ab1adea" + }, + { + "file": "libexample.a", + "group": "archives", + "family": "ar", + "title": "Unix ar archive", + "features": "two members", + "path": "archives/libexample.a", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "ar" + ] + }, + "bytes": 1988, + "sha256": "59396e78830b3119a01419e202810446dc15c312cf4b91727170b181d0afaf3e" + }, + { + "file": "sample.cpio", + "group": "archives", + "family": "cpio", + "title": "cpio archive (newc)", + "features": "four files", + "path": "archives/sample.cpio", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-archives.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-archives.py", + "args": [], + "tools": [ + "cpio" + ] + }, + "bytes": 7168, + "sha256": "aab3c3fdd392a978d0a516e5a420bbed6b97c203966c1ed275d7246a93ab9827" + }, + { + "file": "canon.wav", + "group": "media", + "family": "wav", + "title": "A four-voice canon, WAV", + "features": "8 kHz mono 16-bit, 8.4 seconds, synthesised", + "path": "media/canon.wav", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-media.py", + "args": [], + "tools": [ + "ffmpeg" + ] + }, + "bytes": 134444, + "sha256": "c26b3aa385a70bb6785c7eb36b0bf3fe04a379fe46294d77d29ad153d0cc1028" + }, + { + "file": "canon.mp3", + "group": "media", + "family": "mp3", + "title": "The canon as MP3", + "features": "24 kbit/s", + "path": "media/canon.mp3", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-media.py", + "args": [], + "tools": [ + "ffmpeg" + ] + }, + "bytes": 25920, + "sha256": "c9f569a2833d11af90232c2f950c865f02bfbcc5822fc35cccb7466dd9c46c49" + }, + { + "file": "canon.flac", + "group": "media", + "family": "flac", + "title": "The canon as FLAC", + "features": "lossless, level 8", + "path": "media/canon.flac", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-media.py", + "args": [], + "tools": [ + "ffmpeg" + ] + }, + "bytes": 74904, + "sha256": "c3dd10ace8ccaacb09f74ca73619e0d991ff94ce3d25fdc20ee9fb4597bc8a63" + }, + { + "file": "canon.ogg", + "group": "media", + "family": "ogg", + "title": "The canon as Ogg Vorbis", + "features": "quality 2", + "path": "media/canon.ogg", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-media.py", + "args": [], + "tools": [ + "ffmpeg" + ] + }, + "bytes": 14618, + "sha256": "39c1dabbcadc817bfa1fc76839b1ee6940bab7187dffd4c495a5f61362cb91ad" + }, + { + "file": "canon.opus", + "group": "media", + "family": "opus", + "title": "The canon as Opus", + "features": "16 kbit/s", + "path": "media/canon.opus", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-media.py", + "args": [], + "tools": [ + "ffmpeg" + ] + }, + "bytes": 16274, + "sha256": "e18075a4586929741c6bf264c92e8f7f35086f5e4ab2f3be58845835556bc9ae" + }, + { + "file": "canon.m4a", + "group": "media", + "family": "m4a", + "title": "The canon as AAC in MP4", + "features": "24 kbit/s", + "path": "media/canon.m4a", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-media.py", + "args": [], + "tools": [ + "ffmpeg" + ] + }, + "bytes": 25066, + "sha256": "edd56750fd23edd327a46ea907c9d34d53fcff6e04f2d0516a1083ea2c7306e5" + }, + { + "file": "testcard.mp4", + "group": "media", + "family": "mp4", + "title": "Test card with a clock, H.264 and AAC in MP4", + "features": "256 by 192, 12 frames a second, 4 seconds, with the canon as sound", + "path": "media/testcard.mp4", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-media.py", + "args": [], + "tools": [ + "ffmpeg" + ] + }, + "bytes": 41946, + "sha256": "ad088934ae59962bafc0b9b56a57556623d5e2b7821869e2dc91047573213a12" + }, + { + "file": "testcard.webm", + "group": "media", + "family": "webm", + "title": "Test card with a clock, VP9 and Opus in WebM", + "features": "256 by 192, 12 frames a second, 4 seconds, with the canon as sound", + "path": "media/testcard.webm", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-media.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-media.py", + "args": [], + "tools": [ + "ffmpeg" + ] + }, + "bytes": 42004, + "sha256": "dca14e93bb8e3448a5d9a61fbbe402a7cf2b0c8f476a13b98f5649d079a1b7b8" + }, + { + "file": "house.ifc", + "group": "3d", + "family": "ifc", + "title": "Two-storey house (IFC4)", + "features": "walls with openings filled by windows and doors, slabs, a gable roof, porch columns, a stair of eleven steps, a table and chairs sharing a mapped representation, spaces, a property set, a material and colours", + "path": "ifc/house.ifc", + "committed": true, + "author": "exav project", + "source": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-ifc.py", + "evidence": "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/generate/make-ifc.py", + "evidence_quote": "Output: CC0-1.0, a public domain dedication by the exav project.", + "licence": "CC0-1.0", + "attribution": "", + "generated": { + "script": "generate/make-ifc.py", + "args": [] + }, + "bytes": 66945, + "sha256": "d65c93ae607a5b03316662719f5995f9de3bfd94366383ab683838bbccfa4731" + }, + { + "file": "canon-eos-40d-sraw.cr2", + "group": "raw", + "family": "raw-cr2", + "title": "Canon EOS 40D, sRAW (CR2)", + "features": "TIFF-based CR2 with a 1936 by 1288 JPEG preview", + "author": "Uploader to raw.pixls.us (the site gives no name)", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/2102/nice/Canon%20-%20EOS%2040D%20-%20sRAW2%20%28sRAW%29%20%283%3A2%29.CR2", + "evidence_row": "sha256", + "committed": false, + "bytes": 5805950, + "sha256": "ba644e7dd2abe74eca260e67f0206ff113bf0f62e710f8130611e964d6be5bf1" + }, + { + "file": "canon-eos-r6.cr3", + "group": "raw", + "family": "raw-cr3", + "title": "Canon EOS R6, C-RAW (CR3)", + "features": "ISO base media CR3 with a 3408 by 2272 JPEG in its second track; its EXIF names Johannes Kimmel as artist and states CC0", + "author": "Uploader to raw.pixls.us; EXIF artist Johannes Kimmel", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/4659/nice/Canon%20-%20EOS%20R6%20-%203%3A2.CR3", + "evidence_row": "sha256", + "committed": false, + "bytes": 5273174, + "sha256": "74abb0a113d075ad9887a058082f40dd2a938c4813a08474d82356f11a027778" + }, + { + "file": "canon-powershot-g1.crw", + "group": "raw", + "family": "raw-crw", + "title": "Canon PowerShot G1 (CRW)", + "features": "the 2000 CIFF/HEAP container with a 640 by 480 JPEG", + "author": "Uploader to raw.pixls.us (the site gives no name)", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/2073/nice/Canon%20-%20PowerShot%20G1%20-%20RAW%20%284%3A3%29.crw", + "evidence_row": "sha256", + "committed": false, + "bytes": 2300702, + "sha256": "f39be5acf14057ce04527fd8d0574e57ec8ed5fed082d7bfaff763075a44ee8a" + }, + { + "file": "nikon-d70s.nef", + "group": "raw", + "family": "raw-nef", + "title": "Nikon D70s (NEF)", + "features": "TIFF-based NEF with a 3008 by 2000 JPEG in a SubIFD", + "author": "Uploader to raw.pixls.us (the site gives no name)", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/1117/nice/Nikon%20-%20D70s%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283%3A2%29.NEF", + "evidence_row": "sha256", + "committed": false, + "bytes": 5193213, + "sha256": "6c46f9854033e7623ebbb5de8c951df6762ca44f05d45035ccc5cb7e9f4e5692" + }, + { + "file": "sony-ilce-7s.arw", + "group": "raw", + "family": "raw-arw", + "title": "Sony A7S (ARW)", + "features": "TIFF-based ARW with a 1616 by 1080 JPEG preview", + "author": "Uploader to raw.pixls.us (the site gives no name)", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/1582/nice/Sony%20-%20ILCE-7S%20-%2014bit%2014bit%20compressed%20%283%3A2%29.ARW", + "evidence_row": "sha256", + "committed": false, + "bytes": 6160384, + "sha256": "a35ebb2fbec929daa5beb20d1ce5c15a8aac7b1a7a231455387f3df8a7442e07" + }, + { + "file": "olympus-c5060wz.orf", + "group": "raw", + "family": "raw-orf", + "title": "Olympus C5060WZ (ORF)", + "features": "the only picture inside is a 160 by 120 thumbnail", + "author": "Uploader to raw.pixls.us (the site gives no name)", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/1799/nice/Olympus%20-%20C5060WZ%20-%2012bit%20%284%3A3%29.ORF", + "evidence_row": "sha256", + "committed": false, + "bytes": 7646208, + "sha256": "3fc12bc3f6320cfab456111d7805b446e329fdd1907680be55e2e163e5e084bd" + }, + { + "file": "panasonic-dmc-lx7.rw2", + "group": "raw", + "family": "raw-rw2", + "title": "Panasonic Lumix DMC-LX7 (RW2)", + "features": "RW2 with a 1920 by 1280 JPEG", + "author": "Uploader to raw.pixls.us (the site gives no name)", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/7010/nice/Panasonic%20-%20DMC-LX7%20-%203%3A2.RW2", + "evidence_row": "sha256", + "committed": false, + "bytes": 3394560, + "sha256": "18dfc3877e944020299635a8c2fad61fca2485b1c9b67645a495ee870a86e599" + }, + { + "file": "fujifilm-finepix-s5000.raf", + "group": "raw", + "family": "raw-raf", + "title": "Fujifilm FinePix S5000 (RAF)", + "features": "RAF container with a 1280 by 960 JPEG", + "author": "Uploader to raw.pixls.us (the site gives no name)", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/2726/nice/Fujifilm%20-%20FinePix%20S5000%20-%204%3A3.RAF", + "evidence_row": "sha256", + "committed": false, + "bytes": 6851240, + "sha256": "dabd5e74521a6980156be9fd4b88d0c37b0fe4d0e0e6f5c12db8cffff1b76297" + }, + { + "file": "minolta-dimage-5.mrw", + "group": "unsupported", + "family": "raw-mrw", + "title": "Minolta DiMAGE 5 (MRW)", + "features": "MRW with a 640 by 480 preview whose first byte is stored as zero: the viewer finds no preview in it (kept to show the message)", + "author": "Uploader to raw.pixls.us (the site gives no name)", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/7795/nice/Minolta%20-%20DiMAGE%205%20-%204%3A3.MRW", + "evidence_row": "sha256", + "committed": false, + "bytes": 6422144, + "sha256": "d60bfd80bcb1f7b9c88b14a5e46ea27885bbf92d990ab30b3939288678fea0d9" + }, + { + "file": "adobe-dng-canon-5d3.dng", + "group": "raw", + "family": "raw-dng", + "title": "Canon EOS 5D Mark III converted to DNG (lossy JPEG)", + "features": "DNG with previews of 256, 1024 and 3960 pixels; converted by Adobe DNG Converter, not straight from a camera", + "author": "Uploader to raw.pixls.us; EXIF artist Simon Harhues", + "source": "https://raw.pixls.us/#repo", + "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", + "evidence_quote": "Creative Commons 0 - Public Domain", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.pixls.us/getfile.php/1033/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossy%20JPEG%20compression%2C%20rgb%20%283%3A2%29.DNG", + "evidence_row": "sha256", + "committed": false, + "bytes": 2484836, + "sha256": "b22f1e36331f679abb8b13e433b9bbde3988723adf10fee7aa0dda6381016a98" + }, + { + "file": "barraca-valenciana-0100-r2010.dwg", + "group": "cad", + "family": "dwg", + "title": "Barraca Valenciana, ground floor plan (DWG 2010, Revit export)", + "features": "walls, doors, glazing, stairs, furniture, hatches, areas, 24 layers, 56 blocks, extra layouts with title block and viewports", + "author": "Ã’scar Selfa", + "source": "https://zenodo.org/records/14293897", + "evidence": "https://zenodo.org/records/14293897", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "Ã’scar Selfa, Barraca Valenciana de Llauradors: Model BIM i Estudi de la Tipologia Arquitectònica, Zenodo, doi:10.5281/zenodo.14293897, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/records/14293897/files/BRC-OSP-XXX-P00-PLA-ARQ-0100-tipologiaGen%C3%A8rica-A1-P2.dwg?download=1", + "committed": false, + "bytes": 284266, + "sha256": "fcd3eef78cbd568e3f8a8262378670495a3ea6b8e091cb162fe8ef9c3c9add13" + }, + { + "file": "barraca-valenciana-0300-r2010.dwg", + "group": "cad", + "family": "dwg", + "title": "Barraca Valenciana, elevations (DWG 2010, Revit export)", + "features": "elevations drawn as 2D linework with hatches, topography and planting, no 3D", + "author": "Ã’scar Selfa", + "source": "https://zenodo.org/records/14293897", + "evidence": "https://zenodo.org/records/14293897", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "Ã’scar Selfa, Barraca Valenciana de Llauradors: Model BIM i Estudi de la Tipologia Arquitectònica, Zenodo, doi:10.5281/zenodo.14293897, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/records/14293897/files/BRC-OSP-XXX-ZZZ-PLA-ARQ-0300-tipologiaGen%C3%A8rica-A1-P2.dwg?download=1", + "committed": false, + "bytes": 1069565, + "sha256": "93a211cfb797525a94b5b41aeae3ecf33e61442eea07cadae056463cc400a3e5" + }, + { + "file": "barraca-valenciana-0400-r2010.dwg", + "group": "cad", + "family": "dwg", + "title": "Barraca Valenciana, sections and details (DWG 2010, Revit export)", + "features": "42 layers, hatches, arcs, splines, ellipses, 61 blocks", + "author": "Ã’scar Selfa", + "source": "https://zenodo.org/records/14293897", + "evidence": "https://zenodo.org/records/14293897", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "Ã’scar Selfa, Barraca Valenciana de Llauradors: Model BIM i Estudi de la Tipologia Arquitectònica, Zenodo, doi:10.5281/zenodo.14293897, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/records/14293897/files/BRC-OSP-XXX-ZZZ-PLA-ARQ-0400-tipologiaGen%C3%A8rica-A1-P2.dwg?download=1", + "committed": false, + "bytes": 511114, + "sha256": "a09c492dc34ead8df619d6d618049298afffb050a8436c470055923a90f4b61c" + }, + { + "file": "building-survey-plans-r2000.dwg", + "group": "cad", + "family": "dwg", + "title": "Survey of a small building: plans, elevations and details (DWG 2000)", + "features": "Italian drawing with room names and areas, over 120 layers, 890 blocks, 567 dimensions, 492 hatches", + "author": "Giovanni De Gasperis, Sante Dino Facchini, Asif Saeed", + "source": "https://zenodo.org/records/15076037", + "evidence": "https://zenodo.org/records/15076037", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "De Gasperis, G., Facchini, S. D., Saeed, A., Building Ledger Dossier: Seismic Prevention and Building Documentation Tracking through a Digital Twin Approach (4_Architectural_Building_Survey.dwg), Zenodo, doi:10.5281/zenodo.15076037, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/records/15076037/files/4_Architectural_Building_Survey.dwg?download=1", + "committed": false, + "bytes": 2097350, + "sha256": "2f096b44cd5b60ded2ad8b12c2f46116db8b1fda1c980f7bb02660a85782ab1d" + }, + { + "file": "golden-tile-palace-r2004.dwg", + "group": "cad", + "family": "dwg", + "title": "Golden Tile Palace: front elevation and section (DWG 2004)", + "features": "elevation and section with level marks and Chinese titles, 4393 polylines, 841 arcs, 328 splines, 26 dimensions, 24 hatches", + "author": "Jing Zhang, Yunying Ren, Xiaofan An", + "source": "https://figshare.com/articles/dataset/Figure_of_surveying_and_mapping_of_Golden_Tile_Palace_/21374221", + "evidence": "https://api.figshare.com/v2/articles/21374221", + "evidence_quote": "\"name\": \"CC BY 4.0\"", + "licence": "CC-BY-4.0", + "attribution": "Jing Zhang, Yunying Ren, Xiaofan An, Figure of surveying and mapping of Golden Tile Palace, supplementary file S1 of PLOS ONE doi:10.1371/journal.pone.0262155, figshare, CC BY 4.0. No changes.", + "fetched": "https://ndownloader.figshare.com/files/37930432", + "committed": false, + "bytes": 763903, + "sha256": "14d128397f89c1af652b2cd3c9d523db98ae539b1bc061d4f06fba6d21501ae9" + }, + { + "file": "residential-site-plan-r2007.dwg", + "group": "cad", + "family": "dwg", + "title": "Site plan of an old residential community in Beijing (DWG 2007)", + "features": "58 layers, 79 blocks, buildings, roads, 2865 polylines, 656 texts, 125 hatches, 39 dimensions", + "author": "Yijun Fan", + "source": "https://figshare.com/articles/dataset/Drainage_System_Layout_of_Old_Urban_Residential_Communities_In_Beijing/28188725", + "evidence": "https://api.figshare.com/v2/articles/28188725", + "evidence_quote": "\"name\": \"CC BY 4.0\"", + "licence": "CC-BY-4.0", + "attribution": "Yijun Fan, Drainage System Layout of Old Urban Residential Communities In Beijing (Site Plan.dwg), figshare, doi:10.6084/m9.figshare.28188725.v1, CC BY 4.0. No changes.", + "fetched": "https://ndownloader.figshare.com/files/51611078", + "committed": false, + "bytes": 948192, + "sha256": "37b03757172e5360a03cc26da473cde84e85dbd61914b67741c94a40f7f0db2f" + }, + { + "file": "hospital-floor-plan-r2013.dwg", + "group": "cad", + "family": "dwg", + "title": "Furnished hospital floor plan (DWG 2013)", + "features": "section and level marks, 109 dimensions, 429 block definitions of furniture and plants, 19 layers", + "author": "Toka Ehab", + "source": "https://zenodo.org/records/10520508", + "evidence": "https://zenodo.org/records/10520508", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "Toka Ehab, Nanotechnology Applications in Interior Design of hospitals to enhance thermal comfort and decrease the infection spread for the occupants (Elite hospital plan.dwg), Zenodo, doi:10.5281/zenodo.10520508, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/records/10520508/files/Elite%20hospital%20plan.dwg?download=1", + "committed": false, + "bytes": 1249695, + "sha256": "b618207b01a1759142e038e1edf762ab8efbe11804bb2e84fc6f472e7e040fb9" + }, + { + "file": "commercial-building-hvac-plans-r2018.dwg", + "group": "cad", + "family": "dwg", + "title": "Air-conditioning plans of a commercial building (DWG 2018)", + "features": "48 layers, 320 blocks, 316 dimensions, 125 hatches, 112 wipeouts", + "author": "Kevin Anthony do Bem", + "source": "https://zenodo.org/records/10407941", + "evidence": "https://zenodo.org/records/10407941", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "Kevin Anthony do Bem, Projeto de Climatização de um Edifício de Comércio e Serviços (F04 Plantas- Rede aerolica.dwg), Zenodo, doi:10.5281/zenodo.10407941, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/records/10407941/files/F04%20Plantas-%20Rede%20aerolica.dwg?download=1", + "committed": false, + "bytes": 506884, + "sha256": "bcf3b92e0afdf29bb75e7632e8193f881918ddabb4de67a1331b5eda34ee9be2" + }, + { + "file": "ezdxf-dim-linear-tutorial-r2013.dwg", + "group": "cad", + "family": "dwg", + "title": "ezdxf documentation: linear dimension tutorial (DWG 2013)", + "features": "16 dimensions, 16 multileaders, 13 dimension styles, 27 text styles", + "author": "Manfred Moitzi (ezdxf)", + "source": "https://github.com/mozman/ezdxf/blob/748e211c9a0b977b9e15845a6bcad8e1b9743e26/docs/graphics/dim_linear_tutorial.dwg", + "evidence": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/LICENSE", + "evidence_quote": "Copyright (c) 2020 Manfred Moitzi", + "licence": "MIT", + "attribution": "Copyright (c) 2020 Manfred Moitzi, ezdxf, MIT License. No changes.", + "fetched": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/docs/graphics/dim_linear_tutorial.dwg", + "committed": false, + "bytes": 36391, + "sha256": "88037b595bd46d81c5c98ce7aa428ce6b8506d1aa4602e976811cfb154005155" + }, + { + "file": "hospital-ward-wing-plan-section.dxf", + "group": "cad", + "family": "dxf", + "title": "Hospital ward wing, plan and section (DXF 2010)", + "features": "542 polylines, 17 layers of the A-* scheme, 3 blocks, a paper-space viewport", + "author": "Innerscene (original model)", + "source": "https://www.innerscene.com/tools/library/drawings/hospital-ward-wing-1200-square-metres-plan-and-section-73a3f4dd", + "evidence": "https://www.innerscene.com/tools/library/drawings/hospital-ward-wing-1200-square-metres-plan-and-section-73a3f4dd", + "evidence_quote": "Original Innerscene model, released CC0.", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://www.innerscene.com/api/library/hospital-ward-wing-1200-square-metres-plan-and-section-73a3f4dd/download", + "committed": false, + "bytes": 307641, + "sha256": "3042880c63b7d093f287528bc723298879ffe7c9cf7b47cd8de0d81045867b69" + }, + { + "file": "grocery-store-plan-section.dxf", + "group": "cad", + "family": "dxf", + "title": "Grocery store, plan and section (DXF 2010)", + "features": "234 lines, 150 polylines, 17 layers, 3 blocks, a viewport", + "author": "Innerscene (original model)", + "source": "https://www.innerscene.com/tools/library/drawings/grocery-store-600-square-metres-plan-and-section-2a67d041", + "evidence": "https://www.innerscene.com/tools/library/drawings/grocery-store-600-square-metres-plan-and-section-2a67d041", + "evidence_quote": "Original Innerscene model, released CC0.", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://www.innerscene.com/api/library/grocery-store-600-square-metres-plan-and-section-2a67d041/download", + "committed": false, + "bytes": 131073, + "sha256": "b3d430fa6b47801e99cda102cd2a56c342fdc55f776d57e1e4b28fc59c2ec1bb" + }, + { + "file": "ezdxf-uncommon-entities.dxf", + "group": "cad", + "family": "dxf", + "title": "ezdxf example: uncommon entities (DXF 2018)", + "features": "13 dimensions, 2 multileaders, large radial dimensions, rays, construction lines, splines, 33 blocks, 2 extra layouts", + "author": "Manfred Moitzi (ezdxf)", + "source": "https://github.com/mozman/ezdxf/blob/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/uncommon.dxf", + "evidence": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/LICENSE", + "evidence_quote": "Copyright (c) 2020 Manfred Moitzi", + "licence": "MIT", + "attribution": "Copyright (c) 2020 Manfred Moitzi, ezdxf, MIT License. No changes.", + "fetched": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/uncommon.dxf", + "committed": false, + "bytes": 343110, + "sha256": "5efdd671e3b6d8c45bd0688b1d6479d523f2290f0b390f1584348bf46e34c965" + }, + { + "file": "ezdxf-hatches-1.dxf", + "group": "cad", + "family": "dxf", + "title": "ezdxf example: solid hatches (DXF 2018)", + "features": "ten solid hatches over polylines and arcs", + "author": "Manfred Moitzi (ezdxf)", + "source": "https://github.com/mozman/ezdxf/blob/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/hatches_1.dxf", + "evidence": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/LICENSE", + "evidence_quote": "Copyright (c) 2020 Manfred Moitzi", + "licence": "MIT", + "attribution": "Copyright (c) 2020 Manfred Moitzi, ezdxf, MIT License. No changes.", + "fetched": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/hatches_1.dxf", + "committed": false, + "bytes": 130334, + "sha256": "2dc2930b4f7d5fb16698437e4f6972986e580a81b2b5d36a780b4311f85fb326" + }, + { + "file": "bsi-building-architecture-ifc2x3.ifc", + "group": "3d", + "family": "ifc", + "title": "buildingSMART Simple-Scene Building-Architecture (IFC2X3)", + "features": "a small SketchUp house: walls, slabs, a roof, spaces, materials with colours, quantity and property sets; faceted breps", + "author": "buildingSMART International Ltd.", + "source": "https://github.com/buildingSMART/Sample-Test-Files", + "evidence": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/LICENSE", + "evidence_quote": "This work is licensed under the Creative Commons Attribution 4.0 International License.", + "licence": "CC-BY-4.0", + "attribution": "buildingSMART International, Sample-Test-Files, https://github.com/buildingSMART/Sample-Test-Files, licensed CC BY 4.0. No changes.", + "fetched": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/IFC%202.3.0.1%20%28IFC%202x3%20TC1%29/Simple-Scene/Building-Architecture.ifc", + "committed": false, + "bytes": 92542, + "sha256": "c4db65ba847f6b369a95d6c54fa11f4750cbe6d59f021934e8923d8d578e5885" + }, + { + "file": "bsi-building-architecture-ifc4x3.ifc", + "group": "3d", + "family": "ifc", + "title": "buildingSMART Simple-Scene Building-Architecture (IFC4X3_ADD2)", + "features": "the same house in IFC 4.3, with triangulated faces", + "author": "buildingSMART International Ltd.", + "source": "https://github.com/buildingSMART/Sample-Test-Files", + "evidence": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/LICENSE", + "evidence_quote": "This work is licensed under the Creative Commons Attribution 4.0 International License.", + "licence": "CC-BY-4.0", + "attribution": "buildingSMART International, Sample-Test-Files, https://github.com/buildingSMART/Sample-Test-Files, licensed CC BY 4.0. No changes.", + "fetched": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/IFC%204.3.2.0%20%28IFC%204.3%20ADD2%29/Simple-Scene/Building-Architecture.ifc", + "committed": false, + "bytes": 141965, + "sha256": "96e7103812b15b65eb1fd7802d36b9962833485dce5ead4f440bbf529530881d" + }, + { + "file": "bsi-wall-with-opening-and-window-ifc4.ifc", + "group": "3d", + "family": "ifc", + "title": "buildingSMART reference view: a wall with an opening and a window (IFC4)", + "features": "a wall with a material layer set, an opening element and a window with its type", + "author": "Peter Bonsma, RDF Ltd. (file header), distributed by buildingSMART International Ltd.", + "source": "https://github.com/buildingSMART/Sample-Test-Files", + "evidence": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/LICENSE", + "evidence_quote": "This work is licensed under the Creative Commons Attribution 4.0 International License.", + "licence": "CC-BY-4.0", + "attribution": "buildingSMART International, Sample-Test-Files, https://github.com/buildingSMART/Sample-Test-Files, licensed CC BY 4.0. No changes.", + "fetched": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/IFC%204.0.2.1%20%28IFC%204%20ADD2%20TC1%29/ISO%20Spec%20-%20ReferenceView_V1.2/wall-with-opening-and-window.ifc", + "committed": false, + "bytes": 12492, + "sha256": "73b0e45d931d5dc13bfee5fdc7bd80f796526445458b2de74c4168d209097832" + }, + { + "file": "duplex-apartment-ifc2x3.ifc", + "group": "3d", + "family": "ifc", + "title": "Duplex Apartment (IFC2X3, Revit export)", + "features": "four storeys: 57 walls, 24 windows, 14 doors, 2 stairs, 21 spaces, furniture, 18 materials", + "author": "Provided to the US Army Construction Engineering Research Laboratory, republished by buildingSMART International", + "source": "https://github.com/buildingsmart-community/Community-Sample-Test-Files/tree/7ddf57a201f88a0c213d5322b02ed15e94a60a40/IFC%202.3.0.1%20(IFC%202x3)/Duplex%20Apartment", + "evidence": "https://raw.githubusercontent.com/buildingsmart-community/Community-Sample-Test-Files/7ddf57a201f88a0c213d5322b02ed15e94a60a40/IFC%202.3.0.1%20%28IFC%202x3%29/Duplex%20Apartment/README.md", + "evidence_quote": "Creative Commons (CC BY 4.0). You are free (1) to copy and resitribute this material any way you like", + "licence": "CC-BY-4.0", + "attribution": "BSI (2020) Duplex Apartment Test Files, buildingSMART International, https://github.com/buildingsmart-community/Community-Sample-Test-Files, CC BY 4.0. No changes.", + "fetched": "https://media.githubusercontent.com/media/buildingsmart-community/Community-Sample-Test-Files/7ddf57a201f88a0c213d5322b02ed15e94a60a40/IFC%202.3.0.1%20%28IFC%202x3%29/Duplex%20Apartment/Duplex_A_20110907.ifc", + "committed": false, + "bytes": 2380763, + "sha256": "b347a2c8aa8fff6db896a4417a9c50c22ac0ccd7c5cfc22b99b8d29336c606ed" + }, + { + "file": "barraca-valenciana-ifc4.ifc", + "group": "3d", + "family": "ifc", + "title": "Barraca Valenciana de Llauradors, BIM model (IFC4, Revit export)", + "features": "a traditional farmhouse: 2 storeys, 16 walls, 5 slabs, 2 roofs, a stair, 27 beams, 28 materials", + "author": "Ã’scar Selfa", + "source": "https://zenodo.org/records/14293897", + "evidence": "https://zenodo.org/records/14293897", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "Ã’scar Selfa, Barraca Valenciana de Llauradors: Model BIM i Estudi de la Tipologia Arquitectònica, Zenodo, doi:10.5281/zenodo.14293897, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/records/14293897/files/BRC-OSP-XXX-ZZZ-M3D-ARQ-0001-tipologiaGen%C3%A8rica-S3-0001.ifc?download=1", + "committed": false, + "bytes": 1584183, + "sha256": "b6f8828e9bd2491dc452b2ab78f9f9e28f39bd81a5f2f4e2cdb2d81668287193" + }, + { + "file": "office-archetype-1970-ifc4x3.ifc", + "group": "3d", + "family": "ifc", + "title": "Office archetype of 1970 (IFC4X3_ADD2)", + "features": "four storeys: 88 walls, 167 windows, 49 doors, 16 curtain walls, 48 spaces", + "author": "Verena Dannapfel, Moritz Müller, Rita Streblow, Dirk Müller et al., RWTH Aachen University", + "source": "https://zenodo.org/records/21820756", + "evidence": "https://zenodo.org/records/21820756", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "Dannapfel, V., Müller, M., Streblow, R., Müller, D. et al., Archetypes for German Office Buildings of Construction Years 1970, 1998 and 2026, Zenodo, doi:10.5281/zenodo.21820756, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/records/21820756/files/2026-07_Klassiqua_Office_Archetype_Construction_Year_1970.ifc?download=1", + "committed": false, + "bytes": 1471699, + "sha256": "a5f12304d33fe5da9174c8ea9827d2f322706646b3da2766dff0ab278c65df3c" + }, + { + "file": "fox.glb", + "group": "3d", + "family": "glb", + "title": "Fox, skinned and animated (GLB)", + "features": "a low-poly fox with a skin, three animations and a texture", + "author": "PixelMannen (model, CC0), tomkranis (rigging and animation, CC BY 4.0), glTF conversion by Asobo Studio and the Khronos Group", + "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/Fox", + "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/Fox/README.md", + "evidence_quote": "© 2014, tomkranis. [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode)", + "licence": "CC-BY-4.0", + "attribution": "Fox: model by PixelMannen (CC0, https://opengameart.org/content/fox-and-shiba), rigging and animation by tomkranis (CC BY 4.0, https://sketchfab.com/3d-models/low-poly-fox-by-pixelmannen-animated-371dea88d7e04a76af5763f2a36866bc), glTF conversion by Asobo Studio and the Khronos Group. Unmodified.", + "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/Fox/glTF-Binary/Fox.glb", + "committed": false, + "bytes": 162852, + "sha256": "d97044e701822bac5a62696459b27d7b375aada5de8574ed4362edbba94771f7" + }, + { + "file": "riggedfigure.gltf", + "group": "3d", + "family": "gltf", + "title": "Rigged Figure (glTF with an embedded buffer)", + "features": "a skinned figure, a data URI buffer", + "author": "Cesium", + "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/RiggedFigure", + "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/RiggedFigure/README.md", + "evidence_quote": "© 2017, Cesium. [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode)", + "licence": "CC-BY-4.0", + "attribution": "Rigged Figure by Cesium, CC BY 4.0, https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/RiggedFigure. Unmodified.", + "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/RiggedFigure/glTF-Embedded/RiggedFigure.gltf", + "committed": false, + "bytes": 99902, + "sha256": "354572cfef17b136b437ac9749da0fa316fd6e42ee7d62fe50a696b25f5047e9" + }, + { + "file": "vertexcolortest.glb", + "group": "3d", + "family": "glb", + "title": "Vertex Color Test (GLB)", + "features": "COLOR_0 vertex colours, tangents, two textures", + "author": "Ed Mackey, Analytical Graphics, Inc. (Cesium)", + "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/VertexColorTest", + "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/VertexColorTest/README.md", + "evidence_quote": "© 2018, Analytical Graphics, Inc.. [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode)", + "licence": "CC-BY-4.0", + "attribution": "Vertex Color Test by Ed Mackey, Copyright 2018 Analytical Graphics, Inc., CC BY 4.0, https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/VertexColorTest. Unmodified.", + "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/VertexColorTest/glTF-Binary/VertexColorTest.glb", + "committed": false, + "bytes": 26220, + "sha256": "58006bdcff8084339da0f6e24400160890638c16dbcb83c362ccaf150e8c6e10" + }, + { + "file": "metalroughspheres.glb", + "group": "3d", + "family": "glb", + "title": "Metal-Rough spheres (GLB)", + "features": "119 nodes and 98 materials of growing metalness and roughness", + "author": "Krill Gavrilov / Draw Harness script (Open CASCADE Technology)", + "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/MetalRoughSpheresNoTextures", + "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/MetalRoughSpheresNoTextures/README.md", + "evidence_quote": "© 2019, Public. [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode)", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/MetalRoughSpheresNoTextures/glTF-Binary/MetalRoughSpheresNoTextures.glb", + "committed": false, + "bytes": 291316, + "sha256": "5e677f260ec0f366967a6e9545de2f3dab2a160e307b9bcb9d050cd2028f63f8" + }, + { + "file": "iridescencesuzanne.glb", + "group": "3d", + "family": "glb", + "title": "Iridescence Suzanne (GLB)", + "features": "Suzanne with the iridescence, transmission and volume extensions", + "author": "Mathias Kanzler (UX3D), Pascal Schoen", + "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/IridescenceSuzanne", + "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/IridescenceSuzanne/README.md", + "evidence_quote": "© 2022, UX3D. [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode)", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/IridescenceSuzanne/glTF-Binary/IridescenceSuzanne.glb", + "committed": false, + "bytes": 507608, + "sha256": "866762602d60a0942b7608bfabf4e8686a29033636224f4b55c5e56abe8abf8f" + }, + { + "file": "ratchet-cam.stl", + "group": "3d", + "family": "stl", + "title": "Apollo 11 hatch, stationary ratchet cam (binary STL)", + "features": "1536 triangles, a mechanical part", + "author": "NASA Johnson Space Center, transferred to the Smithsonian Institution; model published on Wikimedia Commons", + "source": "https://commons.wikimedia.org/wiki/File:Stationary_Ratchet_Cam_v11_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl", + "evidence": "https://commons.wikimedia.org/wiki/File:Stationary_Ratchet_Cam_v11_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl", + "evidence_quote": "CC0 1.0 Universal Public Domain Dedication", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/e/e0/Stationary_Ratchet_Cam_v11_%28Hatch%2C_Crew%2C_Apollo_11_by_NASA_and_Smithsonian_Institution%29.stl", + "committed": false, + "bytes": 76884, + "sha256": "b42d79fb357413b118b973f3fa40b76ae2999451537cd991bebce18b486c581f" + }, + { + "file": "ratchet-spur-gear.stl", + "group": "3d", + "family": "stl", + "title": "Apollo 11 hatch, stationary ratchet spur gear (binary STL)", + "features": "3026 triangles, a gear with 19 teeth", + "author": "NASA Johnson Space Center, transferred to the Smithsonian Institution; model published on Wikimedia Commons", + "source": "https://commons.wikimedia.org/wiki/File:N=19,_P=28_-_Stat._Ratchet_Spur_Gear_v13_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl", + "evidence": "https://commons.wikimedia.org/wiki/File:N=19,_P=28_-_Stat._Ratchet_Spur_Gear_v13_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl", + "evidence_quote": "CC0 1.0 Universal Public Domain Dedication", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/c/cb/N%3D19%2C_P%3D28_-_Stat._Ratchet_Spur_Gear_v13_%28Hatch%2C_Crew%2C_Apollo_11_by_NASA_and_Smithsonian_Institution%29.stl", + "committed": false, + "bytes": 151384, + "sha256": "c99eca56ab40ed6b2e9dfc49b88c23c6fbef982ff18813ba593d3e138ef1f51d" + }, + { + "file": "petri-dish-holder-base.stl", + "group": "3d", + "family": "stl", + "title": "Petri dish holder base of a spore capture device (ASCII STL)", + "features": "566 facets as text", + "author": "Kevin A. Adkins, Kaiyu Li, M. N. Blasko, J. L. Cabrera, B. H. Neal, James et al.", + "source": "https://zenodo.org/records/12193898", + "evidence": "https://zenodo.org/api/records/12193898", + "evidence_quote": "\"license\": {\"id\": \"cc-by-4.0\"}", + "licence": "CC-BY-4.0", + "attribution": "Components of the spore capture device in A simple mechanism for uncrewed aircraft bioaerosol sampling in the lower atmosphere (Inspire1Petri_Dish_Holder_Base.stl), Kevin A. Adkins et al., Zenodo 2024, doi:10.5281/zenodo.12193898, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/api/records/12193898/files/Inspire1Petri_Dish_Holder_Base.stl/content", + "committed": false, + "bytes": 151270, + "sha256": "d654bf60064e79675acd9694726282c70251905d77391ad45c29d3ba3ecaf93e" + }, + { + "file": "chickpea-mesh.ply", + "group": "3d", + "family": "ply", + "title": "Photogrammetric mesh of a chickpea plant (binary PLY with colours)", + "features": "8788 triangles, RGBA per vertex", + "author": "William T. Salter, Arjina Shrestha, Margaret M. Barbour", + "source": "https://zenodo.org/records/4018242", + "evidence": "https://zenodo.org/api/records/4018242", + "evidence_quote": "\"license\": {\"id\": \"cc-by-4.0\"}", + "licence": "CC-BY-4.0", + "attribution": "3D photogrammetric reconstructions of individual chickpea plants (PBASlasher_S3s_Wk1_meshed.ply), William T. Salter, Arjina Shrestha and Margaret M. Barbour, Zenodo 2020, doi:10.5281/zenodo.4018242, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/api/records/4018242/files/PBASlasher_S3s_Wk1_meshed.ply/content", + "committed": false, + "bytes": 202701, + "sha256": "ef9cd2141897781c8e3e6a72ac316056b991d4d0fae99e053c4695fceea8cb75" + }, + { + "file": "ruthwell-lamb-of-god.ply", + "group": "3d", + "family": "ply", + "title": "Lamb of God panel of the Ruthwell Cross (binary PLY with normals and colours)", + "features": "33020 triangles, a scanned stone carving", + "author": "Gurpreet Singh", + "source": "https://zenodo.org/records/3348342", + "evidence": "https://zenodo.org/api/records/3348342", + "evidence_quote": "\"license\": {\"id\": \"cc-by-4.0\"}", + "licence": "CC-BY-4.0", + "attribution": "Small Panel PLY (Lamb of God panel from the Ruthwell Cross), Gurpreet Singh, Zenodo 2019, doi:10.5281/zenodo.3348342, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/api/records/3348342/files/Small_Lamb_Of_God_North_Ruthwell_Cross.ply/content", + "committed": false, + "bytes": 893893, + "sha256": "0d40a4305885932596afce091891a8f4b212b0c012b742db10d4487351c2457a" + }, + { + "file": "chickpea-pointcloud-ascii.ply", + "group": "unsupported", + "family": "ply", + "title": "Point cloud of a chickpea plant (ASCII PLY without faces)", + "features": "53195 coloured points and no faces: kept to show what the viewer says about a point cloud", + "author": "William T. Salter, Arjina Shrestha, Margaret M. Barbour", + "source": "https://zenodo.org/records/4018242", + "evidence": "https://zenodo.org/api/records/4018242", + "evidence_quote": "\"license\": {\"id\": \"cc-by-4.0\"}", + "licence": "CC-BY-4.0", + "attribution": "3D photogrammetric reconstructions of individual chickpea plants (SonSla_3_groundtruth_pointcloud.ply), William T. Salter, Arjina Shrestha and Margaret M. Barbour, Zenodo 2020, doi:10.5281/zenodo.4018242, CC BY 4.0. No changes.", + "fetched": "https://zenodo.org/api/records/4018242/files/SonSla_3_groundtruth_pointcloud.ply/content", + "committed": false, + "bytes": 3455582, + "sha256": "53a08f6e1c831ac58c42342ee257c97730bcf6997e246c88198478f9aeba4fe0" + }, + { + "file": "arxiv-rational-cherednik-torus-knots.pdf", + "group": "pdf", + "family": "pdf", + "title": "Rational Cherednik Algebras and Torus Knot Invariants (arXiv:2402.18770)", + "features": "28 pages of mathematics, 24 outline entries, 256 links, set in LaTeX", + "author": "Xinchun Ma", + "source": "https://arxiv.org/abs/2402.18770v1", + "evidence": "https://arxiv.org/abs/2402.18770v1", + "evidence_quote": "href=\"http://creativecommons.org/licenses/by/4.0/\" title=\"Rights to this article\"", + "licence": "CC-BY-4.0", + "attribution": "Xinchun Ma, Rational Cherednik Algebras and Torus Knot Invariants, arXiv:2402.18770v1, CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Not modified.", + "fetched": "https://arxiv.org/pdf/2402.18770v1", + "committed": false, + "bytes": 430745, + "sha256": "2b22841d8a64b271d7def6fa15aa0b5b98f533ae259e4108eb7104320f07cf10" + }, + { + "file": "nist-sp-800-63-4-digital-identity.pdf", + "group": "pdf", + "family": "pdf", + "title": "NIST SP 800-63-4, Digital Identity Guidelines", + "features": "96 tagged pages, 54 outline entries, 227 links", + "author": "David Temoshok, Diana Proud-Madruga, Yee-Yin Choong et al., NIST", + "source": "https://csrc.nist.gov/pubs/sp/800/63/4/final", + "evidence": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-4.pdf", + "evidence_quote": "is not subject to copyright in the United States.", + "licence": "public-domain (US federal government work, 17 U.S.C. 105; the PDF says so itself)", + "attribution": "", + "fetched": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-4.pdf", + "committed": false, + "bytes": 858054, + "sha256": "2f5b107de218a0fc3fe7f7e91b2ece5babb001eda5745b7c568a3cc876002f2d" + }, + { + "file": "scan-cfa-minutes-1971.pdf", + "group": "pdf", + "family": "pdf-scan", + "title": "Minutes of the U.S. Commission of Fine Arts, 1970 to 1972 (scan)", + "features": "40 scanned pages: JPEG 2000 pictures and JBIG2 masks, an OCR text layer, PDF/A-2B", + "author": "U.S. Commission of Fine Arts; scanned by the Internet Archive", + "source": "https://archive.org/details/cfaminutes21april1971", + "evidence": "https://commons.wikimedia.org/wiki/File:July_8_1970_to_May_19_1972_(IA_cfaminutes21april1971).pdf", + "evidence_quote": "This work is in the public domain in the United States because it is a work prepared by an officer o", + "licence": "public-domain (US federal government work, 17 U.S.C. 105)", + "attribution": "", + "fetched": "https://archive.org/download/cfaminutes21april1971/cfaminutes21april1971.pdf", + "committed": false, + "bytes": 1104628, + "sha256": "2fc5edb8a4f303b89449443dcb5a32249c79b2a8d67253348025d0e7eea5f94b" + }, + { + "file": "scan-blm-land-survey-records.pdf", + "group": "pdf", + "family": "pdf-scan", + "title": "Land and survey records, Bureau of Land Management (scan)", + "features": "40 pages scanned at about 500 ppi: JPEG 2000 and JBIG2 images, an OCR text layer, PDF/A-2B", + "author": "United States Bureau of Land Management; scanned by the Internet Archive", + "source": "https://archive.org/details/landsurveyrecord00unit", + "evidence": "https://commons.wikimedia.org/wiki/File:Land_%26_survey_records_-_record_preservation_%26_service_through_microfilm_(IA_landsurveyrecord00unit).pdf", + "evidence_quote": "This work is in the public domain in the United States because it is a work prepared by an officer o", + "licence": "public-domain (US federal government work, 17 U.S.C. 105)", + "attribution": "", + "fetched": "https://archive.org/download/landsurveyrecord00unit/landsurveyrecord00unit.pdf", + "committed": false, + "bytes": 2192413, + "sha256": "d9a9e6583d0afb1688a043ef59a8d2e236e20c58d4ba76ff625d7c11a181c16c" + }, + { + "file": "form-uscis-i9.pdf", + "group": "pdf", + "family": "pdf-form", + "title": "USCIS Form I-9, Employment Eligibility Verification", + "features": "115 text fields, 5 drop-down lists, 8 check boxes, links; a tagged government form", + "author": "U.S. Citizenship and Immigration Services (DHS)", + "source": "https://www.uscis.gov/i-9", + "evidence": "https://www.uscis.gov/website-policies", + "evidence_quote": "Information presented on this WWW site is considered public information and may be distributed or co", + "licence": "public-domain (US federal government work, 17 U.S.C. 105)", + "attribution": "", + "fetched": "https://www.uscis.gov/sites/default/files/document/forms/i-9.pdf", + "committed": false, + "bytes": 524095, + "sha256": "780f348c34df694bb0b4dbbfaf9f22b99b9757b80d16a37ba89aadf069597281" + }, + { + "file": "map-nps-canyonlands.pdf", + "group": "pdf", + "family": "pdf-map", + "title": "Canyonlands National Park, Island in the Sky: roads and trails", + "features": "two tagged pages of vector map", + "author": "National Park Service", + "source": "https://home.nps.gov/cany/planyourvisit/maps.htm", + "evidence": "https://www.nps.gov/aboutus/disclaimer.htm", + "evidence_quote": "material created by the National Park Service and presented on this website, unless otherwise indica", + "licence": "public-domain (US federal government work, 17 U.S.C. 105)", + "attribution": "", + "fetched": "https://home.nps.gov/cany/planyourvisit/upload/IslandRoadsandTrailsUpdated24-508forweb.pdf", + "committed": false, + "bytes": 349001, + "sha256": "b29a639d9bc0d2e37ca41b68c21bcddba7637b1727dce86980c3373715610d55" + }, + { + "file": "slides-unige-open-access.pdf", + "group": "pdf", + "family": "pdf", + "title": "Open Access publishing at no cost thanks to UNIGE agreements (slides)", + "features": "ten slides with outline, links, tags and pictures", + "author": "Floriane Muller, Anouk Santos (University of Geneva)", + "source": "https://zenodo.org/records/23478694", + "evidence": "https://zenodo.org/records/23478694", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "Floriane Muller and Anouk Santos, Open Access publishing at no cost thanks to UNIGE agreements, in 15 minutes, Zenodo, https://doi.org/10.5281/zenodo.23478694, CC BY 4.0. Not modified.", + "fetched": "https://zenodo.org/api/records/23478694/files/2026-10-20_Read_and_publish_en15min_EN_zenodo.pdf/content", + "committed": false, + "bytes": 800339, + "sha256": "03356ed569a80f7d7dc1d96fcbfd75a2fbc663c63f6d74f4e89eb2390bb5ee64" + }, + { + "file": "paper-ja-labor-law.pdf", + "group": "pdf", + "family": "pdf", + "title": "A paper in Japanese on labour law and the constitution", + "features": "25 pages with embedded MS Mincho fonts", + "author": "Takashi Fujiki (藤木 è²´å�²)", + "source": "https://zenodo.org/records/23469422", + "evidence": "https://zenodo.org/records/23469422", + "evidence_quote": "Creative Commons Attribution 4.0 International", + "licence": "CC-BY-4.0", + "attribution": "藤木 è²´å�², アメリカ労åƒ�法ã�‹ã‚‰ã�¿ã�Ÿæ†²æ³•ã�®å½±éŸ¿, アメリカ憲法ã�®ç�†è«–ã�¨å®Ÿè·µ 創刊å�·, Zenodo, https://doi.org/10.5281/zenodo.23469422, CC BY 4.0. Not modified.", + "fetched": "https://zenodo.org/api/records/23469422/files/%E3%82%A2%E3%83%A1%E3%83%AA%E3%82%AB%E5%8A%B4%E5%83%8D%E6%B3%95%E3%81%8B%E3%82%89%E3%81%BF%E3%81%9F%E6%86%B2%E6%B3%95%E3%81%AE%E5%BD%B1%E9%9F%BF.pdf/content", + "committed": false, + "bytes": 881101, + "sha256": "08b2be3ebc781107f71003344ccf8b230229079e978687f1e0da2329bef1cbe9" + }, + { + "file": "monet-gardeners-house-antibes.jpg", + "group": "images", + "family": "jpeg", + "title": "Claude Monet, The Gardener's House at Antibes (1888)", + "features": "a painting, JPEG from the museum's web size", + "author": "Claude Monet, Cleveland Museum of Art (open access)", + "source": "https://clevelandart.org/art/1916.1044", + "evidence": "https://clevelandart.org/art/1916.1044", + "evidence_quote": "\"share_license_status\":\"CC0\"", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://openaccess-cdn.clevelandart.org/1916.1044/1916.1044_web.jpg", + "committed": false, + "bytes": 404254, + "sha256": "95142eba48d54f01352eb4ef6f04260cb3df3bcb1d9c86000c1284a7817674ca" + }, + { + "file": "queen-mab-cave.jpg", + "group": "images", + "family": "jpeg", + "title": "Queen Mab's Cave, in the manner of Turner", + "features": "a painting", + "author": "Imitator of J. M. W. Turner, Cleveland Museum of Art (open access)", + "source": "https://clevelandart.org/art/1916.1036", + "evidence": "https://clevelandart.org/art/1916.1036", + "evidence_quote": "\"share_license_status\":\"CC0\"", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://openaccess-cdn.clevelandart.org/1916.1036/1916.1036_web.jpg", + "committed": false, + "bytes": 218321, + "sha256": "b84e005332288229d808172a7610b501d9977c0b4668e333faea5ff780157223" + }, + { + "file": "rembrandt-st-francis.jpg", + "group": "images", + "family": "jpeg", + "title": "Rembrandt, Saint Francis Beneath a Tree Praying (etching)", + "features": "an etching, fine line work", + "author": "Rembrandt van Rijn, Cleveland Museum of Art (open access)", + "source": "https://clevelandart.org/art/2024.64", + "evidence": "https://clevelandart.org/art/2024.64", + "evidence_quote": "\"share_license_status\":\"CC0\"", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://openaccess-cdn.clevelandart.org/2024.64/2024.64_web.jpg", + "committed": false, + "bytes": 370411, + "sha256": "74b37aa3738914dd66c07533b3758f7694fefbaa918d2f23c263205cf987db7b" + }, + { + "file": "nolli-rome-plate.jpg", + "group": "images", + "family": "jpeg", + "title": "Nolli's map of Rome (1748), a plate", + "features": "an engraved city plan", + "author": "Giovanni Battista Nolli et al., Cleveland Museum of Art (open access)", + "source": "https://clevelandart.org/art/2020.276.3", + "evidence": "https://clevelandart.org/art/2020.276.3", + "evidence_quote": "\"share_license_status\":\"CC0\"", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://openaccess-cdn.clevelandart.org/2020.276.3/2020.276.3_web.jpg", + "committed": false, + "bytes": 322943, + "sha256": "7643c9e59f7706ebe62518a51daf7d72129acc8092dde7afc65516d3d11b6c77" + }, + { + "file": "pallid-swift.jpg", + "group": "images", + "family": "jpeg", + "title": "Pallid swift in flight (photograph)", + "features": "a camera JPEG with EXIF data (Canon EOS R6 Mark II)", + "author": "Hobbyfotowiki", + "source": "https://commons.wikimedia.org/wiki/File:Pallid_swift_(in_flight).jpg", + "evidence": "https://commons.wikimedia.org/wiki/File:Pallid_swift_(in_flight).jpg", + "evidence_quote": "This file is made available under the Creative Commons CC0 1.0 Universal Public Domain Dedication.", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/d/df/Pallid_swift_%28in_flight%29.jpg", + "committed": false, + "bytes": 356572, + "sha256": "9d0f79f12dcc236f549cdefb66dc468f25335d444ebd26e98805efdd94afecbd" + }, + { + "file": "cyclotron-patent.png", + "group": "images", + "family": "png", + "title": "Drawing from Lawrence's cyclotron patent", + "features": "a black and white technical drawing", + "author": "Ernest O. Lawrence (US Patent Office)", + "source": "https://commons.wikimedia.org/wiki/File:Cyclotron_patent_p1.png", + "evidence": "https://commons.wikimedia.org/wiki/File:Cyclotron_patent_p1.png", + "evidence_quote": "The text and illustrations of US patents published before March 1, 1989 are in the public domain unl", + "licence": "public-domain (US patent text and drawings published before 1989)", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/4/42/Cyclotron_patent_p1.png", + "committed": false, + "bytes": 207304, + "sha256": "0be9b59ae21bbadeb8a13fafde5e0bcfb6d470ba516244eab0ec1c74afd7394f" + }, + { + "file": "mature-flower-diagram.svg", + "group": "images", + "family": "svg", + "title": "Diagram of a mature flower", + "features": "85 texts, about 90 gradient references, many paths", + "author": "Mariana Ruiz Villarreal (LadyofHats)", + "source": "https://commons.wikimedia.org/wiki/File:Mature_flower_diagram.svg", + "evidence": "https://commons.wikimedia.org/wiki/File:Mature_flower_diagram.svg", + "evidence_quote": "This work has been released into the public domain by its author, LadyofHats.", + "licence": "public-domain (released by its author)", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/7/7f/Mature_flower_diagram.svg", + "committed": false, + "bytes": 123527, + "sha256": "b40bd4e188c0a99e51e994b4f39f931d3fca28093ecb6454aa2bd93c575ea999" + }, + { + "file": "entamoeba-life-cycle.svg", + "group": "images", + "family": "svg", + "title": "Life cycle of Entamoeba histolytica", + "features": "547 paths, text and arrows", + "author": "Mariana Ruiz Villarreal (LadyofHats)", + "source": "https://commons.wikimedia.org/wiki/File:Entamoeba_histolytica_life_cycle-en.svg", + "evidence": "https://commons.wikimedia.org/wiki/File:Entamoeba_histolytica_life_cycle-en.svg", + "evidence_quote": "This work has been released into the public domain by its author, LadyofHats.", + "licence": "public-domain (released by its author)", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/a/a1/Entamoeba_histolytica_life_cycle-en.svg", + "committed": false, + "bytes": 273234, + "sha256": "68a4a92289d81f0e7e9361abbba2da9a8c8b4e440e3ad9de5c420646eb133eaa" + }, + { + "file": "sieve-of-eratosthenes.svg", + "group": "images", + "family": "svg", + "title": "Sieve of Eratosthenes, animated", + "features": "SMIL animation with 179 animate elements", + "author": "Ricordisamoa, with later edits by Jc86035, HereToHelp and Glrx", + "source": "https://commons.wikimedia.org/wiki/File:Sieve_of_Eratosthenes_animation.svg", + "evidence": "https://commons.wikimedia.org/wiki/File:Sieve_of_Eratosthenes_animation.svg", + "evidence_quote": "This work is ineligible for copyright and therefore in the public domain because it consists entirel", + "licence": "public-domain (ineligible for copyright, Wikimedia Commons PD-ineligible)", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/0/0b/Sieve_of_Eratosthenes_animation.svg", + "committed": false, + "bytes": 22054, + "sha256": "d8f1d6d0a4b5710ebe1e7766eab30b766b10dc9380c1058ccb39baccd7d51586" + }, + { + "file": "muybridge-horse.gif", + "group": "images", + "family": "gif", + "title": "Muybridge's horse in motion, animated GIF", + "features": "15 frames", + "author": "Eadweard Muybridge (photographs, 1887); animation by Waugsberg", + "source": "https://commons.wikimedia.org/wiki/File:Muybridge_race_horse_animated.gif", + "evidence": "https://commons.wikimedia.org/wiki/File:Muybridge_race_horse_animated.gif", + "evidence_quote": "The author died in 1904, so this work is in the public domain in its country of origin and other cou", + "licence": "public-domain (the author died in 1904)", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/d/dd/Muybridge_race_horse_animated.gif", + "committed": false, + "bytes": 568203, + "sha256": "eb40bebc1f039e7c2a121673c3b55d4aa73582f858053dbbcca9ebce9e2b479d" + }, + { + "file": "geneva-mechanism.gif", + "group": "images", + "family": "gif", + "title": "Geneva mechanism with six spokes, animated GIF", + "features": "101 frames", + "author": "Mike1024", + "source": "https://commons.wikimedia.org/wiki/File:Geneva_mechanism_6spoke_animation.gif", + "evidence": "https://commons.wikimedia.org/wiki/File:Geneva_mechanism_6spoke_animation.gif", + "evidence_quote": "I, the copyright holder of this work, release this work into the public domain.", + "licence": "public-domain (released by its author)", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/9/9b/Geneva_mechanism_6spoke_animation.gif", + "committed": false, + "bytes": 229346, + "sha256": "d7df5c9984109c2ca60f7aab1a51db1f0b578cddcdede5d43be9f67536e2a1ee" + }, + { + "file": "abundance-nemo-g4-6pages.tif", + "group": "images", + "family": "tiff-fax", + "title": "Les grans et merveilleux faictz du seigneur Nemo (scan, six pages, CCITT Group 4)", + "features": "a multi-page bilevel TIFF scanned from a 16th-century book", + "author": "Jean d'Abundance (16th century); scanned by the Bibliothèque nationale de France", + "source": "https://commons.wikimedia.org/wiki/File:Abundance_-_Les_grans_et_merveilleux_faictz_du_seigneur_Nemo%2C_avec_les_privil%C3%A8ges_qu%E2%80%99il_a.tif", + "evidence": "https://commons.wikimedia.org/wiki/File:Abundance_-_Les_grans_et_merveilleux_faictz_du_seigneur_Nemo%2C_avec_les_privil%C3%A8ges_qu%E2%80%99il_a.tif", + "evidence_quote": "This file is in the public domain because it is a mere mechanical scan or photocopy of a public doma", + "licence": "public-domain (a mechanical scan of a public domain book, Wikimedia Commons PD-scan)", + "attribution": "", + "fetched": "https://upload.wikimedia.org/wikipedia/commons/b/b5/Abundance_-_Les_grans_et_merveilleux_faictz_du_seigneur_Nemo%2C_avec_les_privil%C3%A8ges_qu%E2%80%99il_a.tif", + "committed": false, + "bytes": 330420, + "sha256": "38bab27c6d559e11468d6c7e4222f0bb1bee4ba0044540adcebfc012930e00d1" + }, + { + "file": "docx4j-getting-started.docx", + "group": "office", + "family": "docx", + "title": "docx4j Getting Started (Word)", + "features": "a manual with styles, tables, pictures and links", + "author": "Jason Harrop and the docx4j contributors", + "source": "https://github.com/plutext/docx4j/blob/c95b55be6832ff716131ffdbd7006755db92f696/docs/Docx4j_GettingStarted.docx", + "evidence": "https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/legals/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "docx4j, Jason Harrop / Plutext, https://github.com/plutext/docx4j, Apache-2.0. No changes.", + "fetched": "https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/docs/Docx4j_GettingStarted.docx", + "committed": false, + "bytes": 156287, + "sha256": "a518cffcbef0b60cf5c95930acd9af9a56484e47376c5f0c4d6e3a15a0799941" + }, + { + "file": "docx4j-two-charts.docx", + "group": "office", + "family": "docx", + "title": "docx4j chart test: two charts (Word)", + "features": "two native charts in a document", + "author": "Jason Harrop and the docx4j contributors", + "source": "https://github.com/plutext/docx4j/blob/c95b55be6832ff716131ffdbd7006755db92f696/docx4j-chart-render/src/test/resources/charts/render/office/p3-two-charts.docx", + "evidence": "https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/legals/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "docx4j, Jason Harrop / Plutext, https://github.com/plutext/docx4j, Apache-2.0. No changes.", + "fetched": "https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/docx4j-chart-render/src/test/resources/charts/render/office/p3-two-charts.docx", + "committed": false, + "bytes": 41590, + "sha256": "e0bc29916de415c53c03dcee14817bbd1e3eeea8f54a2b76629fe6754e6c641e" + }, + { + "file": "gsa-policy-review-template.xlsx", + "group": "office", + "family": "xlsx", + "title": "Section508.gov Policy Review Template (Excel)", + "features": "a government workbook with formatted tables", + "author": "U.S. General Services Administration and the agencies that wrote the documents (Section508.gov)", + "source": "https://github.com/GSA/Assets.Section508.gov/blob/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Policy%20Review%20Template.xlsx", + "evidence": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/LICENSE.md", + "evidence_quote": "this project is in the public domain within the United States of America. Additionally, we waive copyright and", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Policy%20Review%20Template.xlsx", + "committed": false, + "bytes": 222164, + "sha256": "840661eba4653879dca24f209bca1619f7b168ef1232b595df5713741b2465bd" + }, + { + "file": "closedxml-pivot-tables.xlsx", + "group": "office", + "family": "xlsx", + "title": "ClosedXML example: pivot tables (Excel)", + "features": "pivot tables over a data sheet", + "author": "ClosedXML contributors", + "source": "https://github.com/ClosedXML/ClosedXML/blob/09f2f21a60e821ce7fb18ca3576b272e5dd6d921/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx", + "evidence": "https://raw.githubusercontent.com/ClosedXML/ClosedXML/09f2f21a60e821ce7fb18ca3576b272e5dd6d921/LICENSE", + "evidence_quote": "MIT License Copyright (c) 2016 ClosedXML", + "licence": "MIT", + "attribution": "Copyright (c) 2016 ClosedXML, MIT, https://github.com/ClosedXML/ClosedXML. No changes.", + "fetched": "https://raw.githubusercontent.com/ClosedXML/ClosedXML/09f2f21a60e821ce7fb18ca3576b272e5dd6d921/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx", + "committed": false, + "bytes": 30912, + "sha256": "d13f149091c804cfe9b02c81301ea7ce13d20d1f3311cac799151541d0467d02" + }, + { + "file": "xlsxwriter-bar-chart.xlsx", + "group": "office", + "family": "xlsx", + "title": "XlsxWriter test file: a bar chart (Excel)", + "features": "a native bar chart saved in Excel", + "author": "John McNamara (XlsxWriter)", + "source": "https://github.com/jmcnamara/XlsxWriter/blob/5d4606d89a955226d2d0825a0f44309043ae7251/xlsxwriter/test/comparison/xlsx_files/chart_bar14.xlsx", + "evidence": "https://raw.githubusercontent.com/jmcnamara/XlsxWriter/5d4606d89a955226d2d0825a0f44309043ae7251/LICENSE.txt", + "evidence_quote": "BSD 2-Clause License Copyright (c) 2013-2025, John McNamara", + "licence": "BSD-2-Clause", + "attribution": "Copyright (c) 2013-2025, John McNamara, BSD-2-Clause, https://github.com/jmcnamara/XlsxWriter. No changes.", + "fetched": "https://raw.githubusercontent.com/jmcnamara/XlsxWriter/5d4606d89a955226d2d0825a0f44309043ae7251/xlsxwriter/test/comparison/xlsx_files/chart_bar14.xlsx", + "committed": false, + "bytes": 13879, + "sha256": "c4ed1e8117b4a0e014b95f42361c7e060728b398d4ea650395d902557429cdd2" + }, + { + "file": "python-pptx-chart-types.pptx", + "group": "office", + "family": "pptx", + "title": "python-pptx test file: one slide for each chart type (PowerPoint)", + "features": "many native chart types", + "author": "Steve Canny (python-pptx)", + "source": "https://github.com/scanny/python-pptx/blob/278b47b1dedd5b46ee84c286e77cdfb0bf4594be/features/steps/test_files/cht-chart-type.pptx", + "evidence": "https://raw.githubusercontent.com/scanny/python-pptx/278b47b1dedd5b46ee84c286e77cdfb0bf4594be/LICENSE", + "evidence_quote": "The MIT License (MIT) Copyright (c) 2013 Steve Canny", + "licence": "MIT", + "attribution": "Copyright (c) 2013 Steve Canny, MIT, https://github.com/scanny/python-pptx. No changes.", + "fetched": "https://raw.githubusercontent.com/scanny/python-pptx/278b47b1dedd5b46ee84c286e77cdfb0bf4594be/features/steps/test_files/cht-chart-type.pptx", + "committed": false, + "bytes": 1393564, + "sha256": "d0a7979d05970aa83fd8406f0829cd0019853a7bc4e16f8cda814586fc2eaccc" + }, + { + "file": "cdc-lifecycle-508.pptx", + "group": "office", + "family": "pptx", + "title": "Integrating Section 508 Requirements in the Development Lifecycle (PowerPoint)", + "features": "a government deck with pictures and diagrams", + "author": "Mark D. Urban, CDC Section 508 Coordinator (US federal government)", + "source": "https://github.com/GSA/Assets.Section508.gov/blob/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Integrating%20Section%20508%20Requirements%20in%20the%20Development%20Lifecycle.pptx", + "evidence": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/LICENSE.md", + "evidence_quote": "this project is in the public domain within the United States of America. Additionally, we waive copyright and", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Integrating%20Section%20508%20Requirements%20in%20the%20Development%20Lifecycle.pptx", + "committed": false, + "bytes": 611924, + "sha256": "51c7d6af2e6682e5ee66204b813a45488e62b0e6e13cecc9207a982fdeb1720a" + }, + { + "file": "poi-sample.doc", + "group": "converted", + "family": "doc", + "title": "Apache POI test document (Word 97)", + "features": "a binary .doc", + "author": "Apache POI committers", + "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/SampleDoc.doc", + "evidence": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/SampleDoc.doc", + "committed": false, + "bytes": 27136, + "sha256": "717585c0a88f20862c86220163dd03da87cd743970354c6b29add32725f2e833" + }, + { + "file": "poi-word95.doc", + "group": "converted", + "family": "doc", + "title": "Apache POI test document (Word 6 and 95 format)", + "features": "the pre-1997 Word format", + "author": "Nick Burch (Apache POI)", + "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/Word95.doc", + "evidence": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/Word95.doc", + "committed": false, + "bytes": 102400, + "sha256": "a7b9557e917daf29cfee6baaa90226fbfa7900ffa05cc35bdc03aafdefef5789" + }, + { + "file": "dhs-directive-139-02.doc", + "group": "converted", + "family": "doc", + "title": "DHS Directive 139-02 (Word 97)", + "features": "a government directive in the binary .doc format", + "author": "U.S. Department of Homeland Security", + "source": "https://github.com/GSA/Assets.Section508.gov/blob/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Directive_139.2_Final_10-28-2008.doc", + "evidence": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/LICENSE.md", + "evidence_quote": "this project is in the public domain within the United States of America. Additionally, we waive copyright and", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Directive_139.2_Final_10-28-2008.doc", + "committed": false, + "bytes": 82432, + "sha256": "48d1c85456d6b515ce8e3db6b43024f90bac69be86a3e78c1ae08406d4cad493" + }, + { + "file": "poi-formula-eval.xls", + "group": "converted", + "family": "xls", + "title": "Apache POI test workbook of formulas (Excel 97)", + "features": "hundreds of formula cases", + "author": "Apache POI committers", + "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/FormulaEvalTestData.xls", + "evidence": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/FormulaEvalTestData.xls", + "committed": false, + "bytes": 178176, + "sha256": "c2040c1fac1088f1e112abef197ae1f9bcb03547861783b89c1d4281a42049d1" + }, + { + "file": "poi-conditional-formats.xls", + "group": "converted", + "family": "xls", + "title": "Apache POI test workbook of conditional formats (Excel 97)", + "features": "colour scales, icon sets and data bars", + "author": "Nick Burch (Apache POI)", + "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/NewStyleConditionalFormattings.xls", + "evidence": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/NewStyleConditionalFormattings.xls", + "committed": false, + "bytes": 44544, + "sha256": "899a29aa6ce35fee0268847ff8b5d9459e6ce34d5e2ff462fd42ee9b80837b87" + }, + { + "file": "poi-excel5.xls", + "group": "converted", + "family": "xls", + "title": "Apache POI test workbook (Excel 5 and 95 format)", + "features": "the BIFF5 format of 1993 to 1995", + "author": "Apache POI committers", + "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/testEXCEL_5.xls", + "evidence": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/testEXCEL_5.xls", + "committed": false, + "bytes": 7680, + "sha256": "ac0ce3a2eff43e32e56d6b789df6aaa1645ae846d22fa3a2fe3057391d0f1cae" + }, + { + "file": "poi-sample-show.ppt", + "group": "converted", + "family": "ppt", + "title": "Apache POI test presentation (PowerPoint 97)", + "features": "a binary .ppt", + "author": "Apache POI committers", + "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/slideshow/SampleShow.ppt", + "evidence": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/slideshow/SampleShow.ppt", + "committed": false, + "bytes": 125440, + "sha256": "64275a6685b8f267827178037dcc59de927475e56385725916f4c6039c7ce7f6" + }, + { + "file": "tika-rtf-various.rtf", + "group": "converted", + "family": "rtf", + "title": "Apache Tika test document: various RTF features", + "features": "fonts, colours, tables and lists in RTF", + "author": "Michael McCandless (Apache Tika)", + "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testRTFVarious.rtf", + "evidence": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testRTFVarious.rtf", + "committed": false, + "bytes": 65517, + "sha256": "cf00c56fe7099d14b820a0d751324e415f2c09af580f2c96f08614a2aec42c1e" + }, + { + "file": "tdf-thesis-template.odt", + "group": "converted", + "family": "odt", + "title": "Thesis template in Spanish (OpenDocument text)", + "features": "styles, a table of contents, headers", + "author": "Phorious (Document Foundation wiki user)", + "source": "https://wiki.documentfoundation.org/File:Tesis-v1.0.4.odt", + "evidence": "https://wiki.documentfoundation.org/File:Tesis-v1.0.4.odt", + "evidence_quote": "This file is made available under the Creative Commons CC0 1.0 Universal Public Domain Ded", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://wiki.documentfoundation.org/images/5/54/Tesis-v1.0.4.odt", + "committed": false, + "bytes": 67249, + "sha256": "ec323e9fde67103a7f0039d8a59625bd7e2c5afa9e057563f5064c6b68d442a1" + }, + { + "file": "tika-odt-ooo3.odt", + "group": "converted", + "family": "odt", + "title": "Apache Tika test document written with OpenOffice 3 (OpenDocument text)", + "features": "an .odt of 2009", + "author": "Apache Tika committers", + "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testODFwithOOo3.odt", + "evidence": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testODFwithOOo3.odt", + "committed": false, + "bytes": 24286, + "sha256": "b6cd74b570a98fcb6d838dd85ab869604e0711812d9533d5ecde0000b24ba7ef" + }, + { + "file": "tdf-household-budget.ods", + "group": "converted", + "family": "ods", + "title": "Household budget book (OpenDocument spreadsheet)", + "features": "formulas, charts and several sheets", + "author": "Hrbrgr (Document Foundation wiki user)", + "source": "https://wiki.documentfoundation.org/File:Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods", + "evidence": "https://wiki.documentfoundation.org/File:Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods", + "evidence_quote": "This work is licensed under the Attribution 3.0 Unported License", + "licence": "CC-BY-3.0", + "attribution": "Hrbrgr (Document Foundation wiki user), Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods, https://wiki.documentfoundation.org/File:Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods, CC BY 3.0. No changes.", + "fetched": "https://wiki.documentfoundation.org/images/b/bc/Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods", + "committed": false, + "bytes": 21595, + "sha256": "aacd76666860f7bf879e1518a07b90d535815ce92d076ea26c835061b8aff854" + }, + { + "file": "odfdo-flat.fods", + "group": "converted", + "family": "fods", + "title": "odfdo test sample (flat OpenDocument spreadsheet)", + "features": "one XML file", + "author": "Jérôme Dumonteil (odfdo)", + "source": "https://github.com/jdum/odfdo/blob/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/test_flat_lo.fods", + "evidence": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Copyright Jérôme Dumonteil, odfdo, Apache-2.0, https://github.com/jdum/odfdo. No changes.", + "fetched": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/test_flat_lo.fods", + "committed": false, + "bytes": 33177, + "sha256": "4966023bf427dbc9c975dc9b11816b4a90158f7dab452c59104b8800fb27ec36" + }, + { + "file": "tdf-presentation-template.odp", + "group": "converted", + "family": "odp", + "title": "Presentation template (OpenDocument presentation)", + "features": "master slides, pictures and layouts", + "author": "Phorious (Document Foundation wiki user)", + "source": "https://wiki.documentfoundation.org/File:Presenta.odp", + "evidence": "https://wiki.documentfoundation.org/File:Presenta.odp", + "evidence_quote": "This file is made available under the Creative Commons CC0 1.0 Universal Public Domain Ded", + "licence": "CC0-1.0", + "attribution": "", + "fetched": "https://wiki.documentfoundation.org/images/5/51/Presenta.odp", + "committed": false, + "bytes": 412768, + "sha256": "147ca9c6dbcdd45470627b84c04824c7f3e07de57485a8190018eea5f23c64b3" + }, + { + "file": "odfdo-example.odp", + "group": "converted", + "family": "odp", + "title": "odfdo test sample (OpenDocument presentation)", + "features": "slides with text and shapes", + "author": "Jérôme Dumonteil (odfdo)", + "source": "https://github.com/jdum/odfdo/blob/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/example.odp", + "evidence": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Copyright Jérôme Dumonteil, odfdo, Apache-2.0, https://github.com/jdum/odfdo. No changes.", + "fetched": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/example.odp", + "committed": false, + "bytes": 55687, + "sha256": "9075b87e36bb4618bdc9a8a6138bcaaf31b2b81e089e9006ac906e66a94f912b" + }, + { + "file": "tdf-diagram-gallery.odg", + "group": "converted", + "family": "odg", + "title": "A gallery of diagrams (OpenDocument drawing)", + "features": "several diagram types", + "author": "Hrbrgr (Document Foundation wiki user)", + "source": "https://wiki.documentfoundation.org/File:Diagramme.odg", + "evidence": "https://wiki.documentfoundation.org/File:Diagramme.odg", + "evidence_quote": "This work is licensed under the Attribution 3.0 Unported License", + "licence": "CC-BY-3.0", + "attribution": "Hrbrgr (Document Foundation wiki user), Diagramme.odg, https://wiki.documentfoundation.org/File:Diagramme.odg, CC BY 3.0. No changes.", + "fetched": "https://wiki.documentfoundation.org/images/0/07/Diagramme.odg", + "committed": false, + "bytes": 91383, + "sha256": "2e5f43734db7c600256e0670f6ec9da9428eb6b7a074c4742fd506e32c833a5a" + }, + { + "file": "odfdo-shapes.odg", + "group": "converted", + "family": "odg", + "title": "odfdo test sample: basic shapes (OpenDocument drawing)", + "features": "rectangles, ellipses, lines and polygons", + "author": "Jérôme Dumonteil (odfdo)", + "source": "https://github.com/jdum/odfdo/blob/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/base_shapes.odg", + "evidence": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Copyright Jérôme Dumonteil, odfdo, Apache-2.0, https://github.com/jdum/odfdo. No changes.", + "fetched": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/base_shapes.odg", + "committed": false, + "bytes": 10351, + "sha256": "dfe6a6c83ba5caec340889d6c77a899e420060d55f4b08fd9a1fb7b36e0b799f" + }, + { + "file": "tika-keynote09.key", + "group": "converted", + "family": "keynote", + "title": "Apache Tika test presentation (Keynote '09)", + "features": "an XML Keynote document (index.apxl)", + "author": "Jukka Zitting (Apache Tika)", + "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote.key", + "evidence": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote.key", + "committed": false, + "bytes": 221745, + "sha256": "4a270c2427f11d77f82fae696e3fd6eda945bae0422ae87c5fac6cf2bd06015c" + }, + { + "file": "tika-keynote2013.key", + "group": "converted", + "family": "keynote", + "title": "Apache Tika test presentation (Keynote 2013, IWA format)", + "features": "a Keynote document in the iWork archive format", + "author": "Tim Allison (Apache Tika)", + "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote2013.key", + "evidence": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote2013.key", + "committed": false, + "bytes": 274397, + "sha256": "8da93f6b566bc9eb22f4791bbfd5f2179162f39bf80968187b164c33d31824db" + }, + { + "file": "tika-numbers09-charts.numbers", + "group": "converted", + "family": "numbers", + "title": "Apache Tika test spreadsheet with charts (Numbers '09)", + "features": "an XML Numbers document with charts", + "author": "Michael McCandless (Apache Tika)", + "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testNumbersCharts.numbers", + "evidence": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testNumbersCharts.numbers", + "committed": false, + "bytes": 104547, + "sha256": "f4430f2aa0ddab186e7e32f15552b58b7b03abada7a978dfbcd51e5548757789" + }, + { + "file": "numbers-formulas.numbers", + "group": "converted", + "family": "numbers", + "title": "numbers-parser test spreadsheet of formulas (Numbers)", + "features": "formulas across tables", + "author": "Jon Connell (numbers-parser)", + "source": "https://github.com/masaccio/numbers-parser/blob/a6affe5599c8373f575192ac5777d0c6426da696/tests/data/create-formulas.numbers", + "evidence": "https://raw.githubusercontent.com/masaccio/numbers-parser/a6affe5599c8373f575192ac5777d0c6426da696/LICENSE.rst", + "evidence_quote": "Permission is hereby granted, free of charge, to any person obtaining a copy of this softw", + "licence": "MIT", + "attribution": "Copyright 2021 Jon Connell, numbers-parser, MIT, https://github.com/masaccio/numbers-parser. No changes.", + "fetched": "https://raw.githubusercontent.com/masaccio/numbers-parser/a6affe5599c8373f575192ac5777d0c6426da696/tests/data/create-formulas.numbers", + "committed": false, + "bytes": 330282, + "sha256": "9371c5b1d6ee4dfa17569097f064eba9c67f804d88b48638efbbeeb459d07dd4" + }, + { + "file": "poi-network.vsdx", + "group": "converted", + "family": "visio", + "title": "Apache POI test diagram (Visio): a network", + "features": "a use-case diagram: actors, ovals and connecting lines", + "author": "A reporter of Apache POI bug 60489 (committed by Tim Allison)", + "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/diagram/60489.vsdx", + "evidence": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/diagram/60489.vsdx", + "committed": false, + "bytes": 44426, + "sha256": "15494702ecb5554c1dfaee2c09ac35e2ea656e2eed7ba4ba0cf1039e6125fbe1" + }, + { + "file": "vsdx-house.vsdx", + "group": "converted", + "family": "visio", + "title": "vsdx library test drawing (Visio): a house", + "features": "boxes and house shapes with text", + "author": "Dave Howard (vsdx library)", + "source": "https://github.com/dave-howard/vsdx/blob/6703e6c2c906bea4051f43525ec9af9dcf735c13/tests/test3_house.vsdx", + "evidence": "https://raw.githubusercontent.com/dave-howard/vsdx/6703e6c2c906bea4051f43525ec9af9dcf735c13/LICENSE", + "evidence_quote": "BSD 3-Clause License Copyright (c) 2020, Dave Howard", + "licence": "BSD-3-Clause", + "attribution": "Copyright (c) 2020, Dave Howard, BSD-3-Clause, https://github.com/dave-howard/vsdx. No changes.", + "fetched": "https://raw.githubusercontent.com/dave-howard/vsdx/6703e6c2c906bea4051f43525ec9af9dcf735c13/tests/test3_house.vsdx", + "committed": false, + "bytes": 20914, + "sha256": "e454217e49d1ec2aaa7c9b8f38b148ffc0d028ce44ba096fe3386e9894b1775f" + }, + { + "file": "tika-pages09-layout.pages", + "group": "unsupported", + "family": "pages", + "title": "Apache Tika test document (Pages '09)", + "features": "kept to show what the viewer says about Pages files, which it does not list yet", + "author": "Michael McCandless (Apache Tika)", + "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testPagesLayout.pages", + "evidence": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt", + "evidence_quote": "Version 2.0, January 2004", + "licence": "Apache-2.0", + "attribution": "Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes.", + "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testPagesLayout.pages", + "committed": false, + "bytes": 66480, + "sha256": "6157fe7a95f7bc7d42251872f990d2f2d589f7f5e25a52b58e25c14cc447a4b2" + } + ] +} diff --git a/crates/exav-viewer/demo/examples/files/archives/encrypted-aes.zip b/crates/exav-viewer/demo/examples/files/archives/encrypted-aes.zip new file mode 100644 index 00000000..adc147a7 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/encrypted-aes.zip differ diff --git a/crates/exav-viewer/demo/examples/files/archives/encrypted-zipcrypto.zip b/crates/exav-viewer/demo/examples/files/archives/encrypted-zipcrypto.zip new file mode 100644 index 00000000..cdb7fa4b Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/encrypted-zipcrypto.zip differ diff --git a/crates/exav-viewer/demo/examples/files/archives/encrypted.7z b/crates/exav-viewer/demo/examples/files/archives/encrypted.7z new file mode 100644 index 00000000..e1cc2da9 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/encrypted.7z differ diff --git a/crates/exav-viewer/demo/examples/files/archives/example.jar b/crates/exav-viewer/demo/examples/files/archives/example.jar new file mode 100644 index 00000000..6f07e392 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/example.jar differ diff --git a/crates/exav-viewer/demo/examples/files/archives/exav-example.deb b/crates/exav-viewer/demo/examples/files/archives/exav-example.deb new file mode 100644 index 00000000..7bfac737 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/exav-example.deb differ diff --git a/crates/exav-viewer/demo/examples/files/archives/libexample.a b/crates/exav-viewer/demo/examples/files/archives/libexample.a new file mode 100644 index 00000000..075883e1 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/archives/libexample.a @@ -0,0 +1,40 @@ +! +README.txt/ 0 0 0 644 227 ` +Example archive +=============== + +This archive holds a few files of the exav demo. It is in the public domain +(CC0-1.0). Look at the folders: docs, data, images, src, an empty one, and a +folder with accented and Japanese names. + +field-notes.txt/0 0 0 644 1632 ` +Field notes +=========== + +Plain text is shown as it is written: line breaks, indentation, tabs and +long lines are kept. + + A line that starts with a tab. + A line that starts with eight spaces. + +A very long line, to see how the viewer wraps or scrolls it: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +Pangrams, one for each script: + + English The quick brown fox jumps over the lazy dog. + Français Portez ce vieux whisky au juge blond qui fume. + Deutsch Zwölf Boxkämpfer jagen Viktor quer über den großen Sylter Deich. + Español El pingüino Wenceslao hizo kilómetros bajo exhaustiva lluvia y frío. + Ελληνικά Ξεσκεπάζω την ψυχοφθόÏ�α βδελυγμία. + РуÑ�Ñ�кий Съешь же ещё Ñ�тих мÑ�гких французÑ�ких булок да выпей чаю. + 日本語 ã�„ã‚�ã�¯ã�«ã�»ã�¸ã�¨ ã�¡ã‚Šã�¬ã‚‹ã‚’ ã‚�ã�‹ã‚ˆã�Ÿã‚Œã�� ã�¤ã�­ã�ªã‚‰ã‚€ + العربية نص حكيم له سر قاطع وذو شأن عظيم مكتوب على ثوب أخضر ومزخرÙ� + עברית דג סקרן שט בי×� מ×�וכזב ולפתע מצ×� לו חברה ×�יך הקליטה + +Symbols and box drawing: + + ┌──────┬──────â”� â†� ↑ → ↓ ✓ ✗ ½ â…“ ¾ ± × ÷ ≠ ≤ ≥ ∞ + │ a │ b │ € £ Â¥ â‚¿ © ® â„¢ ☀ â˜� ☂ ☃ + └──────┴──────┘ + +The last line has no newline at the end. \ No newline at end of file diff --git a/crates/exav-viewer/demo/examples/files/archives/matryoshka.zip b/crates/exav-viewer/demo/examples/files/archives/matryoshka.zip new file mode 100644 index 00000000..cb364545 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/matryoshka.zip differ diff --git a/crates/exav-viewer/demo/examples/files/archives/pages.cbz b/crates/exav-viewer/demo/examples/files/archives/pages.cbz new file mode 100644 index 00000000..04c5fd2a Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/pages.cbz differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.7z b/crates/exav-viewer/demo/examples/files/archives/sample.7z new file mode 100644 index 00000000..11e20bf6 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.7z differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.cab b/crates/exav-viewer/demo/examples/files/archives/sample.cab new file mode 100644 index 00000000..8a7e54cc Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.cab differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.cpio b/crates/exav-viewer/demo/examples/files/archives/sample.cpio new file mode 100644 index 00000000..53c7d50d Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.cpio differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.iso b/crates/exav-viewer/demo/examples/files/archives/sample.iso new file mode 100644 index 00000000..17b5c7cb Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.iso differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.rar b/crates/exav-viewer/demo/examples/files/archives/sample.rar new file mode 100644 index 00000000..b1b3f915 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.rar differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.tar b/crates/exav-viewer/demo/examples/files/archives/sample.tar new file mode 100644 index 00000000..af4121bc Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.tar differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.tar.bz2 b/crates/exav-viewer/demo/examples/files/archives/sample.tar.bz2 new file mode 100644 index 00000000..e324763b Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.tar.bz2 differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.tar.gz b/crates/exav-viewer/demo/examples/files/archives/sample.tar.gz new file mode 100644 index 00000000..e2226a94 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.tar.gz differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.tar.xz b/crates/exav-viewer/demo/examples/files/archives/sample.tar.xz new file mode 100644 index 00000000..67a330de Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.tar.xz differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.tar.zst b/crates/exav-viewer/demo/examples/files/archives/sample.tar.zst new file mode 100644 index 00000000..be2a171f Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.tar.zst differ diff --git a/crates/exav-viewer/demo/examples/files/archives/sample.zip b/crates/exav-viewer/demo/examples/files/archives/sample.zip new file mode 100644 index 00000000..8dfb9b37 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/sample.zip differ diff --git a/crates/exav-viewer/demo/examples/files/archives/server.log.bz2 b/crates/exav-viewer/demo/examples/files/archives/server.log.bz2 new file mode 100644 index 00000000..0ee7140c Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/server.log.bz2 differ diff --git a/crates/exav-viewer/demo/examples/files/archives/server.log.gz b/crates/exav-viewer/demo/examples/files/archives/server.log.gz new file mode 100644 index 00000000..c466381d Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/server.log.gz differ diff --git a/crates/exav-viewer/demo/examples/files/archives/server.log.xz b/crates/exav-viewer/demo/examples/files/archives/server.log.xz new file mode 100644 index 00000000..d6fbea03 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/archives/server.log.xz differ diff --git a/crates/exav-viewer/demo/examples/files/cad/blocks.dxf b/crates/exav-viewer/demo/examples/files/cad/blocks.dxf new file mode 100644 index 00000000..e7273b73 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/blocks.dxf @@ -0,0 +1,6052 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1032 + 9 +$ACADMAINTVER + 90 +4 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$REQUIREDVERSIONS +160 +0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +A2 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +9E +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +298.30127018922195 + 22 +-110.57066309444073 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +421.1983924983256 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +5 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PARTS + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +NOTE + 70 +0 + 62 +8 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +43 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +RED + 70 +0 + 62 +1 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +44 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +BL-TEXT + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +9F +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +A0 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +6 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +45 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +BOLT +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +52 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +PLATE +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +67 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +FRAME +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8F +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SYMBOL +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +46 +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +BOLT + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +BOLT + 1 + + 0 +LWPOLYLINE + 5 +48 +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +-6.0 + 20 +0.0 + 10 +6.0 + 20 +0.0 + 10 +6.0 + 20 +4.0 + 10 +-6.0 + 20 +4.0 + 0 +LWPOLYLINE + 5 +49 +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +-3.0 + 20 +0.0 + 10 +3.0 + 20 +0.0 + 10 +3.0 + 20 +-22.0 + 10 +-3.0 + 20 +-22.0 + 0 +LINE + 5 +4A +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +-3.0 + 20 +-4.0 + 30 +0.0 + 11 +3.0 + 21 +-6.5 + 31 +0.0 + 0 +LINE + 5 +4B +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +-3.0 + 20 +-6.5 + 30 +0.0 + 11 +3.0 + 21 +-9.0 + 31 +0.0 + 0 +LINE + 5 +4C +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +-3.0 + 20 +-9.0 + 30 +0.0 + 11 +3.0 + 21 +-11.5 + 31 +0.0 + 0 +LINE + 5 +4D +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +-3.0 + 20 +-11.5 + 30 +0.0 + 11 +3.0 + 21 +-14.0 + 31 +0.0 + 0 +LINE + 5 +4E +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +-3.0 + 20 +-14.0 + 30 +0.0 + 11 +3.0 + 21 +-16.5 + 31 +0.0 + 0 +LINE + 5 +4F +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +-3.0 + 20 +-16.5 + 30 +0.0 + 11 +3.0 + 21 +-19.0 + 31 +0.0 + 0 +LINE + 5 +50 +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +-3.0 + 20 +-19.0 + 30 +0.0 + 11 +3.0 + 21 +-21.5 + 31 +0.0 + 0 +ATTDEF + 5 +51 +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +8.0 + 20 +1.0 + 30 +0.0 + 40 +2.5 + 1 +M6 +100 +AcDbAttributeDefinition + 3 + + 2 +SIZE + 70 +0 + 71 +1 + 72 +0 + 0 +ENDBLK + 5 +47 +330 +45 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +53 +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +PLATE + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +PLATE + 1 + + 0 +LWPOLYLINE + 5 +55 +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +100.0 + 20 +0.0 + 10 +100.0 + 20 +60.0 + 10 +0.0 + 20 +60.0 + 0 +CIRCLE + 5 +56 +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +15.0 + 20 +15.0 + 30 +0.0 + 40 +4.0 + 0 +INSERT + 5 +57 +330 +52 +100 +AcDbEntity + 8 +RED +100 +AcDbBlockReference + 66 +1 + 2 +BOLT + 10 +15.0 + 20 +15.0 + 30 +0.0 + 0 +ATTRIB + 5 +59 +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +23.0 + 20 +16.0 + 30 +0.0 + 40 +2.5 + 1 +M6 + 11 +23.0 + 21 +16.0 + 31 +0.0 +100 +AcDbAttribute + 2 +SIZE + 70 +0 + 71 +1 + 72 +0 + 11 +23.0 + 21 +16.0 + 31 +0.0 + 0 +SEQEND + 5 +58 +330 +57 +100 +AcDbEntity + 8 +RED + 0 +CIRCLE + 5 +5A +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +85.0 + 20 +15.0 + 30 +0.0 + 40 +4.0 + 0 +INSERT + 5 +5B +330 +52 +100 +AcDbEntity + 8 +RED +100 +AcDbBlockReference + 66 +1 + 2 +BOLT + 10 +85.0 + 20 +15.0 + 30 +0.0 + 0 +ATTRIB + 5 +5D +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +93.0 + 20 +16.0 + 30 +0.0 + 40 +2.5 + 1 +M6 + 11 +93.0 + 21 +16.0 + 31 +0.0 +100 +AcDbAttribute + 2 +SIZE + 70 +0 + 71 +1 + 72 +0 + 11 +93.0 + 21 +16.0 + 31 +0.0 + 0 +SEQEND + 5 +5C +330 +5B +100 +AcDbEntity + 8 +RED + 0 +CIRCLE + 5 +5E +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +15.0 + 20 +45.0 + 30 +0.0 + 40 +4.0 + 0 +INSERT + 5 +5F +330 +52 +100 +AcDbEntity + 8 +RED +100 +AcDbBlockReference + 66 +1 + 2 +BOLT + 10 +15.0 + 20 +45.0 + 30 +0.0 + 0 +ATTRIB + 5 +61 +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +23.0 + 20 +46.0 + 30 +0.0 + 40 +2.5 + 1 +M6 + 11 +23.0 + 21 +46.0 + 31 +0.0 +100 +AcDbAttribute + 2 +SIZE + 70 +0 + 71 +1 + 72 +0 + 11 +23.0 + 21 +46.0 + 31 +0.0 + 0 +SEQEND + 5 +60 +330 +5F +100 +AcDbEntity + 8 +RED + 0 +CIRCLE + 5 +62 +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +85.0 + 20 +45.0 + 30 +0.0 + 40 +4.0 + 0 +INSERT + 5 +63 +330 +52 +100 +AcDbEntity + 8 +RED +100 +AcDbBlockReference + 66 +1 + 2 +BOLT + 10 +85.0 + 20 +45.0 + 30 +0.0 + 0 +ATTRIB + 5 +65 +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +93.0 + 20 +46.0 + 30 +0.0 + 40 +2.5 + 1 +M6 + 11 +93.0 + 21 +46.0 + 31 +0.0 +100 +AcDbAttribute + 2 +SIZE + 70 +0 + 71 +1 + 72 +0 + 11 +93.0 + 21 +46.0 + 31 +0.0 + 0 +SEQEND + 5 +64 +330 +63 +100 +AcDbEntity + 8 +RED + 0 +ATTDEF + 5 +66 +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +50.0 + 20 +30.0 + 30 +0.0 + 40 +6.0 + 1 +P-1 + 72 +4 + 11 +50.0 + 21 +30.0 + 31 +0.0 +100 +AcDbAttributeDefinition + 3 + + 2 +MARK + 70 +0 + 71 +1 + 72 +0 + 11 +50.0 + 21 +30.0 + 31 +0.0 + 0 +ENDBLK + 5 +54 +330 +52 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +68 +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +FRAME + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +FRAME + 1 + + 0 +LWPOLYLINE + 5 +6A +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +-10.0 + 20 +-10.0 + 10 +330.0 + 20 +-10.0 + 10 +330.0 + 20 +90.0 + 10 +-10.0 + 20 +90.0 + 0 +INSERT + 5 +6B +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockReference + 66 +1 + 2 +PLATE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +ATTRIB + 5 +6D +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +50.0 + 20 +30.0 + 30 +0.0 + 40 +6.0 + 1 +A + 72 +4 + 11 +50.0 + 21 +30.0 + 31 +0.0 +100 +AcDbAttribute + 2 +MARK + 70 +0 + 71 +1 + 72 +0 + 11 +50.0 + 21 +30.0 + 31 +0.0 + 0 +SEQEND + 5 +6C +330 +6B +100 +AcDbEntity + 8 +0 + 0 +INSERT + 5 +6E +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockReference + 66 +1 + 2 +PLATE + 10 +120.0 + 20 +0.0 + 30 +0.0 + 41 +0.7 + 42 +0.7 + 0 +ATTRIB + 5 +70 +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +155.0 + 20 +21.0 + 30 +0.0 + 40 +4.199999999999999 + 1 +B + 72 +4 + 11 +155.0 + 21 +21.0 + 31 +0.0 +100 +AcDbAttribute + 2 +MARK + 70 +0 + 71 +1 + 72 +0 + 11 +155.0 + 21 +21.0 + 31 +0.0 + 0 +SEQEND + 5 +6F +330 +6E +100 +AcDbEntity + 8 +0 + 0 +INSERT + 5 +71 +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockReference + 66 +1 + 2 +PLATE + 10 +230.0 + 20 +70.0 + 30 +0.0 + 41 +0.6 + 42 +0.6 + 50 +-90.0 + 0 +ATTRIB + 5 +73 +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +248.0 + 20 +40.0 + 30 +0.0 + 40 +3.5999999999999996 + 1 +C + 50 +-90.0 + 72 +4 + 11 +248.0 + 21 +40.0 + 31 +0.0 +100 +AcDbAttribute + 2 +MARK + 70 +0 + 71 +1 + 72 +0 + 11 +248.0 + 21 +40.0 + 31 +0.0 + 0 +SEQEND + 5 +72 +330 +71 +100 +AcDbEntity + 8 +0 + 0 +ENDBLK + 5 +69 +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +90 +330 +8F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SYMBOL + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SYMBOL + 1 + + 0 +CIRCLE + 5 +92 +330 +8F +100 +AcDbEntity + 8 +0 + 62 +0 +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +10.0 + 0 +LINE + 5 +93 +330 +8F +100 +AcDbEntity + 8 +0 + 62 +0 +100 +AcDbLine + 10 +-10.0 + 20 +0.0 + 30 +0.0 + 11 +10.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +94 +330 +8F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +0.0 + 20 +-10.0 + 30 +0.0 + 11 +0.0 + 21 +10.0 + 31 +0.0 + 0 +ENDBLK + 5 +91 +330 +8F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +INSERT + 5 +74 +330 +17 +100 +AcDbEntity + 8 +PARTS +100 +AcDbBlockReference + 2 +FRAME + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +TEXT + 5 +76 +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +0.0 + 20 +-20.0 + 30 +0.0 + 40 +4.0 + 1 +FRAME: nested PLATE, BOLT blocks, attributes + 7 +BL-TEXT + 11 +0.0 + 21 +-20.0 + 31 +0.0 +100 +AcDbText + 0 +INSERT + 5 +77 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockReference + 66 +1 + 2 +PLATE + 10 +0.0 + 20 +-80.0 + 30 +0.0 + 0 +ATTRIB + 5 +79 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +50.0 + 20 +-50.0 + 30 +0.0 + 40 +6.0 + 1 +scale 1 + 72 +4 + 11 +50.0 + 21 +-50.0 + 31 +0.0 +100 +AcDbAttribute + 2 +MARK + 70 +0 + 71 +1 + 72 +0 + 11 +50.0 + 21 +-50.0 + 31 +0.0 + 0 +SEQEND + 5 +78 +330 +77 +100 +AcDbEntity + 8 +0 + 0 +TEXT + 5 +7A +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +0.0 + 20 +-100.0 + 30 +0.0 + 40 +4.0 + 1 +scale 1 + 7 +BL-TEXT + 11 +0.0 + 21 +-100.0 + 31 +0.0 +100 +AcDbText + 0 +INSERT + 5 +7B +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockReference + 66 +1 + 2 +PLATE + 10 +230.0 + 20 +-80.0 + 30 +0.0 + 41 +-1.0 + 0 +ATTRIB + 5 +7D +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +-180.0 + 20 +-50.0 + 30 +0.0 + 40 +6.0 + 1 +xscale -1 + 72 +4 + 11 +-180.0 + 21 +-50.0 + 31 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 +100 +AcDbAttribute + 2 +MARK + 70 +0 + 71 +1 + 72 +0 + 11 +-180.0 + 21 +-50.0 + 31 +0.0 + 0 +SEQEND + 5 +7C +330 +7B +100 +AcDbEntity + 8 +0 + 0 +TEXT + 5 +7E +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +130.0 + 20 +-100.0 + 30 +0.0 + 40 +4.0 + 1 +xscale -1 + 7 +BL-TEXT + 11 +130.0 + 21 +-100.0 + 31 +0.0 +100 +AcDbText + 0 +INSERT + 5 +7F +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockReference + 66 +1 + 2 +PLATE + 10 +260.0 + 20 +-20.0 + 30 +0.0 + 42 +-1.0 + 0 +ATTRIB + 5 +81 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +-310.0 + 20 +-50.0 + 30 +0.0 + 40 +6.0 + 1 +yscale -1 + 50 +180.0 + 72 +4 + 11 +-310.0 + 21 +-50.0 + 31 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 +100 +AcDbAttribute + 2 +MARK + 70 +0 + 71 +1 + 72 +0 + 11 +-310.0 + 21 +-50.0 + 31 +0.0 + 0 +SEQEND + 5 +80 +330 +7F +100 +AcDbEntity + 8 +0 + 0 +TEXT + 5 +82 +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +260.0 + 20 +-100.0 + 30 +0.0 + 40 +4.0 + 1 +yscale -1 + 7 +BL-TEXT + 11 +260.0 + 21 +-100.0 + 31 +0.0 +100 +AcDbText + 0 +INSERT + 5 +83 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockReference + 66 +1 + 2 +PLATE + 10 +390.0 + 20 +-80.0 + 30 +0.0 + 41 +1.5 + 42 +0.6 + 0 +ATTRIB + 5 +85 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +465.0 + 20 +-62.0 + 30 +0.0 + 40 +3.5999999999999996 + 1 +1.5 by 0.6 + 41 +2.5 + 72 +4 + 11 +465.0 + 21 +-62.0 + 31 +0.0 +100 +AcDbAttribute + 2 +MARK + 70 +0 + 71 +1 + 72 +0 + 11 +465.0 + 21 +-62.0 + 31 +0.0 + 0 +SEQEND + 5 +84 +330 +83 +100 +AcDbEntity + 8 +0 + 0 +TEXT + 5 +86 +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +390.0 + 20 +-100.0 + 30 +0.0 + 40 +4.0 + 1 +1.5 by 0.6 + 7 +BL-TEXT + 11 +390.0 + 21 +-100.0 + 31 +0.0 +100 +AcDbText + 0 +INSERT + 5 +87 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockReference + 66 +1 + 2 +PLATE + 10 +520.0 + 20 +-80.0 + 30 +0.0 + 50 +30.0 + 0 +ATTRIB + 5 +89 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +548.3012701892219 + 20 +-29.01923788646684 + 30 +0.0 + 40 +6.0 + 1 +rotated 30 + 50 +29.999999999999996 + 72 +4 + 11 +548.3012701892219 + 21 +-29.01923788646684 + 31 +0.0 +100 +AcDbAttribute + 2 +MARK + 70 +0 + 71 +1 + 72 +0 + 11 +548.3012701892219 + 21 +-29.01923788646684 + 31 +0.0 + 0 +SEQEND + 5 +88 +330 +87 +100 +AcDbEntity + 8 +0 + 0 +TEXT + 5 +8A +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +520.0 + 20 +-100.0 + 30 +0.0 + 40 +4.0 + 1 +rotated 30 + 7 +BL-TEXT + 11 +520.0 + 21 +-100.0 + 31 +0.0 +100 +AcDbText + 0 +INSERT + 5 +8B +330 +17 +100 +AcDbEntity + 8 +PARTS +100 +AcDbMInsertBlock + 66 +1 + 2 +BOLT + 10 +0.0 + 20 +-200.0 + 30 +0.0 + 70 +8 + 71 +3 + 44 +20.0 + 45 +35.0 + 0 +ATTRIB + 5 +8D +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbText + 10 +8.0 + 20 +-199.0 + 30 +0.0 + 40 +2.5 + 1 +M6 + 11 +8.0 + 21 +-199.0 + 31 +0.0 +100 +AcDbAttribute + 2 +SIZE + 70 +0 + 71 +1 + 72 +0 + 11 +8.0 + 21 +-199.0 + 31 +0.0 + 0 +SEQEND + 5 +8C +330 +8B +100 +AcDbEntity + 8 +PARTS + 0 +TEXT + 5 +8E +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +0.0 + 20 +-310.0 + 30 +0.0 + 40 +4.0 + 1 +MINSERT: 3 rows by 8 columns of BOLT + 7 +BL-TEXT + 11 +0.0 + 21 +-310.0 + 31 +0.0 +100 +AcDbText + 0 +INSERT + 5 +95 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbBlockReference + 2 +SYMBOL + 10 +200.0 + 20 +-220.0 + 30 +0.0 + 0 +INSERT + 5 +97 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbBlockReference + 2 +SYMBOL + 10 +230.0 + 20 +-220.0 + 30 +0.0 + 0 +INSERT + 5 +99 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +5 +100 +AcDbBlockReference + 2 +SYMBOL + 10 +260.0 + 20 +-220.0 + 30 +0.0 + 0 +INSERT + 5 +9B +330 +17 +100 +AcDbEntity + 8 +0 + 62 +6 +100 +AcDbBlockReference + 2 +SYMBOL + 10 +290.0 + 20 +-220.0 + 30 +0.0 + 0 +TEXT + 5 +9D +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +200.0 + 20 +-240.0 + 30 +0.0 + 40 +3.0 + 1 +BYBLOCK circle and cross, BYLAYER vertical line: insert colour 1, 3, 5, 6 + 7 +BL-TEXT + 11 +200.0 + 21 +-240.0 + 31 +0.0 +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +A1 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +DICTIONARYVAR + 5 +A1 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/bracket.dxf b/crates/exav-viewer/demo/examples/files/cad/bracket.dxf new file mode 100644 index 00000000..b099700f --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/bracket.dxf @@ -0,0 +1,8288 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1032 + 9 +$ACADMAINTVER + 90 +4 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$REQUIREDVERSIONS +160 +0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +E9 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +E5 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +101.06408489618218 + 22 +-32.25 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +247.275 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +9 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +OUTLINE + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +HIDDEN + 70 +0 + 62 +4 + 6 +DASHED +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +43 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +CENTER + 70 +0 + 62 +1 + 6 +CENTER +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +44 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +SECTION + 70 +0 + 62 +8 + 6 +Continuous +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +45 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +DIMS + 70 +0 + 62 +3 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +46 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +SHEET + 70 +0 + 62 +7 + 6 +Continuous +370 +35 +390 +13 +347 +21 + 0 +LAYER + 5 +47 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TEXT + 70 +0 + 62 +2 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +48 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +MECH + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +E6 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +E7 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +3 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +49 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +MECH + 70 +0 + 40 +1.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 43 +3.75 + 44 +2.1 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +3.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +1.05 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +3 +177 +3 +178 +2 +179 +2 +271 +1 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +340 +48 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +4A +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +MECHTOL + 70 +0 + 40 +1.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 43 +3.75 + 44 +2.1 + 45 +0.0 + 46 +0.0 + 47 +0.1 + 48 +0.0 + 49 +2.5 +140 +3.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +0.7 +147 +1.05 +148 +0.0 + 69 +0 + 70 +0 + 71 +1 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +3 +177 +3 +178 +2 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +1 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +340 +48 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +11 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +6D +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D1 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +72 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDFILLED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +80 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D2 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8F +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D3 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +9E +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D4 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +A9 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D5 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +B8 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D6 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +C5 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D7 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +D5 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space0 +340 +D8 + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +6E +330 +6D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D1 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D1 + 1 + + 0 +LINE + 5 +70 +330 +6D +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-2.8000000000000003 + 30 +0.0 + 11 +0.0 + 21 +-14.1 + 31 +0.0 + 0 +LINE + 5 +71 +330 +6D +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +100.0 + 20 +-2.8000000000000003 + 30 +0.0 + 11 +100.0 + 21 +-14.1 + 31 +0.0 + 0 +INSERT + 5 +76 +330 +6D +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-12.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +180.0 + 0 +INSERT + 5 +78 +330 +6D +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +100.0 + 20 +-12.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 0 +LINE + 5 +7A +330 +6D +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +2.8000000000000003 + 20 +-12.0 + 30 +0.0 + 11 +97.2 + 21 +-12.0 + 31 +0.0 + 0 +MTEXT + 5 +7B +330 +6D +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +50.0 + 20 +-9.2 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +100 + 7 +MECH + 0 +POINT + 5 +7C +330 +6D +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-12.0 + 30 +0.0 + 0 +POINT + 5 +7D +330 +6D +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +7E +330 +6D +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +100.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +6F +330 +6D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +73 +330 +72 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDFILLED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDFILLED + 1 + + 0 +SOLID + 5 +75 +330 +72 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbTrace + 10 +-1.0 + 20 +0.16439898372402537 + 30 +0.0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 12 +-1.0 + 22 +-0.16439898372402537 + 32 +0.0 + 13 +-1.0 + 23 +-0.16439898372402537 + 33 +0.0 + 0 +ENDBLK + 5 +74 +330 +72 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +81 +330 +80 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D2 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D2 + 1 + + 0 +LINE + 5 +83 +330 +80 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-2.8000000000000003 + 20 +0.0 + 30 +0.0 + 11 +-14.1 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +84 +330 +80 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-2.8000000000000003 + 20 +72.0 + 30 +0.0 + 11 +-14.1 + 21 +72.0 + 31 +0.0 + 0 +INSERT + 5 +85 +330 +80 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-12.0 + 20 +0.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +270.0 + 0 +INSERT + 5 +87 +330 +80 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-12.0 + 20 +72.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +90.0 + 0 +LINE + 5 +89 +330 +80 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-12.0 + 20 +2.8000000000000003 + 30 +0.0 + 11 +-12.0 + 21 +69.2 + 31 +0.0 + 0 +MTEXT + 5 +8A +330 +80 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +-14.8 + 20 +36.0 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +\A1;72{\H0.70x;\S+0,10^ 0,00;} + 7 +MECH + 50 +90.0 + 0 +POINT + 5 +8B +330 +80 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-12.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +8C +330 +80 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +8D +330 +80 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +72.0 + 30 +0.0 + 0 +ENDBLK + 5 +82 +330 +80 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +90 +330 +8F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D3 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D3 + 1 + + 0 +LINE + 5 +92 +330 +8F +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +35.0 + 20 +-2.8000000000000003 + 30 +0.0 + 11 +35.0 + 21 +-26.1 + 31 +0.0 + 0 +LINE + 5 +93 +330 +8F +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +80.0 + 20 +-2.8000000000000003 + 30 +0.0 + 11 +80.0 + 21 +-26.1 + 31 +0.0 + 0 +INSERT + 5 +94 +330 +8F +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +35.0 + 20 +-24.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +180.0 + 0 +INSERT + 5 +96 +330 +8F +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +80.0 + 20 +-24.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 0 +LINE + 5 +98 +330 +8F +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +37.8 + 20 +-24.0 + 30 +0.0 + 11 +77.2 + 21 +-24.0 + 31 +0.0 + 0 +MTEXT + 5 +99 +330 +8F +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +57.5 + 20 +-21.2 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +\A1;45{\H0.70x;\S+0,10^ 0,00;} + 7 +MECH + 0 +POINT + 5 +9A +330 +8F +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +35.0 + 20 +-24.0 + 30 +0.0 + 0 +POINT + 5 +9B +330 +8F +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +35.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +9C +330 +8F +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +80.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +91 +330 +8F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +9F +330 +9E +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D4 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D4 + 1 + + 0 +INSERT + 5 +A1 +330 +9E +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14.343145750507619 + 20 +14.34314575050762 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +44.99999999999997 + 0 +LINE + 5 +A3 +330 +9E +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +20.0 + 20 +20.0 + 30 +0.0 + 11 +14.343145750507619 + 21 +14.34314575050762 + 31 +0.0 + 0 +LINE + 5 +A4 +330 +9E +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +12.363246763185284 + 20 +12.363246763185288 + 30 +0.0 + 11 +6.671037174633574 + 21 +6.67103717463358 + 31 +0.0 + 0 +MTEXT + 5 +A5 +330 +9E +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +7.166011921464162 + 20 +11.125809896108832 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +R8 + 7 +MECH + 50 +44.99999999999997 + 0 +POINT + 5 +A6 +330 +9E +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +20.0 + 20 +20.0 + 30 +0.0 + 0 +POINT + 5 +A7 +330 +9E +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14.343145750507619 + 20 +14.34314575050762 + 30 +0.0 + 0 +ENDBLK + 5 +A0 +330 +9E +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +AA +330 +A9 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D5 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D5 + 1 + + 0 +LINE + 5 +AC +330 +A9 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-87.2 + 30 +0.0 + 11 +0.0 + 21 +-75.9 + 31 +0.0 + 0 +LINE + 5 +AD +330 +A9 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +100.0 + 20 +-87.2 + 30 +0.0 + 11 +100.0 + 21 +-75.9 + 31 +0.0 + 0 +INSERT + 5 +AE +330 +A9 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-78.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +180.0 + 0 +INSERT + 5 +B0 +330 +A9 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +100.0 + 20 +-78.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 0 +LINE + 5 +B2 +330 +A9 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +2.8000000000000003 + 20 +-78.0 + 30 +0.0 + 11 +97.2 + 21 +-78.0 + 31 +0.0 + 0 +MTEXT + 5 +B3 +330 +A9 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +50.0 + 20 +-75.2 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +100 + 7 +MECH + 0 +POINT + 5 +B4 +330 +A9 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-78.0 + 30 +0.0 + 0 +POINT + 5 +B5 +330 +A9 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +B6 +330 +A9 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +100.0 + 20 +-90.0 + 30 +0.0 + 0 +ENDBLK + 5 +AB +330 +A9 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +B9 +330 +B8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D6 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D6 + 1 + + 0 +INSERT + 5 +BB +330 +B8 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +76.72339182284404 + 20 +-122.29430574540419 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +35.00000000000006 + 0 +INSERT + 5 +BD +330 +B8 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +83.27660817715596 + 20 +-117.70569425459581 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +35.00000000000006 + 0 +LINE + 5 +BF +330 +B8 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +74.42976609883486 + 20 +-123.90031976718711 + 30 +0.0 + 11 +80.98298245314679 + 21 +-119.31170827637874 + 31 +0.0 + 0 +LINE + 5 +C0 +330 +B8 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +74.42976609883486 + 20 +-123.90031976718711 + 30 +0.0 + 11 +56.367463522262604 + 21 +-136.5476801887277 + 31 +0.0 + 0 +MTEXT + 5 +C1 +330 +B8 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +63.36254596551408 + 20 +-128.23150188303254 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +2x ⌀%%c8 + 7 +MECH + 50 +35.00000000000006 + 0 +POINT + 5 +C2 +330 +B8 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +80.0 + 20 +-120.0 + 30 +0.0 + 0 +POINT + 5 +C3 +330 +B8 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +76.72339182284404 + 20 +-122.29430574540419 + 30 +0.0 + 0 +ENDBLK + 5 +BA +330 +B8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +C6 +330 +C5 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D7 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D7 + 1 + + 0 +LINE + 5 +C8 +330 +C5 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +212.8 + 20 +0.0 + 30 +0.0 + 11 +222.1 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +C9 +330 +C5 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +212.8 + 20 +12.0 + 30 +0.0 + 11 +222.1 + 21 +12.0 + 31 +0.0 + 0 +INSERT + 5 +CA +330 +C5 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +220.0 + 20 +0.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +270.0 + 0 +INSERT + 5 +CC +330 +C5 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +220.0 + 20 +12.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +90.0 + 0 +LINE + 5 +CE +330 +C5 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +220.0 + 20 +2.8000000000000003 + 30 +0.0 + 11 +220.0 + 21 +9.2 + 31 +0.0 + 0 +MTEXT + 5 +CF +330 +C5 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +217.2 + 20 +6.0 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +12 + 7 +MECH + 50 +90.0 + 0 +POINT + 5 +D0 +330 +C5 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +D1 +330 +C5 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +210.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +D2 +330 +C5 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +210.0 + 20 +12.0 + 30 +0.0 + 0 +ENDBLK + 5 +C7 +330 +C5 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +D6 +330 +D5 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space0 + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space0 + 1 + + 0 +VIEWPORT + 5 +D9 +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +VIEWPORTS +100 +AcDbViewport + 10 +143.5 + 20 +100.0 + 30 +0.0 + 40 +326.70000000000005 + 41 +231.00000000000003 + 68 +1 + 69 +1 + 12 +143.5 + 22 +100.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +231.00000000000003 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +557088 + 1 + +281 +0 + 71 +0 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 +282 +0 + 0 +LWPOLYLINE + 5 +DA +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +5.0 + 20 +5.0 + 10 +292.0 + 20 +5.0 + 10 +292.0 + 20 +205.0 + 10 +5.0 + 20 +205.0 + 0 +LWPOLYLINE + 5 +DB +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +137.0 + 20 +5.0 + 10 +292.0 + 20 +5.0 + 10 +292.0 + 20 +45.0 + 10 +137.0 + 20 +45.0 + 0 +LINE + 5 +DC +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbLine + 10 +137.0 + 20 +25.0 + 30 +0.0 + 11 +292.0 + 21 +25.0 + 31 +0.0 + 0 +LINE + 5 +DD +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbLine + 10 +200.0 + 20 +5.0 + 30 +0.0 + 11 +200.0 + 21 +25.0 + 31 +0.0 + 0 +LINE + 5 +DE +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbLine + 10 +250.0 + 20 +5.0 + 30 +0.0 + 11 +250.0 + 21 +25.0 + 31 +0.0 + 0 +TEXT + 5 +DF +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbText + 10 +142.0 + 20 +33.0 + 30 +0.0 + 40 +6.0 + 1 +L-BRACKET 100 x 72 x 60 + 7 +MECH + 11 +142.0 + 21 +33.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +E0 +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbText + 10 +142.0 + 20 +15.0 + 30 +0.0 + 40 +3.5 + 1 +Material: steel S235 + 7 +MECH + 11 +142.0 + 21 +15.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +E1 +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbText + 10 +205.0 + 20 +15.0 + 30 +0.0 + 40 +3.5 + 1 +Scale 1:1 + 7 +MECH + 11 +205.0 + 21 +15.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +E2 +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbText + 10 +255.0 + 20 +15.0 + 30 +0.0 + 40 +3.5 + 1 +Sheet 1/1 + 7 +MECH + 11 +255.0 + 21 +15.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +E3 +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +SHEET +100 +AcDbText + 10 +142.0 + 20 +8.0 + 30 +0.0 + 40 +3.0 + 1 +Drawn: exav demo + 7 +MECH + 11 +142.0 + 21 +8.0 + 31 +0.0 +100 +AcDbText + 0 +VIEWPORT + 5 +E4 +330 +D5 +100 +AcDbEntity + 67 +1 + 8 +VIEWPORTS +100 +AcDbViewport + 10 +130.0 + 20 +120.0 + 30 +0.0 + 40 +250.0 + 41 +140.0 + 68 +1 + 69 +3 + 12 +90.0 + 22 +-30.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +140.0 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +0 + 1 + +281 +0 + 71 +0 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 +282 +0 + 0 +ENDBLK + 5 +D7 +330 +D5 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LWPOLYLINE + 5 +4B +330 +17 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbPolyline + 90 +7 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +100.0 + 20 +0.0 + 10 +100.0 + 20 +12.0 + 10 +20.0 + 20 +12.0 + 10 +12.0 + 20 +12.0 + 10 +12.0 + 20 +72.0 + 10 +0.0 + 20 +72.0 + 0 +ARC + 5 +4C +330 +17 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbCircle + 10 +20.0 + 20 +20.0 + 30 +0.0 + 40 +8.0 +100 +AcDbArc + 50 +180.0 + 51 +270.0 + 0 +LINE + 5 +4D +330 +17 +100 +AcDbEntity + 8 +OUTLINE + 62 +8 +100 +AcDbLine + 10 +12.0 + 20 +12.0 + 30 +0.0 + 11 +12.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 5 +4E +330 +17 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +100.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +4F +330 +17 +100 +AcDbEntity + 8 +HIDDEN +100 +AcDbLine + 10 +31.0 + 20 +0.0 + 30 +0.0 + 11 +31.0 + 21 +12.0 + 31 +0.0 + 0 +LINE + 5 +50 +330 +17 +100 +AcDbEntity + 8 +HIDDEN +100 +AcDbLine + 10 +39.0 + 20 +0.0 + 30 +0.0 + 11 +39.0 + 21 +12.0 + 31 +0.0 + 0 +LINE + 5 +51 +330 +17 +100 +AcDbEntity + 8 +CENTER +100 +AcDbLine + 10 +35.0 + 20 +-6.0 + 30 +0.0 + 11 +35.0 + 21 +18.0 + 31 +0.0 + 0 +LINE + 5 +52 +330 +17 +100 +AcDbEntity + 8 +HIDDEN +100 +AcDbLine + 10 +76.0 + 20 +0.0 + 30 +0.0 + 11 +76.0 + 21 +12.0 + 31 +0.0 + 0 +LINE + 5 +53 +330 +17 +100 +AcDbEntity + 8 +HIDDEN +100 +AcDbLine + 10 +84.0 + 20 +0.0 + 30 +0.0 + 11 +84.0 + 21 +12.0 + 31 +0.0 + 0 +LINE + 5 +54 +330 +17 +100 +AcDbEntity + 8 +CENTER +100 +AcDbLine + 10 +80.0 + 20 +-6.0 + 30 +0.0 + 11 +80.0 + 21 +18.0 + 31 +0.0 + 0 +LINE + 5 +55 +330 +17 +100 +AcDbEntity + 8 +CENTER +100 +AcDbLine + 10 +-6.0 + 20 +52.0 + 30 +0.0 + 11 +18.0 + 21 +52.0 + 31 +0.0 + 0 +LINE + 5 +56 +330 +17 +100 +AcDbEntity + 8 +HIDDEN +100 +AcDbLine + 10 +0.0 + 20 +45.0 + 30 +0.0 + 11 +12.0 + 21 +45.0 + 31 +0.0 + 0 +LINE + 5 +57 +330 +17 +100 +AcDbEntity + 8 +HIDDEN +100 +AcDbLine + 10 +0.0 + 20 +59.0 + 30 +0.0 + 11 +12.0 + 21 +59.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +58 +330 +17 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-90.0 + 10 +100.0 + 20 +-90.0 + 10 +100.0 + 20 +-150.0 + 10 +0.0 + 20 +-150.0 + 0 +LINE + 5 +59 +330 +17 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbLine + 10 +12.0 + 20 +-90.0 + 30 +0.0 + 11 +12.0 + 21 +-150.0 + 31 +0.0 + 0 +CIRCLE + 5 +5A +330 +17 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbCircle + 10 +35.0 + 20 +-120.0 + 30 +0.0 + 40 +4.0 + 0 +LINE + 5 +5B +330 +17 +100 +AcDbEntity + 8 +CENTER +100 +AcDbLine + 10 +27.0 + 20 +-120.0 + 30 +0.0 + 11 +43.0 + 21 +-120.0 + 31 +0.0 + 0 +LINE + 5 +5C +330 +17 +100 +AcDbEntity + 8 +CENTER +100 +AcDbLine + 10 +35.0 + 20 +-112.0 + 30 +0.0 + 11 +35.0 + 21 +-128.0 + 31 +0.0 + 0 +CIRCLE + 5 +5D +330 +17 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbCircle + 10 +80.0 + 20 +-120.0 + 30 +0.0 + 40 +4.0 + 0 +LINE + 5 +5E +330 +17 +100 +AcDbEntity + 8 +CENTER +100 +AcDbLine + 10 +72.0 + 20 +-120.0 + 30 +0.0 + 11 +88.0 + 21 +-120.0 + 31 +0.0 + 0 +LINE + 5 +5F +330 +17 +100 +AcDbEntity + 8 +CENTER +100 +AcDbLine + 10 +80.0 + 20 +-112.0 + 30 +0.0 + 11 +80.0 + 21 +-128.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +60 +330 +17 +100 +AcDbEntity + 8 +HIDDEN +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +0.0 + 20 +-110.0 + 10 +12.0 + 20 +-110.0 + 0 +LWPOLYLINE + 5 +61 +330 +17 +100 +AcDbEntity + 8 +HIDDEN +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +0.0 + 20 +-130.0 + 10 +12.0 + 20 +-130.0 + 0 +LWPOLYLINE + 5 +62 +330 +17 +100 +AcDbEntity + 8 +OUTLINE + 62 +8 +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +0.0 + 20 +-110.0 + 10 +0.0 + 20 +-130.0 + 0 +LWPOLYLINE + 5 +63 +330 +17 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbPolyline + 90 +6 + 70 +1 + 10 +150.0 + 20 +0.0 + 10 +181.0 + 20 +0.0 + 10 +181.0 + 20 +12.0 + 10 +162.0 + 20 +12.0 + 10 +162.0 + 20 +72.0 + 10 +150.0 + 20 +72.0 + 0 +LWPOLYLINE + 5 +64 +330 +17 +100 +AcDbEntity + 8 +OUTLINE +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +189.0 + 20 +0.0 + 10 +210.0 + 20 +0.0 + 10 +210.0 + 20 +12.0 + 10 +189.0 + 20 +12.0 + 0 +HATCH + 5 +65 +330 +17 +100 +AcDbEntity + 8 +SECTION + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +6 + 10 +150.0 + 20 +0.0 + 10 +181.0 + 20 +0.0 + 10 +181.0 + 20 +12.0 + 10 +162.0 + 20 +12.0 + 10 +162.0 + 20 +72.0 + 10 +150.0 + 20 +72.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +2.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-4.4901280606 + 46 +4.4901280606 + 79 +0 + 98 +0 + 0 +HATCH + 5 +66 +330 +17 +100 +AcDbEntity + 8 +SECTION + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +189.0 + 20 +0.0 + 10 +210.0 + 20 +0.0 + 10 +210.0 + 20 +12.0 + 10 +189.0 + 20 +12.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +2.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-4.4901280606 + 46 +4.4901280606 + 79 +0 + 98 +0 + 0 +LINE + 5 +67 +330 +17 +100 +AcDbEntity + 8 +CENTER +100 +AcDbLine + 10 +185.0 + 20 +-6.0 + 30 +0.0 + 11 +185.0 + 21 +18.0 + 31 +0.0 + 0 +TEXT + 5 +68 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +150.0 + 20 +-14.0 + 30 +0.0 + 40 +5.0 + 1 +SECTION A-A + 7 +MECH + 11 +150.0 + 21 +-14.0 + 31 +0.0 +100 +AcDbText + 0 +LWPOLYLINE + 5 +69 +330 +17 +100 +AcDbEntity + 8 +CENTER +370 +50 +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +-8.0 + 20 +-120.0 + 10 +108.0 + 20 +-120.0 + 0 +TEXT + 5 +6A +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +-14.0 + 20 +-123.0 + 30 +0.0 + 40 +6.0 + 1 +A + 7 +MECH + 11 +-14.0 + 21 +-123.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +6B +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +112.0 + 20 +-123.0 + 30 +0.0 + 40 +6.0 + 1 +A + 7 +MECH + 11 +112.0 + 21 +-123.0 + 31 +0.0 +100 +AcDbText + 0 +DIMENSION + 5 +6C +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D1 + 3 +MECH + 10 +0.0 + 20 +-12.0 + 30 +0.0 + 11 +50.0 + 21 +-9.2 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +100.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +7F +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D2 + 3 +MECHTOL + 10 +-12.0 + 20 +0.0 + 30 +0.0 + 11 +-14.8 + 21 +36.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +0.0 + 24 +72.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +8E +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D3 + 3 +MECHTOL + 10 +35.0 + 20 +-24.0 + 30 +0.0 + 11 +57.5 + 21 +-21.2 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +35.0 + 23 +0.0 + 33 +0.0 + 14 +80.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +9D +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D4 + 3 +MECH + 10 +20.0 + 20 +20.0 + 30 +0.0 + 11 +7.166011921464162 + 21 +11.125809896108832 + 31 +0.0 + 70 +36 + 71 +5 + 1 +<> +100 +AcDbRadialDimension + 15 +14.343145750507619 + 25 +14.34314575050762 + 35 +0.0 + 0 +DIMENSION + 5 +A8 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D5 + 3 +MECH + 10 +0.0 + 20 +-78.0 + 30 +0.0 + 11 +50.0 + 21 +-75.2 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +-90.0 + 33 +0.0 + 14 +100.0 + 24 +-90.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +B7 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D6 + 3 +MECH + 10 +83.27660817715596 + 20 +-117.70569425459581 + 30 +0.0 + 11 +63.36254596551408 + 21 +-128.23150188303254 + 31 +0.0 + 70 +35 + 71 +5 + 1 +2x ⌀<> +100 +AcDbDiametricDimension + 15 +76.72339182284404 + 25 +-122.29430574540419 + 35 +0.0 + 0 +DIMENSION + 5 +C4 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D7 + 3 +MECH + 10 +220.0 + 20 +0.0 + 30 +0.0 + 11 +217.2 + 21 +6.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +210.0 + 23 +0.0 + 33 +0.0 + 14 +210.0 + 24 +12.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +LEADER + 5 +D3 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbLeader + 3 +Standard + 73 +3 + 76 +3 + 10 +155.0 + 20 +60.0 + 30 +0.0 + 10 +138.0 + 20 +80.0 + 30 +0.0 + 10 +110.0 + 20 +80.0 + 30 +0.0 + 0 +TEXT + 5 +D4 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +110.0 + 20 +82.0 + 30 +0.0 + 40 +3.5 + 1 +slot 14 x 12 + 7 +MECH + 11 +110.0 + 21 +82.0 + 31 +0.0 +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 3 +A4 Sheet +350 +D8 + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +E8 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +LAYOUT + 5 +D8 +330 +D +100 +AcDbPlotSettings + 1 + + 2 +DWG to PDF.pc3 + 4 +ezdxf_(297.00_x_210.00_MM) + 6 + + 40 +5.0 + 41 +5.0 + 42 +5.0 + 43 +5.0 + 44 +297.0 + 45 +210.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +672 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +A4 Sheet + 70 +1 + 71 +3 + 10 +-5.0 + 20 +-5.0 + 11 +292.0 + 21 +205.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +D5 +331 +D9 + 0 +DICTIONARYVAR + 5 +E8 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/dimensions.dxf b/crates/exav-viewer/demo/examples/files/cad/dimensions.dxf new file mode 100644 index 00000000..87ae230a --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/dimensions.dxf @@ -0,0 +1,10562 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1032 + 9 +$ACADMAINTVER + 90 +4 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$REQUIREDVERSIONS +160 +0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +153 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ARC_DIMENSION + 2 +AcDbArcDimension + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +1 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +14F +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +152.28944372309257 + 22 +-33.04999999999998 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +308.80500000000006 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +5 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +OBJECT + 70 +0 + 62 +7 + 6 +Continuous +370 +35 +390 +13 +347 +21 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +DIMS + 70 +0 + 62 +3 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +43 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +NOTE + 70 +0 + 62 +2 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +44 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +DIM-TEXT + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +150 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +151 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +4 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +45 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +METRIC + 70 +0 + 40 +1.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 43 +3.75 + 44 +2.1 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +3.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +1.05 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +3 +177 +3 +178 +2 +179 +2 +271 +1 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +340 +44 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +46 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +TOL + 70 +0 + 40 +1.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 43 +3.75 + 44 +2.1 + 45 +0.0 + 46 +0.0 + 47 +0.05 + 48 +0.02 + 49 +2.5 +140 +3.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +0.7 +147 +1.05 +148 +0.0 + 69 +0 + 70 +0 + 71 +1 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +3 +177 +3 +178 +2 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +1 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +340 +44 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +47 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +ARCH + 70 +0 + 40 +1.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 43 +3.75 + 44 +2.1 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +3.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +1.05 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +1 + 74 +0 + 75 +0 + 76 +0 + 77 +0 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +3 +177 +3 +178 +2 +179 +2 +271 +0 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +340 +44 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +4A +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D1 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +4F +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDFILLED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +5D +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D2 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +6C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D3 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +7B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D4 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8A +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D5 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +9A +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D6 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +A8 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D7 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +B4 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D8 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +C6 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D9 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +D7 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D10 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +EA +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D11 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +F5 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D12 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +100 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D13 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +10B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D14 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +116 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D15 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +121 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D16 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +131 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D17 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +141 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D18 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +4B +330 +4A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D1 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D1 + 1 + + 0 +LINE + 5 +4D +330 +4A +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-2.8000000000000003 + 30 +0.0 + 11 +0.0 + 21 +-14.1 + 31 +0.0 + 0 +LINE + 5 +4E +330 +4A +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +80.0 + 20 +-2.8000000000000003 + 30 +0.0 + 11 +80.0 + 21 +-14.1 + 31 +0.0 + 0 +INSERT + 5 +53 +330 +4A +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-12.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +180.0 + 0 +INSERT + 5 +55 +330 +4A +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +80.0 + 20 +-12.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 0 +LINE + 5 +57 +330 +4A +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +2.8000000000000003 + 20 +-12.0 + 30 +0.0 + 11 +77.2 + 21 +-12.0 + 31 +0.0 + 0 +MTEXT + 5 +58 +330 +4A +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +40.0 + 20 +-9.2 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +80 + 7 +DIM-TEXT + 0 +POINT + 5 +59 +330 +4A +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-12.0 + 30 +0.0 + 0 +POINT + 5 +5A +330 +4A +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +5B +330 +4A +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +80.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +4C +330 +4A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +50 +330 +4F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDFILLED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDFILLED + 1 + + 0 +SOLID + 5 +52 +330 +4F +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbTrace + 10 +-1.0 + 20 +0.16439898372402537 + 30 +0.0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 12 +-1.0 + 22 +-0.16439898372402537 + 32 +0.0 + 13 +-1.0 + 23 +-0.16439898372402537 + 33 +0.0 + 0 +ENDBLK + 5 +51 +330 +4F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +5E +330 +5D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D2 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D2 + 1 + + 0 +LINE + 5 +60 +330 +5D +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-2.8000000000000003 + 20 +0.0 + 30 +0.0 + 11 +-16.1 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +61 +330 +5D +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-2.8000000000000003 + 20 +50.0 + 30 +0.0 + 11 +-16.1 + 21 +50.0 + 31 +0.0 + 0 +INSERT + 5 +62 +330 +5D +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-14.0 + 20 +0.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +270.0 + 0 +INSERT + 5 +64 +330 +5D +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-14.0 + 20 +50.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +90.0 + 0 +LINE + 5 +66 +330 +5D +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-14.0 + 20 +2.8000000000000003 + 30 +0.0 + 11 +-14.0 + 21 +47.2 + 31 +0.0 + 0 +MTEXT + 5 +67 +330 +5D +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +-16.8 + 20 +25.0 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +50 + 7 +DIM-TEXT + 50 +90.0 + 0 +POINT + 5 +68 +330 +5D +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-14.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +69 +330 +5D +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +6A +330 +5D +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +50.0 + 30 +0.0 + 0 +ENDBLK + 5 +5F +330 +5D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +6D +330 +6C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D3 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D3 + 1 + + 0 +LINE + 5 +6F +330 +6C +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +52.8 + 30 +0.0 + 11 +0.0 + 21 +64.1 + 31 +0.0 + 0 +LINE + 5 +70 +330 +6C +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +80.0 + 20 +52.8 + 30 +0.0 + 11 +80.0 + 21 +64.1 + 31 +0.0 + 0 +INSERT + 5 +71 +330 +6C +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +62.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +180.0 + 0 +INSERT + 5 +73 +330 +6C +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +80.0 + 20 +62.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 0 +LINE + 5 +75 +330 +6C +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +2.8000000000000003 + 20 +62.0 + 30 +0.0 + 11 +77.2 + 21 +62.0 + 31 +0.0 + 0 +MTEXT + 5 +76 +330 +6C +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +40.0 + 20 +64.8 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +\A1;80{\H0.70x;\S+0,05^ -0,02;} + 7 +DIM-TEXT + 0 +POINT + 5 +77 +330 +6C +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +62.0 + 30 +0.0 + 0 +POINT + 5 +78 +330 +6C +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +50.0 + 30 +0.0 + 0 +POINT + 5 +79 +330 +6C +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +80.0 + 20 +50.0 + 30 +0.0 + 0 +ENDBLK + 5 +6E +330 +6C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +7C +330 +7B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D4 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D4 + 1 + + 0 +LINE + 5 +7E +330 +7B +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +52.8 + 30 +0.0 + 11 +0.0 + 21 +72.1 + 31 +0.0 + 0 +LINE + 5 +7F +330 +7B +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +80.0 + 20 +52.8 + 30 +0.0 + 11 +80.0 + 21 +72.1 + 31 +0.0 + 0 +INSERT + 5 +80 +330 +7B +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +70.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +180.0 + 0 +INSERT + 5 +82 +330 +7B +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +80.0 + 20 +70.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 0 +LINE + 5 +84 +330 +7B +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +2.8000000000000003 + 20 +70.0 + 30 +0.0 + 11 +77.2 + 21 +70.0 + 31 +0.0 + 0 +MTEXT + 5 +85 +330 +7B +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +40.0 + 20 +74.9 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +80 overall (text override) + 7 +DIM-TEXT + 0 +POINT + 5 +86 +330 +7B +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +70.0 + 30 +0.0 + 0 +POINT + 5 +87 +330 +7B +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +50.0 + 30 +0.0 + 0 +POINT + 5 +88 +330 +7B +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +80.0 + 20 +50.0 + 30 +0.0 + 0 +ENDBLK + 5 +7D +330 +7B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +8B +330 +8A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D5 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D5 + 1 + + 0 +LINE + 5 +8D +330 +8A +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +77.59901980800488 + 20 +1.4405881151970736 + 30 +0.0 + 11 +67.90934974745312 + 21 +7.254390151528121 + 31 +0.0 + 0 +LINE + 5 +8E +330 +8A +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +107.59901980800488 + 20 +51.44058811519707 + 30 +0.0 + 11 +97.90934974745312 + 21 +57.254390151528135 + 31 +0.0 + 0 +INSERT + 5 +8F +330 +8A +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +69.71008489144947 + 20 +6.173949065130316 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +239.03624346792648 + 0 +INSERT + 5 +91 +330 +8A +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +99.71008489144947 + 20 +56.17394906513033 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +59.03624346792648 + 0 +LINE + 5 +93 +330 +8A +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +71.15067300664654 + 20 +8.57492925712544 + 30 +0.0 + 11 +98.2694967762524 + 21 +53.772968873135206 + 31 +0.0 + 0 +MTEXT + 5 +94 +330 +8A +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +82.30910469945435 + 20 +32.614537180327396 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +58,3 + 7 +DIM-TEXT + 50 +59.03624346792648 + 0 +POINT + 5 +95 +330 +8A +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +69.71008489144947 + 20 +6.173949065130316 + 30 +0.0 + 0 +POINT + 5 +96 +330 +8A +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +80.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +97 +330 +8A +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +110.0 + 20 +50.0 + 30 +0.0 + 0 +ENDBLK + 5 +8C +330 +8A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +9B +330 +9A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D6 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D6 + 1 + + 0 +INSERT + 5 +9D +330 +9A +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +130.84888892202554 + 20 +8.930309757836518 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +39.99999999999997 + 0 +INSERT + 5 +9F +330 +9A +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +169.15111107797446 + 20 +41.06969024216348 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +39.99999999999997 + 0 +LINE + 5 +A1 +330 +9A +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +128.7039644812924 + 20 +7.130504450714209 + 30 +0.0 + 11 +167.0061866372413 + 21 +39.26988493504117 + 31 +0.0 + 0 +LINE + 5 +A2 +330 +9A +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +128.7039644812924 + 20 +7.130504450714209 + 30 +0.0 + 11 +119.8561511632682 + 21 +-0.2936924411653168 + 31 +0.0 + 0 +MTEXT + 5 +A3 +330 +9A +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +122.07807918252053 + 20 +5.2258669504221515 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +%%c50 + 7 +DIM-TEXT + 50 +39.99999999999997 + 0 +POINT + 5 +A4 +330 +9A +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +150.0 + 20 +25.0 + 30 +0.0 + 0 +POINT + 5 +A5 +330 +9A +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +130.84888892202554 + 20 +8.930309757836518 + 30 +0.0 + 0 +ENDBLK + 5 +9C +330 +9A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +A9 +330 +A8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D7 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D7 + 1 + + 0 +INSERT + 5 +AB +330 +A8 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +229.0 + 20 +9.411542731880106 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +120.0 + 0 +LINE + 5 +AD +330 +A8 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +220.0 + 20 +25.0 + 30 +0.0 + 11 +229.0 + 21 +9.411542731880106 + 31 +0.0 + 0 +LINE + 5 +AE +330 +A8 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +230.4 + 20 +6.9866716012836765 + 30 +0.0 + 11 +236.175 + 21 +-3.0159218124265887 + 31 +0.0 + 0 +MTEXT + 5 +AF +330 +A8 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +235.97487113059643 + 20 +2.9307115574417146 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +R18 + 7 +DIM-TEXT + 50 +300.0 + 0 +POINT + 5 +B0 +330 +A8 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +25.0 + 30 +0.0 + 0 +POINT + 5 +B1 +330 +A8 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +229.0 + 20 +9.411542731880106 + 30 +0.0 + 0 +ENDBLK + 5 +AA +330 +A8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +B5 +330 +B4 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D8 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D8 + 1 + + 0 +LINE + 5 +B7 +330 +B4 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +330.2188441696369 + 20 +14.63846213433862 + 30 +0.0 + 11 +337.078600301374 + 21 +17.135209180616002 + 31 +0.0 + 0 +LINE + 5 +B8 +330 +B4 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +257.2132978345078 + 20 +27.511309694583893 + 30 +0.0 + 11 +251.62117339973923 + 21 +32.20365924529563 + 31 +0.0 + 0 +INSERT + 5 +B9 +330 +B4 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +335.10524579772357 + 20 +16.4169668796321 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +291.6711269024649 + 0 +INSERT + 5 +BB +330 +B4 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +253.22986673028907 + 20 +30.853805264953902 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +-131.67112690246492 + 0 +MTEXT + 5 +BD +330 +B4 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +298.8213274254801 + 20 +50.028233853020154 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +83,8 + 7 +DIM-TEXT + 50 +-10.000000000000018 + 0 +ARC + 5 +BE +330 +B4 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbCircle + 10 +290.0 + 20 +0.0 + 30 +0.0 + 40 +47.99999999999999 +100 +AcDbArc + 50 +23.342253804929804 + 51 +136.6577461950702 + 0 +POINT + 5 +BF +330 +B4 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +298.33511252801264 + 20 +47.27077214458598 + 30 +0.0 + 0 +POINT + 5 +C0 +330 +B4 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +327.5877048314363 + 20 +13.680805733026748 + 30 +0.0 + 0 +POINT + 5 +C1 +330 +B4 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +259.3582222752409 + 20 +25.71150438746158 + 30 +0.0 + 0 +POINT + 5 +C2 +330 +B4 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +290.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +B6 +330 +B4 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +C7 +330 +C6 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D9 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D9 + 1 + + 0 +LINE + 5 +C9 +330 +C6 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +2.0927460923142474 + 20 +-58.139781251276226 + 30 +0.0 + 11 +24.190270250470654 + 21 +-38.49753755513719 + 31 +0.0 + 0 +LINE + 5 +CA +330 +C6 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +57.2 + 20 +-60.0 + 30 +0.0 + 11 +28.16549190084311 + 21 +-60.0 + 31 +0.0 + 0 +INSERT + 5 +CB +330 +C6 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +22.62071068123497 + 20 +-39.89270161668003 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +314.2838875146575 + 0 +INSERT + 5 +CD +330 +C6 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +30.265491900843113 + 20 +-60.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +87.3496518219127 + 0 +MTEXT + 5 +CF +330 +C6 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +-30.907032852025363 + 20 +-71.75083294615946 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +318,37° + 7 +DIM-TEXT + 50 +290.8167696682851 + 0 +ARC + 5 +D0 +330 +C6 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbCircle + 10 +0.0 + 20 +-60.0 + 30 +0.0 + 40 +30.265491900843113 +100 +AcDbArc + 50 +46.934235692744785 + 51 +-5.300696356174586 + 0 +POINT + 5 +D1 +330 +C6 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +45.0 + 20 +-20.0 + 30 +0.0 + 0 +POINT + 5 +D2 +330 +C6 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-60.0 + 30 +0.0 + 0 +POINT + 5 +D3 +330 +C6 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +60.0 + 20 +-60.0 + 30 +0.0 + 0 +POINT + 5 +D4 +330 +C6 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-60.0 + 30 +0.0 + 0 +POINT + 5 +D5 +330 +C6 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +30.0 + 20 +-56.0 + 30 +0.0 + 0 +ENDBLK + 5 +C8 +330 +C6 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +D8 +330 +D7 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D10 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D10 + 1 + + 0 +LINE + 5 +DA +330 +D7 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +148.61081097260086 + 20 +-47.43108079794849 + 30 +0.0 + 11 +140.05211215395522 + 21 +-62.40880373057834 + 31 +0.0 + 0 +LINE + 5 +DB +330 +D7 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +167.2 + 20 +-80.0 + 30 +0.0 + 11 +150.2606797749979 + 21 +-80.0 + 31 +0.0 + 0 +INSERT + 5 +DC +330 +D7 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +141.0940039245046 + 20 +-60.58549313211698 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +333.8424019218803 + 0 +INSERT + 5 +DE +330 +D7 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +152.3606797749979 + 20 +-80.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +86.41271678117742 + 0 +MTEXT + 5 +E0 +330 +D7 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +108.23827411287941 + 20 +-92.62881994304354 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +299,74° + 7 +DIM-TEXT + 50 +300.12755935152893 + 0 +ARC + 5 +E1 +330 +D7 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbCircle + 10 +130.0 + 20 +-80.0 + 30 +0.0 + 40 +22.360679774997898 +100 +AcDbArc + 50 +67.42968514070293 + 51 +-7.1745664376451455 + 0 +POINT + 5 +E2 +330 +D7 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +140.0 + 20 +-60.0 + 30 +0.0 + 0 +POINT + 5 +E3 +330 +D7 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +150.0 + 20 +-45.0 + 30 +0.0 + 0 +POINT + 5 +E4 +330 +D7 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +170.0 + 20 +-80.0 + 30 +0.0 + 0 +POINT + 5 +E5 +330 +D7 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +130.0 + 20 +-80.0 + 30 +0.0 + 0 +ENDBLK + 5 +D9 +330 +D7 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +EB +330 +EA +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D11 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D11 + 1 + + 0 +LINE + 5 +ED +330 +EA +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +220.0 + 20 +-92.8 + 30 +0.0 + 11 +220.0 + 21 +-95.6 + 31 +0.0 + 0 +LINE + 5 +EE +330 +EA +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +220.0 + 20 +-95.6 + 30 +0.0 + 11 +220.0 + 21 +-96.39999999999999 + 31 +0.0 + 0 +LINE + 5 +EF +330 +EA +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +220.0 + 20 +-96.39999999999999 + 30 +0.0 + 11 +220.0 + 21 +-105.49999999999999 + 31 +0.0 + 0 +MTEXT + 5 +F0 +330 +EA +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +217.2 + 20 +-103.74999999999999 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +0 + 7 +DIM-TEXT + 50 +90.0 + 0 +POINT + 5 +F1 +330 +EA +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +F2 +330 +EA +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +F3 +330 +EA +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +-101.99999999999999 + 30 +0.0 + 0 +ENDBLK + 5 +EC +330 +EA +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +F6 +330 +F5 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D12 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D12 + 1 + + 0 +LINE + 5 +F8 +330 +F5 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +260.0 + 20 +-92.8 + 30 +0.0 + 11 +260.0 + 21 +-95.6 + 31 +0.0 + 0 +LINE + 5 +F9 +330 +F5 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +260.0 + 20 +-95.6 + 30 +0.0 + 11 +259.99999999999994 + 21 +-96.39999999999999 + 31 +0.0 + 0 +LINE + 5 +FA +330 +F5 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +259.99999999999994 + 20 +-96.39999999999999 + 30 +0.0 + 11 +259.99999999999994 + 21 +-108.99999999999999 + 31 +0.0 + 0 +MTEXT + 5 +FB +330 +F5 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +257.19999999999993 + 20 +-105.49999999999999 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +40 + 7 +DIM-TEXT + 50 +90.0 + 0 +POINT + 5 +FC +330 +F5 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +FD +330 +F5 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +260.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +FE +330 +F5 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +259.99999999999994 + 20 +-101.99999999999999 + 30 +0.0 + 0 +ENDBLK + 5 +F7 +330 +F5 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +101 +330 +100 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D13 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D13 + 1 + + 0 +LINE + 5 +103 +330 +100 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +320.0 + 20 +-92.8 + 30 +0.0 + 11 +320.0 + 21 +-95.6 + 31 +0.0 + 0 +LINE + 5 +104 +330 +100 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +320.0 + 20 +-95.6 + 30 +0.0 + 11 +320.0 + 21 +-96.4 + 31 +0.0 + 0 +LINE + 5 +105 +330 +100 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +320.0 + 20 +-96.4 + 30 +0.0 + 11 +320.0 + 21 +-112.5 + 31 +0.0 + 0 +MTEXT + 5 +106 +330 +100 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +317.2 + 20 +-107.25 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +100 + 7 +DIM-TEXT + 50 +90.0 + 0 +POINT + 5 +107 +330 +100 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +108 +330 +100 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +320.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +109 +330 +100 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +320.0 + 20 +-102.0 + 30 +0.0 + 0 +ENDBLK + 5 +102 +330 +100 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +10C +330 +10B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D14 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D14 + 1 + + 0 +LINE + 5 +10E +330 +10B +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +322.8 + 20 +-90.0 + 30 +0.0 + 11 +325.6 + 21 +-90.0 + 31 +0.0 + 0 +LINE + 5 +10F +330 +10B +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +325.6 + 20 +-90.0 + 30 +0.0 + 11 +328.4 + 21 +-90.0 + 31 +0.0 + 0 +LINE + 5 +110 +330 +10B +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +328.4 + 20 +-90.0 + 30 +0.0 + 11 +337.5 + 21 +-90.0 + 31 +0.0 + 0 +MTEXT + 5 +111 +330 +10B +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +335.75 + 20 +-87.2 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +0 + 7 +DIM-TEXT + 0 +POINT + 5 +112 +330 +10B +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +113 +330 +10B +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +320.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +114 +330 +10B +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +334.0 + 20 +-90.0 + 30 +0.0 + 0 +ENDBLK + 5 +10D +330 +10B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +117 +330 +116 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D15 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D15 + 1 + + 0 +LINE + 5 +119 +330 +116 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +322.8 + 20 +-60.0 + 30 +0.0 + 11 +325.6 + 21 +-60.0 + 31 +0.0 + 0 +LINE + 5 +11A +330 +116 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +325.6 + 20 +-60.0 + 30 +0.0 + 11 +328.4 + 21 +-59.999999999999986 + 31 +0.0 + 0 +LINE + 5 +11B +330 +116 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +328.4 + 20 +-59.999999999999986 + 30 +0.0 + 11 +341.0 + 21 +-59.999999999999986 + 31 +0.0 + 0 +MTEXT + 5 +11C +330 +116 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +337.5 + 20 +-57.19999999999999 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +30 + 7 +DIM-TEXT + 0 +POINT + 5 +11D +330 +116 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +11E +330 +116 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +320.0 + 20 +-60.0 + 30 +0.0 + 0 +POINT + 5 +11F +330 +116 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +334.0 + 20 +-59.999999999999986 + 30 +0.0 + 0 +ENDBLK + 5 +118 +330 +116 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +122 +330 +121 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D16 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D16 + 1 + + 0 +LINE + 5 +124 +330 +121 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +322.8 + 20 +-40.0 + 30 +0.0 + 11 +325.6 + 21 +-40.0 + 31 +0.0 + 0 +LINE + 5 +125 +330 +121 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +325.6 + 20 +-40.0 + 30 +0.0 + 11 +328.4 + 21 +-40.00000000000001 + 31 +0.0 + 0 +LINE + 5 +126 +330 +121 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +328.4 + 20 +-40.00000000000001 + 30 +0.0 + 11 +341.0 + 21 +-40.00000000000001 + 31 +0.0 + 0 +MTEXT + 5 +127 +330 +121 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +337.5 + 20 +-37.20000000000001 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +50 + 7 +DIM-TEXT + 0 +POINT + 5 +128 +330 +121 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 0 +POINT + 5 +129 +330 +121 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +320.0 + 20 +-40.0 + 30 +0.0 + 0 +POINT + 5 +12A +330 +121 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +334.0 + 20 +-40.00000000000001 + 30 +0.0 + 0 +ENDBLK + 5 +123 +330 +121 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +132 +330 +131 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D17 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D17 + 1 + + 0 +LINE + 5 +134 +330 +131 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-162.8 + 30 +0.0 + 11 +0.0 + 21 +-170.1 + 31 +0.0 + 0 +LINE + 5 +135 +330 +131 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +60.0 + 20 +-162.8 + 30 +0.0 + 11 +60.0 + 21 +-170.1 + 31 +0.0 + 0 +INSERT + 5 +136 +330 +131 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-168.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +180.0 + 0 +INSERT + 5 +138 +330 +131 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +60.0 + 20 +-168.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 0 +LINE + 5 +13A +330 +131 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +2.8000000000000003 + 20 +-168.0 + 30 +0.0 + 11 +26.5 + 21 +-168.0 + 31 +0.0 + 0 +LINE + 5 +13B +330 +131 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +33.49999999999999 + 20 +-168.0 + 30 +0.0 + 11 +57.2 + 21 +-168.0 + 31 +0.0 + 0 +MTEXT + 5 +13C +330 +131 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +30.0 + 20 +-168.0 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +60 + 7 +DIM-TEXT + 0 +POINT + 5 +13D +330 +131 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-168.0 + 30 +0.0 + 0 +POINT + 5 +13E +330 +131 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-160.0 + 30 +0.0 + 0 +POINT + 5 +13F +330 +131 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +60.0 + 20 +-160.0 + 30 +0.0 + 0 +ENDBLK + 5 +133 +330 +131 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +142 +330 +141 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D18 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D18 + 1 + + 0 +LINE + 5 +144 +330 +141 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-162.8 + 30 +0.0 + 11 +0.0 + 21 +-180.1 + 31 +0.0 + 0 +LINE + 5 +145 +330 +141 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +60.0 + 20 +-162.8 + 30 +0.0 + 11 +60.0 + 21 +-180.1 + 31 +0.0 + 0 +INSERT + 5 +146 +330 +141 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-178.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 50 +180.0 + 0 +INSERT + 5 +148 +330 +141 +100 +AcDbEntity + 8 +DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +60.0 + 20 +-178.0 + 30 +0.0 + 41 +2.8000000000000003 + 42 +2.8000000000000003 + 0 +LINE + 5 +14A +330 +141 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +2.8000000000000003 + 20 +-178.0 + 30 +0.0 + 11 +57.2 + 21 +-178.0 + 31 +0.0 + 0 +MTEXT + 5 +14B +330 +141 +100 +AcDbEntity + 8 +DIMS + 62 +2 +100 +AcDbMText + 10 +30.0 + 20 +-175.2 + 30 +0.0 + 40 +3.5 + 71 +5 + 1 +\A1;60{\H0.70x;\S+0,05^ -0,02;} + 7 +DIM-TEXT + 0 +POINT + 5 +14C +330 +141 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-178.0 + 30 +0.0 + 0 +POINT + 5 +14D +330 +141 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-160.0 + 30 +0.0 + 0 +POINT + 5 +14E +330 +141 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +60.0 + 20 +-160.0 + 30 +0.0 + 0 +ENDBLK + 5 +143 +330 +141 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LWPOLYLINE + 5 +48 +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +80.0 + 20 +0.0 + 10 +80.0 + 20 +50.0 + 10 +0.0 + 20 +50.0 + 0 +DIMENSION + 5 +49 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D1 + 3 +METRIC + 10 +0.0 + 20 +-12.0 + 30 +0.0 + 11 +40.0 + 21 +-9.2 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +80.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +5C +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D2 + 3 +METRIC + 10 +-14.0 + 20 +0.0 + 30 +0.0 + 11 +-16.8 + 21 +25.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +0.0 + 24 +50.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +6B +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D3 + 3 +TOL + 10 +0.0 + 20 +62.0 + 30 +0.0 + 11 +40.0 + 21 +64.8 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +50.0 + 33 +0.0 + 14 +80.0 + 24 +50.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +7A +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D4 + 3 +METRIC + 10 +0.0 + 20 +70.0 + 30 +0.0 + 11 +40.0 + 21 +74.9 + 31 +0.0 + 70 +32 + 71 +5 + 1 +80 overall (text override) +100 +AcDbAlignedDimension + 13 +0.0 + 23 +50.0 + 33 +0.0 + 14 +80.0 + 24 +50.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +89 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D5 + 3 +METRIC + 10 +69.71008489144947 + 20 +6.173949065130316 + 30 +0.0 + 11 +82.30910469945435 + 21 +32.614537180327396 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +80.0 + 23 +0.0 + 33 +0.0 + 14 +110.0 + 24 +50.0 + 34 +0.0 + 50 +59.03624346792648 +100 +AcDbRotatedDimension + 0 +CIRCLE + 5 +98 +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbCircle + 10 +150.0 + 20 +25.0 + 30 +0.0 + 40 +25.0 + 0 +DIMENSION + 5 +99 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D6 + 3 +METRIC + 10 +169.15111107797446 + 20 +41.06969024216348 + 30 +0.0 + 11 +122.07807918252053 + 21 +5.2258669504221515 + 31 +0.0 + 70 +35 + 71 +5 + 1 +<> +100 +AcDbDiametricDimension + 15 +130.84888892202554 + 25 +8.930309757836518 + 35 +0.0 + 0 +CIRCLE + 5 +A6 +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbCircle + 10 +220.0 + 20 +25.0 + 30 +0.0 + 40 +18.0 + 0 +DIMENSION + 5 +A7 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D7 + 3 +METRIC + 10 +220.0 + 20 +25.0 + 30 +0.0 + 11 +235.97487113059643 + 21 +2.9307115574417146 + 31 +0.0 + 70 +36 + 71 +5 + 1 +<> +100 +AcDbRadialDimension + 15 +229.0 + 25 +9.411542731880106 + 35 +0.0 + 0 +ARC + 5 +B2 +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbCircle + 10 +290.0 + 20 +0.0 + 30 +0.0 + 40 +40.0 +100 +AcDbArc + 50 +20.0 + 51 +140.0 + 0 +ARC_DIMENSION + 5 +B3 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D8 + 3 +METRIC + 10 +298.33511252801264 + 20 +47.27077214458598 + 30 +0.0 + 11 +298.8213274254801 + 21 +50.028233853020154 + 31 +0.0 + 70 +40 + 71 +5 + 1 +<> +100 +AcDbArcDimension + 13 +327.5877048314363 + 23 +13.680805733026748 + 33 +0.0 + 14 +259.3582222752409 + 24 +25.71150438746158 + 34 +0.0 + 15 +290.0 + 25 +0.0 + 35 +0.0 + 40 +0.0 + 41 +0.0 + 70 +0 + 71 +0 + 16 +0.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 0 +LINE + 5 +C3 +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbLine + 10 +0.0 + 20 +-60.0 + 30 +0.0 + 11 +60.0 + 21 +-60.0 + 31 +0.0 + 0 +LINE + 5 +C4 +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbLine + 10 +0.0 + 20 +-60.0 + 30 +0.0 + 11 +45.0 + 21 +-20.0 + 31 +0.0 + 0 +DIMENSION + 5 +C5 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D9 + 3 +METRIC + 10 +45.0 + 20 +-20.0 + 30 +0.0 + 11 +-30.907032852025363 + 21 +-71.75083294615946 + 31 +0.0 + 70 +34 + 71 +5 + 1 +<> +100 +AcDb2LineAngularDimension + 13 +0.0 + 23 +-60.0 + 33 +0.0 + 14 +60.0 + 24 +-60.0 + 34 +0.0 + 15 +0.0 + 25 +-60.0 + 35 +0.0 + 16 +30.0 + 26 +-56.0 + 36 +0.0 + 0 +DIMENSION + 5 +D6 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D10 + 3 +METRIC + 10 +140.0 + 20 +-60.0 + 30 +0.0 + 11 +108.23827411287941 + 21 +-92.62881994304354 + 31 +0.0 + 70 +37 + 71 +5 + 1 +<> +100 +AcDb3PointAngularDimension + 13 +150.0 + 23 +-45.0 + 33 +0.0 + 14 +170.0 + 24 +-80.0 + 34 +0.0 + 15 +130.0 + 25 +-80.0 + 35 +0.0 + 16 +0.0 + 26 +0.0 + 36 +0.0 + 0 +LINE + 5 +E6 +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbLine + 10 +130.0 + 20 +-80.0 + 30 +0.0 + 11 +170.0 + 21 +-80.0 + 31 +0.0 + 0 +LINE + 5 +E7 +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbLine + 10 +130.0 + 20 +-80.0 + 30 +0.0 + 11 +150.0 + 21 +-45.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +E8 +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbPolyline + 90 +6 + 70 +1 + 10 +220.0 + 20 +-90.0 + 10 +320.0 + 20 +-90.0 + 10 +320.0 + 20 +-40.0 + 10 +260.0 + 20 +-40.0 + 10 +260.0 + 20 +-60.0 + 10 +220.0 + 20 +-60.0 + 0 +DIMENSION + 5 +E9 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D11 + 3 +METRIC + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 11 +217.2 + 21 +-103.74999999999999 + 31 +0.0 + 70 +102 + 71 +5 + 1 +<> +100 +AcDbOrdinateDimension + 13 +220.0 + 23 +-90.0 + 33 +0.0 + 14 +220.0 + 24 +-101.99999999999999 + 34 +0.0 + 0 +DIMENSION + 5 +F4 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D12 + 3 +METRIC + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 11 +257.19999999999993 + 21 +-105.49999999999999 + 31 +0.0 + 70 +102 + 71 +5 + 1 +<> +100 +AcDbOrdinateDimension + 13 +260.0 + 23 +-90.0 + 33 +0.0 + 14 +259.99999999999994 + 24 +-101.99999999999999 + 34 +0.0 + 0 +DIMENSION + 5 +FF +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D13 + 3 +METRIC + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 11 +317.2 + 21 +-107.25 + 31 +0.0 + 70 +102 + 71 +5 + 1 +<> +100 +AcDbOrdinateDimension + 13 +320.0 + 23 +-90.0 + 33 +0.0 + 14 +320.0 + 24 +-102.0 + 34 +0.0 + 0 +DIMENSION + 5 +10A +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D14 + 3 +METRIC + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 11 +335.75 + 21 +-87.2 + 31 +0.0 + 70 +38 + 71 +5 + 1 +<> +100 +AcDbOrdinateDimension + 13 +320.0 + 23 +-90.0 + 33 +0.0 + 14 +334.0 + 24 +-90.0 + 34 +0.0 + 0 +DIMENSION + 5 +115 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D15 + 3 +METRIC + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 11 +337.5 + 21 +-57.19999999999999 + 31 +0.0 + 70 +38 + 71 +5 + 1 +<> +100 +AcDbOrdinateDimension + 13 +320.0 + 23 +-60.0 + 33 +0.0 + 14 +334.0 + 24 +-59.999999999999986 + 34 +0.0 + 0 +DIMENSION + 5 +120 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D16 + 3 +METRIC + 10 +220.0 + 20 +-90.0 + 30 +0.0 + 11 +337.5 + 21 +-37.20000000000001 + 31 +0.0 + 70 +38 + 71 +5 + 1 +<> +100 +AcDbOrdinateDimension + 13 +320.0 + 23 +-40.0 + 33 +0.0 + 14 +334.0 + 24 +-40.00000000000001 + 34 +0.0 + 0 +MULTILEADER + 5 +12B +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbMLeader +270 +2 +300 +CONTEXT_DATA{ + 40 +1.0 + 10 +98.0 + 20 +83.25 + 30 +0.0 + 41 +3.5 +140 +4.0 +145 +2.0 +174 +1 +175 +1 +176 +0 +177 +0 +290 +1 +304 +Chamfer 2 x 45° + 11 +0.0 + 21 +0.0 + 31 +1.0 +340 +29 + 12 +100.0 + 22 +85.0 + 32 +0.0 + 13 +1.0 + 23 +0.0 + 33 +0.0 + 42 +0.0 + 43 +0.0 + 44 +0.0 + 45 +1.0 +170 +1 + 90 +-1056964608 +171 +1 +172 +1 + 91 +-939524096 +141 +1.5 + 92 +0 +291 +0 +292 +0 +173 +0 +293 +0 +142 +0.0 +143 +0.0 +294 +0 +295 +1 +296 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 +297 +0 +302 +LEADER{ +290 +1 +291 +1 + 10 +90.0 + 20 +83.25 + 30 +0.0 + 11 +1.0 + 21 +0.0 + 31 +0.0 + 90 +0 + 40 +8.0 +304 +LEADER_LINE{ + 10 +75.0 + 20 +45.0 + 30 +0.0 + 91 +0 + 92 +-1056964608 +305 +} +271 +0 +303 +} +272 +9 +273 +9 +301 +} +340 +2C + 90 +2147483647 +170 +1 + 91 +-1056964608 +341 +25 +171 +-2 +290 +1 +291 +1 + 41 +8.0 + 42 +4.0 +172 +2 +343 +29 +173 +1 + 95 +1 +174 +1 +175 +0 + 92 +-1056964608 +292 +0 + 93 +-1056964608 + 10 +1.0 + 20 +1.0 + 30 +1.0 + 43 +0.0 +176 +0 +293 +0 +294 +0 +178 +0 +179 +1 + 45 +1.0 +271 +0 +272 +9 +273 +9 +295 +0 + 0 +LEADER + 5 +12C +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbLeader + 3 +METRIC + 73 +3 + 76 +3 + 10 +15.0 + 20 +15.0 + 30 +0.0 + 10 +30.0 + 20 +30.0 + 30 +0.0 + 10 +40.0 + 20 +30.0 + 30 +0.0 + 0 +TEXT + 5 +12D +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +41.0 + 20 +29.0 + 30 +0.0 + 40 +3.5 + 1 +hole 5 + 7 +DIM-TEXT + 11 +41.0 + 21 +29.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +12E +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +0.0 + 20 +110.0 + 30 +0.0 + 40 +4.0 + 1 +METRIC style (left), TOL style with tolerances, ARCH (text horizontal) + 7 +DIM-TEXT + 11 +0.0 + 21 +110.0 + 31 +0.0 +100 +AcDbText + 0 +LWPOLYLINE + 5 +12F +330 +17 +100 +AcDbEntity + 8 +OBJECT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-160.0 + 10 +60.0 + 20 +-160.0 + 10 +60.0 + 20 +-130.0 + 10 +0.0 + 20 +-130.0 + 0 +DIMENSION + 5 +130 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D17 + 3 +ARCH + 10 +0.0 + 20 +-168.0 + 30 +0.0 + 11 +30.0 + 21 +-168.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +-160.0 + 33 +0.0 + 14 +60.0 + 24 +-160.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +140 +330 +17 +100 +AcDbEntity + 8 +DIMS +100 +AcDbDimension +280 +0 + 2 +*D18 + 3 +TOL + 10 +0.0 + 20 +-178.0 + 30 +0.0 + 11 +30.0 + 21 +-175.2 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +-160.0 + 33 +0.0 + 14 +60.0 + 24 +-160.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +152 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +DICTIONARYVAR + 5 +152 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/hatch-gallery.dxf b/crates/exav-viewer/demo/examples/files/cad/hatch-gallery.dxf new file mode 100644 index 00000000..bb8bfeb8 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/hatch-gallery.dxf @@ -0,0 +1,26910 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1032 + 9 +$ACADMAINTVER + 90 +4 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$REQUIREDVERSIONS +160 +0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +13E + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +13A +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +630.0 + 22 +-965.7839919624917 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +2238.1463831212327 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +7 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PATTERNS + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +GRADIENTS + 70 +0 + 62 +5 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +43 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +ISLANDS + 70 +0 + 62 +1 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +44 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +EDGES + 70 +0 + 62 +3 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +45 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LABELS + 70 +0 + 62 +2 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +46 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LABEL + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +13B +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +13C +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +HATCH + 5 +47 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +0.0 + 10 +100.0 + 20 +0.0 + 10 +100.0 + 20 +100.0 + 10 +0.0 + 20 +100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-2.2450640303 + 46 +2.2450640303 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +48 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +100.0 + 20 +0.0 + 10 +100.0 + 20 +100.0 + 10 +0.0 + 20 +100.0 + 0 +TEXT + 5 +49 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-14.0 + 30 +0.0 + 40 +9.0 + 1 +ANSI31 + 7 +LABEL + 11 +0.0 + 21 +-14.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +4A +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI32 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +0.0 + 10 +240.0 + 20 +0.0 + 10 +240.0 + 20 +100.0 + 10 +140.0 + 20 +100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-6.7351920908 + 46 +6.7351920908 + 79 +0 + 53 +45.0 + 43 +4.490128053 + 44 +0.0 + 45 +-6.7351920908 + 46 +6.7351920908 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +4B +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +140.0 + 20 +0.0 + 10 +240.0 + 20 +0.0 + 10 +240.0 + 20 +100.0 + 10 +140.0 + 20 +100.0 + 0 +TEXT + 5 +4C +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-14.0 + 30 +0.0 + 40 +9.0 + 1 +ANSI32 + 7 +LABEL + 11 +140.0 + 21 +-14.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +4D +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI33 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +0.0 + 10 +380.0 + 20 +0.0 + 10 +380.0 + 20 +100.0 + 10 +280.0 + 20 +100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-4.4901280605 + 46 +4.4901280605 + 79 +0 + 53 +45.0 + 43 +4.490128053 + 44 +0.0 + 45 +-4.4901280605 + 46 +4.4901280605 + 79 +2 + 49 +3.175 + 49 +-1.5875 + 98 +0 + 0 +LWPOLYLINE + 5 +4E +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +280.0 + 20 +0.0 + 10 +380.0 + 20 +0.0 + 10 +380.0 + 20 +100.0 + 10 +280.0 + 20 +100.0 + 0 +TEXT + 5 +4F +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-14.0 + 30 +0.0 + 40 +9.0 + 1 +ANSI33 + 7 +LABEL + 11 +280.0 + 21 +-14.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +50 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI34 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +0.0 + 10 +520.0 + 20 +0.0 + 10 +520.0 + 20 +100.0 + 10 +420.0 + 20 +100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +4 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-13.4703841816 + 46 +13.4703841816 + 79 +0 + 53 +45.0 + 43 +4.490128053 + 44 +0.0 + 45 +-13.4703841816 + 46 +13.4703841816 + 79 +0 + 53 +45.0 + 43 +8.9802561314 + 44 +0.0 + 45 +-13.4703841816 + 46 +13.4703841816 + 79 +0 + 53 +45.0 + 43 +13.4703841844 + 44 +0.0 + 45 +-13.4703841816 + 46 +13.4703841816 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +51 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +420.0 + 20 +0.0 + 10 +520.0 + 20 +0.0 + 10 +520.0 + 20 +100.0 + 10 +420.0 + 20 +100.0 + 0 +TEXT + 5 +52 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-14.0 + 30 +0.0 + 40 +9.0 + 1 +ANSI34 + 7 +LABEL + 11 +420.0 + 21 +-14.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +53 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI35 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +0.0 + 10 +660.0 + 20 +0.0 + 10 +660.0 + 20 +100.0 + 10 +560.0 + 20 +100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-4.4901280605 + 46 +4.4901280605 + 79 +0 + 53 +45.0 + 43 +4.490128053 + 44 +0.0 + 45 +-4.4901280605 + 46 +4.4901280605 + 79 +4 + 49 +7.9375 + 49 +-1.5875 + 49 +0.0 + 49 +-1.5875 + 98 +0 + 0 +LWPOLYLINE + 5 +54 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +560.0 + 20 +0.0 + 10 +660.0 + 20 +0.0 + 10 +660.0 + 20 +100.0 + 10 +560.0 + 20 +100.0 + 0 +TEXT + 5 +55 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-14.0 + 30 +0.0 + 40 +9.0 + 1 +ANSI35 + 7 +LABEL + 11 +560.0 + 21 +-14.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +56 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI36 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +0.0 + 10 +800.0 + 20 +0.0 + 10 +800.0 + 20 +100.0 + 10 +700.0 + 20 +100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +1.6837980227 + 46 +6.1739260832 + 79 +4 + 49 +7.9375 + 49 +-1.5875 + 49 +0.0 + 49 +-1.5875 + 98 +0 + 0 +LWPOLYLINE + 5 +57 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +700.0 + 20 +0.0 + 10 +800.0 + 20 +0.0 + 10 +800.0 + 20 +100.0 + 10 +700.0 + 20 +100.0 + 0 +TEXT + 5 +58 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-14.0 + 30 +0.0 + 40 +9.0 + 1 +ANSI36 + 7 +LABEL + 11 +700.0 + 21 +-14.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +59 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI37 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +0.0 + 10 +940.0 + 20 +0.0 + 10 +940.0 + 20 +100.0 + 10 +840.0 + 20 +100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-2.2450640303 + 46 +2.2450640303 + 79 +0 + 53 +135.0 + 43 +0.0 + 44 +0.0 + 45 +-2.2450640303 + 46 +-2.2450640303 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +5A +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +0.0 + 10 +940.0 + 20 +0.0 + 10 +940.0 + 20 +100.0 + 10 +840.0 + 20 +100.0 + 0 +TEXT + 5 +5B +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-14.0 + 30 +0.0 + 40 +9.0 + 1 +ANSI37 + 7 +LABEL + 11 +840.0 + 21 +-14.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +5C +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI38 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +980.0 + 20 +0.0 + 10 +1080.0 + 20 +0.0 + 10 +1080.0 + 20 +100.0 + 10 +980.0 + 20 +100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-2.2450640303 + 46 +2.2450640303 + 79 +0 + 53 +135.0 + 43 +0.0 + 44 +0.0 + 45 +-6.7351920908 + 46 +2.2450640303 + 79 +2 + 49 +7.9375 + 49 +-4.7625 + 98 +0 + 0 +LWPOLYLINE + 5 +5D +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +980.0 + 20 +0.0 + 10 +1080.0 + 20 +0.0 + 10 +1080.0 + 20 +100.0 + 10 +980.0 + 20 +100.0 + 0 +TEXT + 5 +5E +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +980.0 + 20 +-14.0 + 30 +0.0 + 40 +9.0 + 1 +ANSI38 + 7 +LABEL + 11 +980.0 + 21 +-14.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +5F +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-B816 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-165.0 + 10 +100.0 + 20 +-165.0 + 10 +100.0 + 20 +-65.0 + 10 +0.0 + 20 +-65.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +203.2 + 79 +0 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-203.2 + 46 +203.2 + 79 +2 + 49 +203.2 + 49 +-203.2 + 98 +0 + 0 +LWPOLYLINE + 5 +60 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-165.0 + 10 +100.0 + 20 +-165.0 + 10 +100.0 + 20 +-65.0 + 10 +0.0 + 20 +-65.0 + 0 +TEXT + 5 +61 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-179.0 + 30 +0.0 + 40 +9.0 + 1 +AR-B816 + 7 +LABEL + 11 +0.0 + 21 +-179.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +62 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-B816C + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +-165.0 + 10 +240.0 + 20 +-165.0 + 10 +240.0 + 20 +-65.0 + 10 +140.0 + 20 +-65.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +4 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +203.2 + 46 +203.2 + 79 +2 + 49 +396.875 + 49 +-9.525 + 53 +0.0 + 43 +-203.2 + 44 +9.525 + 45 +203.2 + 46 +203.2 + 79 +2 + 49 +396.875 + 49 +-9.525 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-203.2 + 46 +203.2 + 79 +2 + 49 +-212.725 + 49 +193.675 + 53 +90.0 + 43 +-9.525 + 44 +0.0 + 45 +-203.2 + 46 +203.2 + 79 +2 + 49 +-212.725 + 49 +193.675 + 98 +0 + 0 +LWPOLYLINE + 5 +63 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +140.0 + 20 +-165.0 + 10 +240.0 + 20 +-165.0 + 10 +240.0 + 20 +-65.0 + 10 +140.0 + 20 +-65.0 + 0 +TEXT + 5 +64 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-179.0 + 30 +0.0 + 40 +9.0 + 1 +AR-B816C + 7 +LABEL + 11 +140.0 + 21 +-179.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +65 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-B88 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +-165.0 + 10 +380.0 + 20 +-165.0 + 10 +380.0 + 20 +-65.0 + 10 +280.0 + 20 +-65.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +203.2 + 79 +0 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-101.6 + 46 +203.2 + 79 +2 + 49 +203.2 + 49 +-203.2 + 98 +0 + 0 +LWPOLYLINE + 5 +66 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +280.0 + 20 +-165.0 + 10 +380.0 + 20 +-165.0 + 10 +380.0 + 20 +-65.0 + 10 +280.0 + 20 +-65.0 + 0 +TEXT + 5 +67 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-179.0 + 30 +0.0 + 40 +9.0 + 1 +AR-B88 + 7 +LABEL + 11 +280.0 + 21 +-179.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +68 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-BRELM + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +-165.0 + 10 +520.0 + 20 +-165.0 + 10 +520.0 + 20 +-65.0 + 10 +420.0 + 20 +-65.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +8 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +135.484 + 79 +2 + 49 +193.675 + 49 +-9.525 + 53 +0.0 + 43 +0.0 + 44 +57.15 + 45 +0.0 + 46 +135.484 + 79 +2 + 49 +193.675 + 49 +-9.525 + 53 +0.0 + 43 +50.8 + 44 +67.7418 + 45 +0.0 + 46 +135.484 + 79 +2 + 49 +92.075 + 49 +-9.525 + 53 +0.0 + 43 +50.8 + 44 +124.892 + 45 +0.0 + 46 +135.484 + 79 +2 + 49 +92.075 + 49 +-9.525 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-203.2 + 46 +0.0 + 79 +2 + 49 +57.15 + 49 +-78.334 + 53 +90.0 + 43 +-9.525 + 44 +0.0 + 45 +-203.2 + 46 +0.0 + 79 +2 + 49 +57.15 + 49 +-78.334 + 53 +90.0 + 43 +50.8 + 44 +67.7418 + 45 +-101.6 + 46 +0.0 + 79 +2 + 49 +57.15 + 49 +-78.334 + 53 +90.0 + 43 +41.275 + 44 +67.7418 + 45 +-101.6 + 46 +0.0 + 79 +2 + 49 +57.15 + 49 +-78.334 + 98 +0 + 0 +LWPOLYLINE + 5 +69 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +420.0 + 20 +-165.0 + 10 +520.0 + 20 +-165.0 + 10 +520.0 + 20 +-65.0 + 10 +420.0 + 20 +-65.0 + 0 +TEXT + 5 +6A +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-179.0 + 30 +0.0 + 40 +9.0 + 1 +AR-BRELM + 7 +LABEL + 11 +420.0 + 21 +-179.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +6B +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-BRSTD + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +-165.0 + 10 +660.0 + 20 +-165.0 + 10 +660.0 + 20 +-65.0 + 10 +560.0 + 20 +-65.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +67.7418 + 79 +0 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-101.6 + 46 +67.7418 + 79 +2 + 49 +67.7418 + 49 +-67.7418 + 98 +0 + 0 +LWPOLYLINE + 5 +6C +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +560.0 + 20 +-165.0 + 10 +660.0 + 20 +-165.0 + 10 +660.0 + 20 +-65.0 + 10 +560.0 + 20 +-65.0 + 0 +TEXT + 5 +6D +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-179.0 + 30 +0.0 + 40 +9.0 + 1 +AR-BRSTD + 7 +LABEL + 11 +560.0 + 21 +-179.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +6E +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-CONC + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +-165.0 + 10 +800.0 + 20 +-165.0 + 10 +800.0 + 20 +-65.0 + 10 +700.0 + 20 +-65.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +13 + 53 +50.0 + 43 +0.0 + 44 +0.0 + 45 +182.184668996 + 46 +-15.9390855389 + 79 +2 + 49 +19.05 + 49 +-209.55 + 53 +355.0 + 43 +0.0 + 44 +0.0 + 45 +-35.243421425 + 46 +191.056845239 + 79 +2 + 49 +15.24 + 49 +-167.64058417 + 53 +100.4514447 + 43 +15.182007 + 44 +-1.3282535 + 45 +146.9412470904 + 46 +175.1177519122 + 79 +2 + 49 +16.1900088 + 49 +-178.0902446 + 53 +46.1842 + 43 +0.0 + 44 +50.8 + 45 +271.0790408921 + 46 +-42.0423279327 + 79 +2 + 49 +28.575 + 49 +-314.325 + 53 +96.63555761 + 43 +22.5899 + 44 +47.2965 + 45 +237.4041340515 + 46 +247.4261245977 + 79 +2 + 49 +24.28502314 + 49 +-267.13560816 + 53 +351.18415117 + 43 +0.0 + 44 +50.8 + 45 +237.404134065 + 46 +247.4261245855 + 79 +2 + 49 +22.85996707 + 49 +-251.45973192 + 53 +21.0 + 43 +25.4 + 44 +38.1 + 45 +151.6143912691 + 46 +-102.2651981871 + 79 +2 + 49 +19.05 + 49 +-209.55 + 53 +326.0 + 43 +25.4 + 44 +38.1 + 45 +61.8020283476 + 46 +184.1879661223 + 79 +2 + 49 +15.24 + 49 +-167.64 + 53 +71.451445 + 43 +38.0345326 + 44 +29.5779001 + 45 +213.4164192787 + 46 +81.9227688859 + 79 +2 + 49 +16.1900088 + 49 +-178.0899376 + 53 +37.5 + 43 +0.0 + 44 +0.0 + 45 +3.0886032506 + 46 +84.5550388732 + 79 +6 + 49 +0.0 + 49 +-165.608 + 49 +0.0 + 49 +-170.18 + 49 +0.0 + 49 +-168.275 + 53 +7.5 + 43 +0.0 + 44 +0.0 + 45 +66.8196625103 + 46 +100.1805748181 + 79 +6 + 49 +0.0 + 49 +-97.028 + 49 +0.0 + 49 +-161.798 + 49 +0.0 + 49 +-64.135 + 53 +-32.5 + 43 +-56.642 + 44 +0.0 + 45 +135.5905951669 + 46 +-5.7287439927 + 79 +6 + 49 +0.0 + 49 +-63.5 + 49 +0.0 + 49 +-198.12 + 49 +0.0 + 49 +-262.89 + 53 +-42.5 + 43 +-82.042 + 44 +0.0 + 45 +148.129181386 + 46 +25.4264910333 + 79 +6 + 49 +0.0 + 49 +-82.55 + 49 +0.0 + 49 +-131.572 + 49 +0.0 + 49 +-186.69 + 98 +0 + 0 +LWPOLYLINE + 5 +6F +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +700.0 + 20 +-165.0 + 10 +800.0 + 20 +-165.0 + 10 +800.0 + 20 +-65.0 + 10 +700.0 + 20 +-65.0 + 0 +TEXT + 5 +70 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-179.0 + 30 +0.0 + 40 +9.0 + 1 +AR-CONC + 7 +LABEL + 11 +700.0 + 21 +-179.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +71 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-HBONE + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +-165.0 + 10 +940.0 + 20 +-165.0 + 10 +940.0 + 20 +-65.0 + 10 +840.0 + 20 +-65.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +143.6840979371 + 79 +2 + 49 +304.8 + 49 +-101.6 + 53 +135.0 + 43 +71.842 + 44 +71.842 + 45 +0.0 + 46 +143.6840979371 + 79 +2 + 49 +304.8 + 49 +-101.6 + 98 +0 + 0 +LWPOLYLINE + 5 +72 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +-165.0 + 10 +940.0 + 20 +-165.0 + 10 +940.0 + 20 +-65.0 + 10 +840.0 + 20 +-65.0 + 0 +TEXT + 5 +73 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-179.0 + 30 +0.0 + 40 +9.0 + 1 +AR-HBONE + 7 +LABEL + 11 +840.0 + 21 +-179.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +74 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-PARQ1 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +980.0 + 20 +-165.0 + 10 +1080.0 + 20 +-165.0 + 10 +1080.0 + 20 +-65.0 + 10 +980.0 + 20 +-65.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +14 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-304.8 + 46 +304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +90.0 + 43 +50.8 + 44 +0.0 + 45 +-304.8 + 46 +304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +90.0 + 43 +101.6 + 44 +0.0 + 45 +-304.8 + 46 +304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +90.0 + 43 +152.4 + 44 +0.0 + 45 +-304.8 + 46 +304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +90.0 + 43 +203.2 + 44 +0.0 + 45 +-304.8 + 46 +304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +90.0 + 43 +254.0 + 44 +0.0 + 45 +-304.8 + 46 +304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +90.0 + 43 +304.8 + 44 +0.0 + 45 +-304.8 + 46 +304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +0.0 + 43 +0.0 + 44 +304.8 + 45 +304.8 + 46 +-304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +0.0 + 43 +0.0 + 44 +355.6 + 45 +304.8 + 46 +-304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +0.0 + 43 +0.0 + 44 +406.4 + 45 +304.8 + 46 +-304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +0.0 + 43 +0.0 + 44 +457.2 + 45 +304.8 + 46 +-304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +0.0 + 43 +0.0 + 44 +508.0 + 45 +304.8 + 46 +-304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +0.0 + 43 +0.0 + 44 +558.8 + 45 +304.8 + 46 +-304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 53 +0.0 + 43 +0.0 + 44 +609.6 + 45 +304.8 + 46 +-304.8 + 79 +2 + 49 +304.8 + 49 +-304.8 + 98 +0 + 0 +LWPOLYLINE + 5 +75 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +980.0 + 20 +-165.0 + 10 +1080.0 + 20 +-165.0 + 10 +1080.0 + 20 +-65.0 + 10 +980.0 + 20 +-65.0 + 0 +TEXT + 5 +76 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +980.0 + 20 +-179.0 + 30 +0.0 + 40 +9.0 + 1 +AR-PARQ1 + 7 +LABEL + 11 +980.0 + 21 +-179.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +77 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-RROOF + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-330.0 + 10 +100.0 + 20 +-330.0 + 10 +100.0 + 20 +-230.0 + 10 +0.0 + 20 +-230.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +55.88 + 46 +25.4 + 79 +4 + 49 +381.0 + 49 +-50.8 + 49 +127.0 + 49 +-25.4 + 53 +0.0 + 43 +33.782 + 44 +12.7 + 45 +-25.4 + 46 +33.782 + 79 +4 + 49 +76.2 + 49 +-8.382 + 49 +152.4 + 49 +-19.05 + 53 +0.0 + 43 +12.7 + 44 +21.59 + 45 +132.08 + 46 +17.018 + 79 +4 + 49 +203.2 + 49 +-35.56 + 49 +101.6 + 49 +-25.4 + 98 +0 + 0 +LWPOLYLINE + 5 +78 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-330.0 + 10 +100.0 + 20 +-330.0 + 10 +100.0 + 20 +-230.0 + 10 +0.0 + 20 +-230.0 + 0 +TEXT + 5 +79 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-344.0 + 30 +0.0 + 40 +9.0 + 1 +AR-RROOF + 7 +LABEL + 11 +0.0 + 21 +-344.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +7A +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-RSHKE + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +-330.0 + 10 +240.0 + 20 +-330.0 + 10 +240.0 + 20 +-230.0 + 10 +140.0 + 20 +-230.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +9 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +647.7 + 46 +304.8 + 79 +6 + 49 +152.4 + 49 +-127.0 + 49 +177.8 + 49 +-76.2 + 49 +228.6 + 49 +-101.6 + 53 +0.0 + 43 +152.4 + 44 +12.7 + 45 +647.7 + 46 +304.8 + 79 +4 + 49 +127.0 + 49 +-482.6 + 49 +101.6 + 49 +-152.4 + 53 +0.0 + 43 +457.2 + 44 +-19.05 + 45 +647.7 + 46 +304.8 + 79 +2 + 49 +76.2 + 49 +-787.4 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-215.9 + 46 +304.8 + 79 +2 + 49 +292.1 + 49 +-927.1 + 53 +90.0 + 43 +152.4 + 44 +0.0 + 45 +-215.9 + 46 +304.8 + 79 +2 + 49 +285.75 + 49 +-933.45 + 53 +90.0 + 43 +279.4 + 44 +0.0 + 45 +-215.9 + 46 +304.8 + 79 +2 + 49 +266.7 + 49 +-952.5 + 53 +90.0 + 43 +457.2 + 44 +-19.05 + 45 +-215.9 + 46 +304.8 + 79 +2 + 49 +292.1 + 49 +-927.1 + 53 +90.0 + 43 +533.4 + 44 +-19.05 + 45 +-215.9 + 46 +304.8 + 79 +2 + 49 +292.1 + 49 +-927.1 + 53 +90.0 + 43 +762.0 + 44 +0.0 + 45 +-215.9 + 46 +304.8 + 79 +2 + 49 +279.4 + 49 +-939.8 + 98 +0 + 0 +LWPOLYLINE + 5 +7B +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +140.0 + 20 +-330.0 + 10 +240.0 + 20 +-330.0 + 10 +240.0 + 20 +-230.0 + 10 +140.0 + 20 +-230.0 + 0 +TEXT + 5 +7C +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-344.0 + 30 +0.0 + 40 +9.0 + 1 +AR-RSHKE + 7 +LABEL + 11 +140.0 + 21 +-344.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +7D +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-SAND + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +-330.0 + 10 +380.0 + 20 +-330.0 + 10 +380.0 + 20 +-230.0 + 10 +280.0 + 20 +-230.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +4 + 53 +37.5 + 43 +0.0 + 44 +0.0 + 45 +-1.600031296 + 46 +48.9413237329 + 79 +6 + 49 +0.0 + 49 +-38.608 + 49 +0.0 + 49 +-43.18 + 49 +0.0 + 49 +-41.275 + 53 +7.5 + 43 +0.0 + 44 +0.0 + 45 +44.9523283138 + 46 +71.6825100568 + 79 +6 + 49 +0.0 + 49 +-20.828 + 49 +0.0 + 49 +-34.798 + 49 +0.0 + 49 +-13.335 + 53 +-32.5 + 43 +-31.242 + 44 +0.0 + 45 +79.0992370241 + 46 +0.1437184679 + 79 +6 + 49 +0.0 + 49 +-12.7 + 49 +0.0 + 49 +-45.72 + 49 +0.0 + 49 +-59.69 + 53 +-42.5 + 43 +-31.242 + 44 +0.0 + 45 +76.3556452472 + 46 +22.2929323257 + 79 +6 + 49 +0.0 + 49 +-6.35 + 49 +0.0 + 49 +-29.972 + 49 +0.0 + 49 +-34.29 + 98 +0 + 0 +LWPOLYLINE + 5 +7E +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +280.0 + 20 +-330.0 + 10 +380.0 + 20 +-330.0 + 10 +380.0 + 20 +-230.0 + 10 +280.0 + 20 +-230.0 + 0 +TEXT + 5 +7F +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-344.0 + 30 +0.0 + 40 +9.0 + 1 +AR-SAND + 7 +LABEL + 11 +280.0 + 21 +-344.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +80 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BOX + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +-330.0 + 10 +520.0 + 20 +-330.0 + 10 +520.0 + 20 +-230.0 + 10 +420.0 + 20 +-230.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +8 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-25.4 + 46 +0.0 + 79 +0 + 53 +90.0 + 43 +6.35 + 44 +0.0 + 45 +-25.4 + 46 +0.0 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +-6.35 + 49 +6.35 + 53 +0.0 + 43 +0.0 + 44 +6.35 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +-6.35 + 49 +6.35 + 53 +0.0 + 43 +0.0 + 44 +12.7 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +0.0 + 43 +0.0 + 44 +19.05 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +12.7 + 44 +0.0 + 45 +-25.4 + 46 +0.0 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +19.05 + 44 +0.0 + 45 +-25.4 + 46 +0.0 + 79 +2 + 49 +6.35 + 49 +-6.35 + 98 +0 + 0 +LWPOLYLINE + 5 +81 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +420.0 + 20 +-330.0 + 10 +520.0 + 20 +-330.0 + 10 +520.0 + 20 +-230.0 + 10 +420.0 + 20 +-230.0 + 0 +TEXT + 5 +82 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-344.0 + 30 +0.0 + 40 +9.0 + 1 +BOX + 7 +LABEL + 11 +420.0 + 21 +-344.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +83 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRASS + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +-330.0 + 10 +660.0 + 20 +-330.0 + 10 +660.0 + 20 +-230.0 + 10 +560.0 + 20 +-230.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +6.35 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +3.175 + 45 +0.0 + 46 +6.35 + 79 +2 + 49 +3.175 + 49 +-1.5875 + 98 +0 + 0 +LWPOLYLINE + 5 +84 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +560.0 + 20 +-330.0 + 10 +660.0 + 20 +-330.0 + 10 +660.0 + 20 +-230.0 + 10 +560.0 + 20 +-230.0 + 0 +TEXT + 5 +85 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-344.0 + 30 +0.0 + 40 +9.0 + 1 +BRASS + 7 +LABEL + 11 +560.0 + 21 +-344.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +86 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRICK + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +-330.0 + 10 +800.0 + 20 +-330.0 + 10 +800.0 + 20 +-230.0 + 10 +700.0 + 20 +-230.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +6.35 + 79 +0 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-12.7 + 46 +0.0 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +6.35 + 44 +0.0 + 45 +-12.7 + 46 +0.0 + 79 +2 + 49 +-6.35 + 49 +6.35 + 98 +0 + 0 +LWPOLYLINE + 5 +87 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +700.0 + 20 +-330.0 + 10 +800.0 + 20 +-330.0 + 10 +800.0 + 20 +-230.0 + 10 +700.0 + 20 +-230.0 + 0 +TEXT + 5 +88 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-344.0 + 30 +0.0 + 40 +9.0 + 1 +BRICK + 7 +LABEL + 11 +700.0 + 21 +-344.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +89 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRICK_DEMOLITION + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +-330.0 + 10 +940.0 + 20 +-330.0 + 10 +940.0 + 20 +-230.0 + 10 +840.0 + 20 +-230.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +2 + 49 +2.54 + 49 +-2.54 + 53 +45.0 + 43 +1.796034 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +2 + 49 +2.54 + 49 +-2.54 + 98 +0 + 0 +LWPOLYLINE + 5 +8A +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +-330.0 + 10 +940.0 + 20 +-330.0 + 10 +940.0 + 20 +-230.0 + 10 +840.0 + 20 +-230.0 + 0 +TEXT + 5 +8B +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-344.0 + 30 +0.0 + 40 +9.0 + 1 +BRICK_DEMOLITION + 7 +LABEL + 11 +840.0 + 21 +-344.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +8C +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRICK_EXISTING + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +980.0 + 20 +-330.0 + 10 +1080.0 + 20 +-330.0 + 10 +1080.0 + 20 +-230.0 + 10 +980.0 + 20 +-230.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-7.1842048969 + 46 +7.1842048969 + 79 +0 + 53 +45.0 + 43 +3.5920934 + 44 +0.0 + 45 +-7.1842048969 + 46 +7.1842048969 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +8D +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +980.0 + 20 +-330.0 + 10 +1080.0 + 20 +-330.0 + 10 +1080.0 + 20 +-230.0 + 10 +980.0 + 20 +-230.0 + 0 +TEXT + 5 +8E +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +980.0 + 20 +-344.0 + 30 +0.0 + 40 +9.0 + 1 +BRICK_EXISTING + 7 +LABEL + 11 +980.0 + 21 +-344.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +8F +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRICK_FLBOND + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-495.0 + 10 +100.0 + 20 +-495.0 + 10 +100.0 + 20 +-395.0 + 10 +0.0 + 20 +-395.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +8 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +38.1 + 46 +16.9333333291 + 79 +2 + 49 +48.41875 + 49 +-27.78125 + 53 +0.0 + 43 +50.8 + 44 +0.0 + 45 +38.1 + 46 +16.9333333291 + 79 +2 + 49 +23.01875 + 49 +-53.18125 + 53 +0.0 + 43 +0.0 + 44 +14.5520833291 + 45 +38.1 + 46 +16.9333333291 + 79 +2 + 49 +48.41875 + 49 +-27.78125 + 53 +0.0 + 43 +50.8 + 44 +14.5520833291 + 45 +38.1 + 46 +16.9333333291 + 79 +2 + 49 +23.01875 + 49 +-53.18125 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-38.1 + 46 +16.9333333291 + 79 +2 + 49 +14.5520833291 + 49 +-19.3145833291 + 53 +90.0 + 43 +48.41875 + 44 +0.0 + 45 +-38.1 + 46 +16.9333333291 + 79 +2 + 49 +14.5520833291 + 49 +-19.3145833291 + 53 +90.0 + 43 +50.8 + 44 +0.0 + 45 +-38.1 + 46 +16.9333333291 + 79 +2 + 49 +14.5520833291 + 49 +-19.3145833291 + 53 +90.0 + 43 +73.81875 + 44 +0.0 + 45 +-38.1 + 46 +16.9333333291 + 79 +2 + 49 +14.5520833291 + 49 +-19.3145833291 + 98 +0 + 0 +LWPOLYLINE + 5 +90 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-495.0 + 10 +100.0 + 20 +-495.0 + 10 +100.0 + 20 +-395.0 + 10 +0.0 + 20 +-395.0 + 0 +TEXT + 5 +91 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-509.0 + 30 +0.0 + 40 +9.0 + 1 +BRICK_FLBOND + 7 +LABEL + 11 +0.0 + 21 +-509.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +92 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRICK_INSULATING + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +-495.0 + 10 +240.0 + 20 +-495.0 + 10 +240.0 + 20 +-395.0 + 10 +140.0 + 20 +-395.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-6.2861792847 + 46 +6.2861792847 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +2.54 + 79 +2 + 49 +0.0 + 49 +-2.54 + 98 +0 + 0 +LWPOLYLINE + 5 +93 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +140.0 + 20 +-495.0 + 10 +240.0 + 20 +-495.0 + 10 +240.0 + 20 +-395.0 + 10 +140.0 + 20 +-395.0 + 0 +TEXT + 5 +94 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-509.0 + 30 +0.0 + 40 +9.0 + 1 +BRICK_INSULATING + 7 +LABEL + 11 +140.0 + 21 +-509.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +95 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRICK_LWEIGHT + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +-495.0 + 10 +380.0 + 20 +-495.0 + 10 +380.0 + 20 +-395.0 + 10 +280.0 + 20 +-395.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-6.2861792847 + 46 +6.2861792847 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.79375 + 46 +1.5875 + 79 +2 + 49 +0.0 + 49 +-1.5875 + 98 +0 + 0 +LWPOLYLINE + 5 +96 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +280.0 + 20 +-495.0 + 10 +380.0 + 20 +-495.0 + 10 +380.0 + 20 +-395.0 + 10 +280.0 + 20 +-395.0 + 0 +TEXT + 5 +97 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-509.0 + 30 +0.0 + 40 +9.0 + 1 +BRICK_LWEIGHT + 7 +LABEL + 11 +280.0 + 21 +-509.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +98 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRICK_PAIRS + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +-495.0 + 10 +520.0 + 20 +-495.0 + 10 +520.0 + 20 +-395.0 + 10 +420.0 + 20 +-395.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +8 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +50.8 + 46 +50.8 + 79 +6 + 49 +48.41875 + 49 +-2.38125 + 49 +23.01875 + 49 +-2.38125 + 49 +23.01875 + 49 +-2.38125 + 53 +0.0 + 43 +0.0 + 44 +23.01875 + 45 +50.8 + 46 +50.8 + 79 +2 + 49 +48.41875 + 49 +-53.18125 + 53 +0.0 + 43 +0.0 + 44 +25.4 + 45 +50.8 + 46 +50.8 + 79 +2 + 49 +48.41875 + 49 +-53.18125 + 53 +0.0 + 43 +0.0 + 44 +48.41875 + 45 +50.8 + 46 +50.8 + 79 +6 + 49 +48.41875 + 49 +-2.38125 + 49 +23.01875 + 49 +-2.38125 + 49 +23.01875 + 49 +-2.38125 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-50.8 + 46 +50.8 + 79 +6 + 49 +23.01875 + 49 +-2.38125 + 49 +23.01875 + 49 +-2.38125 + 49 +48.41875 + 49 +-2.38125 + 53 +90.0 + 43 +23.01875 + 44 +50.8 + 45 +-50.8 + 46 +50.8 + 79 +2 + 49 +48.41875 + 49 +-53.18125 + 53 +90.0 + 43 +25.4 + 44 +50.8 + 45 +-50.8 + 46 +50.8 + 79 +2 + 49 +48.41875 + 49 +-53.18125 + 53 +90.0 + 43 +48.41875 + 44 +0.0 + 45 +-50.8 + 46 +50.8 + 79 +6 + 49 +23.01875 + 49 +-2.38125 + 49 +23.01875 + 49 +-2.38125 + 49 +48.41875 + 49 +-2.38125 + 98 +0 + 0 +LWPOLYLINE + 5 +99 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +420.0 + 20 +-495.0 + 10 +520.0 + 20 +-495.0 + 10 +520.0 + 20 +-395.0 + 10 +420.0 + 20 +-395.0 + 0 +TEXT + 5 +9A +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-509.0 + 30 +0.0 + 40 +9.0 + 1 +BRICK_PAIRS + 7 +LABEL + 11 +420.0 + 21 +-509.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +9B +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRICK_STBOND + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +-495.0 + 10 +660.0 + 20 +-495.0 + 10 +660.0 + 20 +-395.0 + 10 +560.0 + 20 +-395.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +25.4 + 79 +0 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-50.8 + 46 +0.0 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +9C +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +560.0 + 20 +-495.0 + 10 +660.0 + 20 +-495.0 + 10 +660.0 + 20 +-395.0 + 10 +560.0 + 20 +-395.0 + 0 +TEXT + 5 +9D +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-509.0 + 30 +0.0 + 40 +9.0 + 1 +BRICK_STBOND + 7 +LABEL + 11 +560.0 + 21 +-509.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +9E +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +BRICK_STRBOND + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +-495.0 + 10 +800.0 + 20 +-495.0 + 10 +800.0 + 20 +-395.0 + 10 +700.0 + 20 +-395.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +50.8 + 79 +0 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-101.6 + 46 +50.8 + 79 +2 + 49 +50.8 + 49 +-50.8 + 98 +0 + 0 +LWPOLYLINE + 5 +9F +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +700.0 + 20 +-495.0 + 10 +800.0 + 20 +-495.0 + 10 +800.0 + 20 +-395.0 + 10 +700.0 + 20 +-395.0 + 0 +TEXT + 5 +A0 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-509.0 + 30 +0.0 + 40 +9.0 + 1 +BRICK_STRBOND + 7 +LABEL + 11 +700.0 + 21 +-509.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +A1 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +CLAY + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +-495.0 + 10 +940.0 + 20 +-495.0 + 10 +940.0 + 20 +-395.0 + 10 +840.0 + 20 +-395.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +4 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +4.7625 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +0.79375 + 45 +0.0 + 46 +4.7625 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +1.5875 + 45 +0.0 + 46 +4.7625 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +3.175 + 45 +0.0 + 46 +4.7625 + 79 +2 + 49 +4.7625 + 49 +-3.175 + 98 +0 + 0 +LWPOLYLINE + 5 +A2 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +-495.0 + 10 +940.0 + 20 +-495.0 + 10 +940.0 + 20 +-395.0 + 10 +840.0 + 20 +-395.0 + 0 +TEXT + 5 +A3 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-509.0 + 30 +0.0 + 40 +9.0 + 1 +CLAY + 7 +LABEL + 11 +840.0 + 21 +-509.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +A4 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +CONCRETE1 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +980.0 + 20 +-495.0 + 10 +1080.0 + 20 +-495.0 + 10 +1080.0 + 20 +-395.0 + 10 +980.0 + 20 +-395.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +22 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +20.32 + 46 +20.32 + 79 +2 + 49 +2.54 + 49 +-17.78 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-20.32 + 46 +20.32 + 79 +2 + 49 +2.54 + 49 +-17.78 + 53 +135.0 + 43 +2.54 + 44 +0.0 + 45 +-20.3199999659 + 46 +0.0 + 79 +2 + 49 +3.592102544 + 49 +-25.144717808 + 53 +0.0 + 43 +2.54 + 44 +11.43 + 45 +20.32 + 46 +20.32 + 79 +2 + 49 +2.54 + 49 +-17.78 + 53 +45.0 + 43 +3.81 + 44 +10.16 + 45 +0.0 + 46 +20.3199999659 + 79 +2 + 49 +1.796050256 + 49 +-26.940768064 + 53 +135.0 + 43 +3.81 + 44 +10.16 + 45 +-20.3199999659 + 46 +0.0 + 79 +2 + 49 +1.796050256 + 49 +-26.940768064 + 53 +0.0 + 43 +10.16 + 44 +16.51 + 45 +20.32 + 46 +20.32 + 79 +2 + 49 +3.592102544 + 49 +-16.727897456 + 53 +45.0 + 43 +10.16 + 44 +16.51 + 45 +0.0 + 46 +20.3199999659 + 79 +2 + 49 +2.54 + 49 +-26.196820352 + 53 +135.0 + 43 +13.752102544 + 44 +16.51 + 45 +-20.3199999659 + 46 +0.0 + 79 +2 + 49 +2.54 + 49 +-26.196820352 + 53 +90.0 + 43 +13.97 + 44 +5.08 + 45 +-20.32 + 46 +20.32 + 79 +2 + 49 +2.54 + 49 +-17.78 + 53 +45.0 + 43 +13.97 + 44 +5.08 + 45 +0.0 + 46 +20.3199999659 + 79 +2 + 49 +1.796050256 + 49 +-26.940768064 + 53 +135.0 + 43 +15.24 + 44 +6.35 + 45 +-20.3199999659 + 46 +0.0 + 79 +2 + 49 +1.796050256 + 49 +-26.940768064 + 53 +0.0 + 43 +2.54 + 44 +5.08 + 45 +20.32 + 46 +20.32 + 79 +2 + 49 +1.27 + 49 +-17.018 + 53 +0.0 + 43 +17.78 + 44 +10.16 + 45 +20.32 + 46 +20.32 + 79 +2 + 49 +1.27 + 49 +-17.018 + 53 +90.0 + 43 +11.43 + 44 +12.7 + 45 +-20.32 + 46 +20.32 + 79 +2 + 49 +1.27 + 49 +-17.018 + 53 +135.0 + 43 +13.97 + 44 +1.27 + 45 +-20.3199999659 + 46 +0.0 + 79 +2 + 49 +1.796050256 + 49 +-26.940768064 + 53 +135.0 + 43 +5.08 + 44 +15.24 + 45 +-20.3199999659 + 46 +0.0 + 79 +2 + 49 +1.796050256 + 49 +-26.940768064 + 53 +45.0 + 43 +9.525 + 44 +6.6675 + 45 +0.0 + 46 +20.3199999659 + 79 +2 + 49 +1.796050256 + 49 +-26.940768064 + 53 +90.0 + 43 +6.985 + 44 +2.54 + 45 +-20.32 + 46 +20.32 + 79 +2 + 49 +0.3175 + 49 +-20.0025 + 53 +90.0 + 43 +7.9375 + 44 +11.1125 + 45 +-20.32 + 46 +20.32 + 79 +2 + 49 +0.3175 + 49 +-20.0025 + 53 +90.0 + 43 +17.4625 + 44 +14.605 + 45 +-20.32 + 46 +20.32 + 79 +2 + 49 +0.3175 + 49 +-20.0025 + 53 +90.0 + 43 +17.78 + 44 +3.81 + 45 +-20.32 + 46 +20.32 + 79 +2 + 49 +0.3175 + 49 +-20.0025 + 98 +0 + 0 +LWPOLYLINE + 5 +A5 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +980.0 + 20 +-495.0 + 10 +1080.0 + 20 +-495.0 + 10 +1080.0 + 20 +-395.0 + 10 +980.0 + 20 +-395.0 + 0 +TEXT + 5 +A6 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +980.0 + 20 +-509.0 + 30 +0.0 + 40 +9.0 + 1 +CONCRETE1 + 7 +LABEL + 11 +980.0 + 21 +-509.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +A7 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +CONCRETE2 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-660.0 + 10 +100.0 + 20 +-660.0 + 10 +100.0 + 20 +-560.0 + 10 +0.0 + 20 +-560.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +13 + 53 +50.0 + 43 +0.0 + 44 +0.0 + 45 +14.5747269104 + 46 +-1.2751233828 + 79 +2 + 49 +1.524 + 49 +-16.764 + 53 +355.0 + 43 +0.0 + 44 +0.0 + 45 +-2.819427099 + 46 +15.2845441568 + 79 +2 + 49 +1.2192 + 49 +-13.4112 + 53 +100.4514 + 43 +1.2145605376 + 44 +-0.1062601888 + 45 +11.7553106364 + 46 +14.0094114947 + 79 +2 + 49 +1.2952006608 + 49 +-14.2472077158 + 53 +46.1842 + 43 +0.0 + 44 +4.064 + 45 +21.6863381081 + 46 +-3.3633463712 + 79 +2 + 49 +2.286 + 49 +-25.146 + 53 +96.6356 + 43 +1.8071946584 + 44 +3.7837200018 + 45 +18.9923461767 + 46 +19.7940751367 + 79 +2 + 49 +1.9428010522 + 49 +-21.37080816 + 53 +351.1842 + 43 +0.0 + 44 +4.064 + 45 +18.9923307251 + 46 +19.7940899708 + 79 +2 + 49 +1.8288 + 49 +-20.1168 + 53 +21.0 + 43 +2.032 + 44 +3.048 + 45 +12.129151299 + 46 +-8.1812158583 + 79 +2 + 49 +1.524 + 49 +-16.764 + 53 +326.0 + 43 +2.032 + 44 +3.048 + 45 +4.9441675446 + 46 +14.7350489154 + 79 +2 + 49 +1.2192 + 49 +-13.4112 + 53 +71.4514 + 43 +3.0427621746 + 44 +2.3662313661 + 45 +17.0733238128 + 46 +6.5538196931 + 79 +2 + 49 +1.2952006608 + 49 +-14.2472077158 + 53 +37.5 + 43 +0.0 + 44 +0.0 + 45 +0.24708826 + 46 +6.7644031099 + 79 +6 + 49 +0.0 + 49 +-13.24864 + 49 +0.0 + 49 +-13.6144 + 49 +0.0 + 49 +-13.462 + 53 +7.5 + 43 +0.0 + 44 +0.0 + 45 +5.3455730008 + 46 +8.0144459854 + 79 +6 + 49 +0.0 + 49 +-7.76224 + 49 +0.0 + 49 +-12.94384 + 49 +0.0 + 49 +-5.1308 + 53 +-32.5 + 43 +-4.53136 + 44 +0.0 + 45 +10.847271903 + 46 +-0.4583149936 + 79 +6 + 49 +0.0 + 49 +-5.08 + 49 +0.0 + 49 +-15.8496 + 49 +0.0 + 49 +-21.0312 + 53 +-42.5 + 43 +-6.56336 + 44 +0.0 + 45 +11.850342961 + 46 +2.034133241 + 79 +6 + 49 +0.0 + 49 +-6.604 + 49 +0.0 + 49 +-10.52576 + 49 +0.0 + 49 +-14.9352 + 98 +0 + 0 +LWPOLYLINE + 5 +A8 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-660.0 + 10 +100.0 + 20 +-660.0 + 10 +100.0 + 20 +-560.0 + 10 +0.0 + 20 +-560.0 + 0 +TEXT + 5 +A9 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-674.0 + 30 +0.0 + 40 +9.0 + 1 +CONCRETE2 + 7 +LABEL + 11 +0.0 + 21 +-674.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +AA +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +CONCRETE3 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +-660.0 + 10 +240.0 + 20 +-660.0 + 10 +240.0 + 20 +-560.0 + 10 +140.0 + 20 +-560.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +304 + 53 +82.88 + 43 +97.10721498 + 44 +59.2083144 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.03725418 + 49 +-136.48436 + 53 +97.13 + 43 +97.10660538 + 44 +59.2448904 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.04934458 + 49 +-136.4722696 + 53 +116.57 + 43 +97.09453022 + 44 +59.29154258 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +135.0 + 43 +97.06356 + 44 +59.33695778 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +172.88 + 43 +97.01943258 + 44 +59.3792564 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +172.88 + 43 +96.9621632 + 44 +59.3875368 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +187.13 + 43 +96.92463978 + 44 +59.38655382 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.05259578 + 49 +-136.4690184 + 53 +225.0 + 43 +96.8702152 + 44 +59.3801708 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.06072378 + 49 +-23.88639622 + 53 +243.44 + 43 +96.82142942 + 44 +59.3422232 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +262.88 + 43 +96.79022298 + 44 +59.29440262 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +262.88 + 43 +96.78295858 + 44 +59.2425536 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +277.13 + 43 +96.78366978 + 44 +59.20499462 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.0483108 + 49 +-136.47330338 + 53 +296.57 + 43 +96.79555698 + 44 +59.1593432 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +315.0 + 43 +96.8265272 + 44 +59.11391022 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +352.88 + 43 +96.87065462 + 44 +59.07162938 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +7.13 + 43 +96.96544742 + 44 +59.0642964 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.0521208 + 49 +-136.46949338 + 53 +45.0 + 43 +97.0198212 + 44 +59.07066418 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.0608076 + 49 +-23.8863124 + 53 +63.44 + 43 +97.06865778 + 44 +59.1086448 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +82.88 + 43 +97.09986422 + 44 +59.15646538 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +82.88 + 43 +87.67045458 + 44 +53.3422352 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.03725418 + 49 +-136.48436 + 53 +97.13 + 43 +87.66984498 + 44 +53.37879342 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.04934458 + 49 +-136.4722696 + 53 +116.57 + 43 +87.65776982 + 44 +53.4254456 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +135.0 + 43 +87.6267996 + 44 +53.47087858 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +172.88 + 43 +87.58267218 + 44 +53.51315942 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +172.88 + 43 +87.5254028 + 44 +53.52143982 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +187.13 + 43 +87.48787938 + 44 +53.52045938 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.05259578 + 49 +-136.4690184 + 53 +225.0 + 43 +87.4334548 + 44 +53.51407382 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.06072378 + 49 +-23.88639622 + 53 +243.44 + 43 +87.38466902 + 44 +53.47612622 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +262.88 + 43 +87.35346258 + 44 +53.42830818 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +262.88 + 43 +87.34619818 + 44 +53.37645662 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +277.13 + 43 +87.34690938 + 44 +53.33890018 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.0483108 + 49 +-136.47330338 + 53 +296.57 + 43 +87.35879658 + 44 +53.29324622 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +315.0 + 43 +87.3897668 + 44 +53.24781578 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +352.88 + 43 +87.43389422 + 44 +53.2055324 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +7.13 + 43 +87.52868702 + 44 +53.19819942 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.0521208 + 49 +-136.46949338 + 53 +45.0 + 43 +87.5830608 + 44 +53.2045672 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.0608076 + 49 +-23.8863124 + 53 +63.44 + 43 +87.63189738 + 44 +53.24254782 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +82.88 + 43 +87.66310382 + 44 +53.29038618 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +82.88 + 43 +93.68222818 + 44 +52.93331298 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +63.44 + 43 +93.6510192 + 44 +52.8854924 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +45.0 + 43 +93.60218262 + 44 +52.847494 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.0608076 + 49 +-23.8863124 + 53 +7.13 + 43 +93.54781138 + 44 +52.84112622 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.0521208 + 49 +-136.46949338 + 53 +352.88 + 43 +93.45301858 + 44 +52.84847698 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +315.0 + 43 +93.40888862 + 44 +52.89075782 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +296.57 + 43 +93.3779184 + 44 +52.93617302 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +277.13 + 43 +93.3660312 + 44 +52.98182698 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.0483108 + 49 +-136.47330338 + 53 +262.88 + 43 +93.36532 + 44 +53.01938342 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +262.88 + 43 +93.3725844 + 44 +53.07125022 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +243.44 + 43 +93.40379338 + 44 +53.1190708 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +225.0 + 43 +93.45257662 + 44 +53.15700062 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.06072378 + 49 +-23.88639622 + 53 +187.13 + 43 +93.5070012 + 44 +53.16340142 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.05259578 + 49 +-136.4690184 + 53 +172.88 + 43 +93.54452462 + 44 +53.16436662 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +172.88 + 43 +93.601794 + 44 +53.15608622 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +135.0 + 43 +93.64592142 + 44 +53.11380538 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +116.57 + 43 +93.67689418 + 44 +53.0683724 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +97.13 + 43 +93.6889668 + 44 +53.02172022 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.04934458 + 49 +-136.4722696 + 53 +82.88 + 43 +93.6895764 + 44 +52.985162 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.03725418 + 49 +-136.48436 + 53 +82.88 + 43 +89.09138138 + 44 +65.02255982 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +63.44 + 43 +89.06015462 + 44 +64.97474178 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +45.0 + 43 +89.01132058 + 44 +64.93675862 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.0608076 + 49 +-23.8863124 + 53 +7.13 + 43 +88.95696458 + 44 +64.93039338 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.0521208 + 49 +-136.46949338 + 53 +352.88 + 43 +88.862154 + 44 +64.93772382 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +315.0 + 43 +88.81802658 + 44 +64.9800072 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +296.57 + 43 +88.78705382 + 44 +65.0254224 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +277.13 + 43 +88.7751844 + 44 +65.07107382 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.0483108 + 49 +-136.47330338 + 53 +262.88 + 43 +88.77445542 + 44 +65.10865058 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +262.88 + 43 +88.78171982 + 44 +65.1604996 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +243.44 + 43 +88.81294658 + 44 +65.20832018 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +225.0 + 43 +88.86171458 + 44 +65.24626778 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.06072378 + 49 +-23.88639622 + 53 +187.13 + 43 +88.9161544 + 44 +65.2526508 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.05259578 + 49 +-136.4690184 + 53 +172.88 + 43 +88.95366258 + 44 +65.253616 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +172.88 + 43 +89.0109472 + 44 +65.2453356 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +135.0 + 43 +89.05507462 + 44 +65.20305222 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +116.57 + 43 +89.08604738 + 44 +65.15763702 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +97.13 + 43 +89.09810222 + 44 +65.11098738 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.04934458 + 49 +-136.4722696 + 53 +82.88 + 43 +89.0987296 + 44 +65.07441138 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.03725418 + 49 +-136.48436 + 53 +82.88 + 43 +88.11877982 + 44 +63.97348902 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.15551658 + 49 +-136.3660976 + 53 +97.13 + 43 +88.11622458 + 44 +64.12612778 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.20597622 + 49 +-136.31563542 + 53 +116.57 + 43 +88.06583098 + 44 +64.32089498 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.23506938 + 49 +-37.62955898 + 53 +135.0 + 43 +87.93656022 + 44 +64.51053138 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.24807418 + 49 +-23.69904582 + 53 +172.88 + 43 +87.75230862 + 44 +64.6870436 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.24306022 + 49 +-136.27855142 + 53 +172.88 + 43 +87.51319302 + 44 +64.72162062 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.16085058 + 49 +-136.3607636 + 53 +187.13 + 43 +87.35657662 + 44 +64.71755662 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.21959062 + 49 +-136.30202102 + 53 +225.0 + 43 +87.12934822 + 44 +64.69088662 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.25350978 + 49 +-23.69361022 + 53 +243.44 + 43 +86.92572658 + 44 +64.53249222 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.23410418 + 49 +-37.63052418 + 53 +262.88 + 43 +86.79542458 + 44 +64.33284822 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.21999702 + 49 +-136.30161462 + 53 +262.88 + 43 +86.76506142 + 44 +64.11637418 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.16085058 + 49 +-136.3607636 + 53 +277.13 + 43 +86.7680764 + 44 +63.95955458 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.20170902 + 49 +-136.31990262 + 53 +296.57 + 43 +86.81769022 + 44 +63.768986 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.23506938 + 49 +-37.62955898 + 53 +315.0 + 43 +86.94696098 + 44 +63.5793496 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.24807418 + 49 +-23.69904582 + 53 +352.88 + 43 +87.13121258 + 44 +63.4028196 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.24306022 + 49 +-136.27855142 + 53 +7.13 + 43 +87.52695982 + 44 +63.37220498 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.21760942 + 49 +-136.30400222 + 53 +45.0 + 43 +87.75393422 + 44 +63.39877338 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.2538476 + 49 +-23.6932724 + 53 +63.44 + 43 +87.95779462 + 44 +63.55737098 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.23410418 + 49 +-37.63052418 + 53 +82.88 + 43 +88.0881144 + 44 +63.75701498 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.21999702 + 49 +-136.30161462 + 53 +82.88 + 43 +87.3229648 + 44 +65.74639378 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.21999702 + 49 +-136.30161462 + 53 +63.44 + 43 +87.1926628 + 44 +65.54674978 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.23410418 + 49 +-37.63052418 + 53 +45.0 + 43 +86.98878462 + 44 +65.38815218 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.2538476 + 49 +-23.6932724 + 53 +7.13 + 43 +86.76181022 + 44 +65.361566 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.21760942 + 49 +-136.30400222 + 53 +352.88 + 43 +86.36606298 + 44 +65.3921984 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.24306022 + 49 +-136.27855142 + 53 +315.0 + 43 +86.18182662 + 44 +65.56871062 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.24807418 + 49 +-23.69904582 + 53 +296.57 + 43 +86.05254062 + 44 +65.75834702 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.23506938 + 49 +-37.62955898 + 53 +277.13 + 43 +86.00294458 + 44 +65.94893338 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.20170902 + 49 +-136.31990262 + 53 +262.88 + 43 +85.9999296 + 44 +66.1057352 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.16085058 + 49 +-136.3607636 + 53 +262.88 + 43 +86.03027498 + 44 +66.32222702 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.21999702 + 49 +-136.30161462 + 53 +243.44 + 43 +86.16057698 + 44 +66.52187102 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.23410418 + 49 +-37.63052418 + 53 +225.0 + 43 +86.36419862 + 44 +66.68025018 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.25350978 + 49 +-23.69361022 + 53 +187.13 + 43 +86.5914448 + 44 +66.70692018 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.21959062 + 49 +-136.30202102 + 53 +172.88 + 43 +86.7480612 + 44 +66.71099942 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.16085058 + 49 +-136.3607636 + 53 +172.88 + 43 +86.9871768 + 44 +66.6764224 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.24306022 + 49 +-136.27855142 + 53 +135.0 + 43 +87.17141062 + 44 +66.4998924 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.24807418 + 49 +-23.69904582 + 53 +116.57 + 43 +87.30069662 + 44 +66.310256 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.23506938 + 49 +-37.62955898 + 53 +97.13 + 43 +87.35107498 + 44 +66.1154888 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.20597622 + 49 +-136.31563542 + 53 +82.88 + 43 +87.353648 + 44 +65.96285258 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.15551658 + 49 +-136.3660976 + 53 +82.88 + 43 +94.74125578 + 44 +62.99714858 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.52391818 + 49 +-135.997696 + 53 +63.44 + 43 +94.43093382 + 44 +62.52167582 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.55751222 + 49 +-37.3071136 + 53 +45.0 + 43 +93.94542298 + 44 +62.14397782 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.60453778 + 49 +-23.34258222 + 53 +7.13 + 43 +93.40487542 + 44 +62.08068102 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.51824382 + 49 +-136.00336782 + 53 +352.88 + 43 +92.46236778 + 44 +62.15361458 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.578866 + 49 +-135.94274818 + 53 +315.0 + 43 +92.0235904 + 44 +62.5740176 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.59077098 + 49 +-23.35634902 + 53 +296.57 + 43 +91.71570938 + 44 +63.0256296 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.559816 + 49 +-37.30480982 + 53 +277.13 + 43 +91.5975816 + 44 +63.47950982 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.4803648 + 49 +-136.04124938 + 53 +262.88 + 43 +91.59040102 + 44 +63.85294062 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.38306502 + 49 +-136.13854662 + 53 +262.88 + 43 +91.66267418 + 44 +64.36850982 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.52391818 + 49 +-135.997696 + 53 +243.44 + 43 +91.97301138 + 44 +64.84398258 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.55751222 + 49 +-37.3071136 + 53 +225.0 + 43 +92.4579304 + 44 +65.22117258 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.603758 + 49 +-23.343362 + 53 +187.13 + 43 +92.99912058 + 44 +65.28468782 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.52296822 + 49 +-135.99864342 + 53 +172.88 + 43 +93.37209418 + 44 +65.29439062 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.38306502 + 49 +-136.13854662 + 53 +172.88 + 43 +93.94156218 + 44 +65.21204382 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.578866 + 49 +-135.94274818 + 53 +135.0 + 43 +94.38033702 + 44 +64.7916408 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.59077098 + 49 +-23.35634902 + 53 +116.57 + 43 +94.68822058 + 44 +64.3400288 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.559816 + 49 +-37.30480982 + 53 +97.13 + 43 +94.80822542 + 44 +63.876174 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.49054258 + 49 +-136.0310716 + 53 +82.88 + 43 +94.81432142 + 44 +63.51266698 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.37036502 + 49 +-136.15124662 + 53 +82.88 + 43 +99.5581956 + 44 +54.0248856 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.37036502 + 49 +-136.15124662 + 53 +97.13 + 43 +99.5520996 + 44 +54.3884104 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.49054258 + 49 +-136.0310716 + 53 +116.57 + 43 +99.43211 + 44 +54.8522652 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.559816 + 49 +-37.30480982 + 53 +135.0 + 43 +99.12422898 + 44 +55.3038772 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.59077098 + 49 +-23.35634902 + 53 +172.88 + 43 +98.6854516 + 44 +55.72428022 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.578866 + 49 +-135.94274818 + 53 +172.88 + 43 +98.1159836 + 44 +55.80661178 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.38306502 + 49 +-136.13854662 + 53 +187.13 + 43 +97.74301 + 44 +55.79692422 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.52296822 + 49 +-135.99864342 + 53 +225.0 + 43 +97.20181982 + 44 +55.73340898 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.603758 + 49 +-23.343362 + 53 +243.44 + 43 +96.7169008 + 44 +55.3562012 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.55751222 + 49 +-37.3071136 + 53 +262.88 + 43 +96.4065636 + 44 +54.88073098 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.52391818 + 49 +-135.997696 + 53 +262.88 + 43 +96.33429298 + 44 +54.36517702 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.38306502 + 49 +-136.13854662 + 53 +277.13 + 43 +96.34147102 + 44 +53.99174622 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.4803648 + 49 +-136.04124938 + 53 +296.57 + 43 +96.4595988 + 44 +53.53784822 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.559816 + 49 +-37.30480982 + 53 +315.0 + 43 +96.76747982 + 44 +53.08623622 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.59077098 + 49 +-23.35634902 + 53 +352.88 + 43 +97.2062572 + 44 +52.6658332 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.578866 + 49 +-135.94274818 + 53 +7.13 + 43 +98.1487496 + 44 +52.59291742 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.51824382 + 49 +-136.00336782 + 53 +45.0 + 43 +98.68929462 + 44 +52.65619898 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.60453778 + 49 +-23.34258222 + 53 +63.44 + 43 +99.174808 + 44 +53.03391222 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.55751222 + 49 +-37.3071136 + 53 +82.88 + 43 +99.4851452 + 44 +53.50938498 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.52391818 + 49 +-135.997696 + 53 +82.88 + 43 +100.58544018 + 44 +60.18634902 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.21999702 + 49 +-136.30161462 + 53 +63.44 + 43 +100.4551204 + 44 +59.98670502 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.23410418 + 49 +-37.63052418 + 53 +45.0 + 43 +100.25126 + 44 +59.82810742 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.2538476 + 49 +-23.6932724 + 53 +7.13 + 43 +100.0242856 + 44 +59.80153902 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.21760942 + 49 +-136.30400222 + 53 +352.88 + 43 +99.62853582 + 44 +59.83215618 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.24306022 + 49 +-136.27855142 + 53 +315.0 + 43 +99.444302 + 44 +60.00868618 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.24807418 + 49 +-23.69904582 + 53 +296.57 + 43 +99.315016 + 44 +60.1983048 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.23506938 + 49 +-37.62955898 + 53 +277.13 + 43 +99.26541742 + 44 +60.38888862 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.20170902 + 49 +-136.31990262 + 53 +262.88 + 43 +99.26240498 + 44 +60.54570822 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.16085058 + 49 +-136.3607636 + 53 +262.88 + 43 +99.29274782 + 44 +60.7621848 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.21999702 + 49 +-136.30161462 + 53 +243.44 + 43 +99.42304982 + 44 +60.9618288 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.23410418 + 49 +-37.63052418 + 53 +225.0 + 43 +99.626674 + 44 +61.1202232 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.25350978 + 49 +-23.69361022 + 53 +187.13 + 43 +99.85392018 + 44 +61.1468932 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.21959062 + 49 +-136.30202102 + 53 +172.88 + 43 +100.01053658 + 44 +61.1509572 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.16085058 + 49 +-136.3607636 + 53 +172.88 + 43 +100.24965218 + 44 +61.11638018 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.24306022 + 49 +-136.27855142 + 53 +135.0 + 43 +100.433886 + 44 +60.93986542 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.24807418 + 49 +-23.69904582 + 53 +116.57 + 43 +100.563172 + 44 +60.75022902 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.23506938 + 49 +-37.62955898 + 53 +97.13 + 43 +100.61354782 + 44 +60.55544658 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.20597622 + 49 +-136.31563542 + 53 +82.88 + 43 +100.6161056 + 44 +60.40280782 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.15551658 + 49 +-136.3660976 + 53 +82.88 + 43 +97.50794062 + 44 +65.78770942 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +63.44 + 43 +97.47673418 + 44 +65.73989138 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +45.0 + 43 +97.4278976 + 44 +65.70189298 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.0608076 + 49 +-23.8863124 + 53 +7.13 + 43 +97.37352382 + 44 +65.6955252 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.0521208 + 49 +-136.46949338 + 53 +352.88 + 43 +97.27873102 + 44 +65.70285818 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +315.0 + 43 +97.2346036 + 44 +65.7451568 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +296.57 + 43 +97.20363338 + 44 +65.790572 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +277.13 + 43 +97.19174618 + 44 +65.83622342 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.0483108 + 49 +-136.47330338 + 53 +262.88 + 43 +97.19103498 + 44 +65.8737824 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +262.88 + 43 +97.19829938 + 44 +65.9256492 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +243.44 + 43 +97.22950582 + 44 +65.97346978 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +225.0 + 43 +97.2782916 + 44 +66.0113996 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.06072378 + 49 +-23.88639622 + 53 +187.13 + 43 +97.33271618 + 44 +66.01778262 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.05259578 + 49 +-136.4690184 + 53 +172.88 + 43 +97.3702396 + 44 +66.0187656 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +172.88 + 43 +97.42750898 + 44 +66.0104852 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +135.0 + 43 +97.4716364 + 44 +65.96820182 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +116.57 + 43 +97.50260662 + 44 +65.92277138 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +97.13 + 43 +97.51468178 + 44 +65.8761192 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.04934458 + 49 +-136.4722696 + 53 +82.88 + 43 +97.51529138 + 44 +65.83956098 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.03725418 + 49 +-136.48436 + 53 +82.88 + 43 +98.2182948 + 44 +64.2727696 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.15922498 + 49 +-136.3623892 + 53 +63.44 + 43 +98.12399222 + 44 +64.12827662 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.16943578 + 49 +-37.69519258 + 53 +45.0 + 43 +97.97641822 + 44 +64.01346862 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.18372582 + 49 +-23.76339418 + 53 +7.13 + 43 +97.8121488 + 44 +63.99425098 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.15749778 + 49 +-136.3641164 + 53 +352.88 + 43 +97.52570538 + 44 +64.01639978 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.17593818 + 49 +-136.345676 + 53 +315.0 + 43 +97.3923376 + 44 +64.14417702 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.17954498 + 49 +-23.76757502 + 53 +296.57 + 43 +97.298764 + 44 +64.28142338 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.17014698 + 49 +-37.69448138 + 53 +277.13 + 43 +97.26286618 + 44 +64.4193784 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.1459992 + 49 +-136.37561498 + 53 +262.88 + 43 +97.26069702 + 44 +64.5328656 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.11641582 + 49 +-136.40519582 + 53 +262.88 + 43 +97.2826604 + 44 +64.68956582 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.15922498 + 49 +-136.3623892 + 53 +243.44 + 43 +97.37697822 + 44 +64.8340588 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.16943578 + 49 +-37.69519258 + 53 +225.0 + 43 +97.52434902 + 44 +64.94869662 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.1834896 + 49 +-23.7636304 + 53 +187.13 + 43 +97.68882418 + 44 +64.96800062 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.15893542 + 49 +-136.36267622 + 53 +172.88 + 43 +97.80217422 + 44 +64.9709648 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.11641582 + 49 +-136.40519582 + 53 +172.88 + 43 +97.97524982 + 44 +64.94593818 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.17593818 + 49 +-136.345676 + 53 +135.0 + 43 +98.1086176 + 44 +64.8181584 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.17954498 + 49 +-23.76757502 + 53 +116.57 + 43 +98.2021912 + 44 +64.68091458 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.17014698 + 49 +-37.69448138 + 53 +97.13 + 43 +98.23864782 + 44 +64.5399268 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.14908022 + 49 +-136.37253142 + 53 +82.88 + 43 +98.24051218 + 44 +64.42945458 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.11255502 + 49 +-136.40905662 + 53 +82.88 + 43 +94.03588 + 44 +58.51547098 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.15551658 + 49 +-136.3660976 + 53 +97.13 + 43 +94.03332222 + 44 +58.6681072 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.20597622 + 49 +-136.31563542 + 53 +116.57 + 43 +93.9829464 + 44 +58.8628744 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.23506938 + 49 +-37.62955898 + 53 +135.0 + 43 +93.8536604 + 44 +59.0525108 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.24807418 + 49 +-23.69904582 + 53 +172.88 + 43 +93.66942658 + 44 +59.2290408 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.24306022 + 49 +-136.27855142 + 53 +172.88 + 43 +93.4302932 + 44 +59.26360258 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.16085058 + 49 +-136.3607636 + 53 +187.13 + 43 +93.27369458 + 44 +59.25953858 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.21959062 + 49 +-136.30202102 + 53 +225.0 + 43 +93.0464484 + 44 +59.23286858 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.25350978 + 49 +-23.69361022 + 53 +243.44 + 43 +92.84282422 + 44 +59.07447418 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.23410418 + 49 +-37.63052418 + 53 +262.88 + 43 +92.71252222 + 44 +58.87483018 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.21999702 + 49 +-136.30161462 + 53 +262.88 + 43 +92.68217938 + 44 +58.6583536 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.16085058 + 49 +-136.3607636 + 53 +277.13 + 43 +92.68519182 + 44 +58.50155178 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.20170902 + 49 +-136.31990262 + 53 +296.57 + 43 +92.7347904 + 44 +58.31096542 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.23506938 + 49 +-37.62955898 + 53 +315.0 + 43 +92.86405862 + 44 +58.12132902 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.24807418 + 49 +-23.69904582 + 53 +352.88 + 43 +93.04831022 + 44 +57.94479902 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.24306022 + 49 +-136.27855142 + 53 +7.13 + 43 +93.44406 + 44 +57.9141844 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.21760942 + 49 +-136.30400222 + 53 +45.0 + 43 +93.6710344 + 44 +57.9407528 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.2538476 + 49 +-23.6932724 + 53 +63.44 + 43 +93.8748948 + 44 +58.0993504 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.23410418 + 49 +-37.63052418 + 53 +82.88 + 43 +94.00521458 + 44 +58.29901218 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.21999702 + 49 +-136.30161462 + 53 +82.88 + 43 +90.7421096 + 44 +59.22648302 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.11255502 + 49 +-136.40905662 + 53 +97.13 + 43 +90.74024778 + 44 +59.33695778 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.14908022 + 49 +-136.37253142 + 53 +116.57 + 43 +90.70378862 + 44 +59.47794302 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.17014698 + 49 +-37.69448138 + 53 +135.0 + 43 +90.61021502 + 44 +59.61518938 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.17954498 + 49 +-23.76757502 + 53 +172.88 + 43 +90.47686502 + 44 +59.74296662 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.17593818 + 49 +-136.345676 + 53 +172.88 + 43 +90.30378942 + 44 +59.767978 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.11641582 + 49 +-136.40519582 + 53 +187.13 + 43 +90.1904216 + 44 +59.7650316 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.15893542 + 49 +-136.36267622 + 53 +225.0 + 43 +90.02594898 + 44 +59.7457276 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.1834896 + 49 +-23.7636304 + 53 +243.44 + 43 +89.87857818 + 44 +59.63108978 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.16943578 + 49 +-37.69519258 + 53 +262.88 + 43 +89.78425782 + 44 +59.4865968 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.15922498 + 49 +-136.3623892 + 53 +262.88 + 43 +89.76229698 + 44 +59.32989658 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.11641582 + 49 +-136.40519582 + 53 +277.13 + 43 +89.7644636 + 44 +59.21640938 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.1459992 + 49 +-136.37561498 + 53 +296.57 + 43 +89.8003792 + 44 +59.07845182 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.17014698 + 49 +-37.69448138 + 53 +315.0 + 43 +89.8939528 + 44 +58.941208 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.17954498 + 49 +-23.76757502 + 53 +352.88 + 43 +90.0273028 + 44 +58.81342822 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.17593818 + 49 +-136.345676 + 53 +7.13 + 43 +90.31374622 + 44 +58.79126418 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.15749778 + 49 +-136.3641164 + 53 +45.0 + 43 +90.47803342 + 44 +58.8104996 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.18372582 + 49 +-23.76339418 + 53 +63.44 + 43 +90.62559218 + 44 +58.9253076 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.16943578 + 49 +-37.69519258 + 53 +82.88 + 43 +90.71991 + 44 +59.06980058 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.15922498 + 49 +-136.3623892 + 53 +82.88 + 43 +88.04749218 + 44 +58.09938342 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.21413978 + 49 +-136.3074744 + 53 +97.13 + 43 +88.0439692 + 44 +58.30954302 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.28359862 + 49 +-136.23801302 + 53 +116.57 + 43 +87.97459418 + 44 +58.57771622 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.32366458 + 49 +-37.54096378 + 53 +135.0 + 43 +87.79659098 + 44 +58.83882822 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.34156142 + 49 +-23.60555858 + 53 +172.88 + 43 +87.54291102 + 44 +59.0818732 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.3346704 + 49 +-136.18694378 + 53 +172.88 + 43 +87.21367622 + 44 +59.12949058 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.22147022 + 49 +-136.30014142 + 53 +187.13 + 43 +86.998048 + 44 +59.1238848 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.3023616 + 49 +-136.21925258 + 53 +225.0 + 43 +86.68515302 + 44 +59.0871564 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.34906458 + 49 +-23.59805542 + 53 +243.44 + 43 +86.40478782 + 44 +58.869072 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.322326 + 49 +-37.54229982 + 53 +262.88 + 43 +86.22538 + 44 +58.59417542 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.30290262 + 49 +-136.21870902 + 53 +262.88 + 43 +86.18358938 + 44 +58.29611658 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.22147022 + 49 +-136.30014142 + 53 +277.13 + 43 +86.1877372 + 44 +58.08021658 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.2777236 + 49 +-136.24389058 + 53 +296.57 + 43 +86.25603018 + 44 +57.81779902 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.32366458 + 49 +-37.54096378 + 53 +315.0 + 43 +86.43403338 + 44 +57.5567048 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.34156142 + 49 +-23.60555858 + 53 +352.88 + 43 +86.6877108 + 44 +57.31364458 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.3346704 + 49 +-136.18694378 + 53 +7.13 + 43 +87.23262462 + 44 +57.27148058 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.2996184 + 49 +-136.22199578 + 53 +45.0 + 43 +87.54514622 + 44 +57.30807182 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.349504 + 49 +-23.597616 + 53 +63.44 + 43 +87.825834 + 44 +57.52644578 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.322326 + 49 +-37.54229982 + 53 +82.88 + 43 +88.0052596 + 44 +57.80133982 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.30290262 + 49 +-136.21870902 + 53 +82.88 + 43 +90.0168888 + 44 +60.63658942 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.03725418 + 49 +-136.48436 + 53 +97.13 + 43 +90.0162792 + 44 +60.67315018 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.04934458 + 49 +-136.4722696 + 53 +116.57 + 43 +90.00420658 + 44 +60.71979982 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +135.0 + 43 +89.9732516 + 44 +60.7652328 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +172.88 + 43 +89.9291064 + 44 +60.80751618 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +172.88 + 43 +89.87183702 + 44 +60.81579658 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +187.13 + 43 +89.8343136 + 44 +60.8148136 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.05259578 + 49 +-136.4690184 + 53 +225.0 + 43 +89.77988902 + 44 +60.80843058 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.06072378 + 49 +-23.88639622 + 53 +243.44 + 43 +89.73110578 + 44 +60.77049822 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +262.88 + 43 +89.6998968 + 44 +60.7226624 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +262.88 + 43 +89.6926324 + 44 +60.67081338 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.03852418 + 49 +-136.48309 + 53 +277.13 + 43 +89.6933436 + 44 +60.6332544 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.0483108 + 49 +-136.47330338 + 53 +296.57 + 43 +89.7052308 + 44 +60.58760298 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.05630418 + 49 +-37.80832418 + 53 +315.0 + 43 +89.73620102 + 44 +60.54218778 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.05941822 + 49 +-23.88770178 + 53 +352.88 + 43 +89.78033098 + 44 +60.49988662 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.0582168 + 49 +-136.46339738 + 53 +7.13 + 43 +89.87512378 + 44 +60.49255618 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.0521208 + 49 +-136.46949338 + 53 +45.0 + 43 +89.92949502 + 44 +60.49892142 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.0608076 + 49 +-23.8863124 + 53 +63.44 + 43 +89.9783316 + 44 +60.53691982 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.0560832 + 49 +-37.80854262 + 53 +82.88 + 43 +90.00954058 + 44 +60.5847404 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.05269738 + 49 +-136.4689168 + 53 +82.88 + 43 +91.272614 + 44 +55.4477428 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.52391818 + 49 +-135.997696 + 53 +63.44 + 43 +90.9622768 + 44 +54.97227258 + 45 +-0.0014916558 + 46 +16.9328188651 + 79 +2 + 49 +0.55751222 + 49 +-37.3071136 + 53 +45.0 + 43 +90.47676342 + 44 +54.5945568 + 45 +0.0 + 46 +16.9331709419 + 79 +2 + 49 +0.60453778 + 49 +-23.34258222 + 53 +7.13 + 43 +89.9362184 + 44 +54.53127778 + 45 +118.5322702695 + 46 +16.9431028479 + 79 +2 + 49 +0.51824382 + 49 +-136.00336782 + 53 +352.88 + 43 +88.993726 + 44 +54.6042088 + 45 +118.5352255934 + 46 +-16.9224148061 + 79 +2 + 49 +0.578866 + 49 +-135.94274818 + 53 +315.0 + 43 +88.55494862 + 44 +55.02461182 + 45 +16.9331709419 + 46 +-0.0 + 79 +2 + 49 +0.59077098 + 49 +-23.35634902 + 53 +296.57 + 43 +88.2470676 + 44 +55.47622382 + 45 +16.9342802744 + 46 +-16.9313563251 + 79 +2 + 49 +0.559816 + 49 +-37.30480982 + 53 +277.13 + 43 +88.12893982 + 44 +55.93010658 + 45 +16.9431028479 + 46 +-118.5322702695 + 79 +2 + 49 +0.4803648 + 49 +-136.04124938 + 53 +262.88 + 43 +88.12176178 + 44 +56.30353738 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.38306502 + 49 +-136.13854662 + 53 +262.88 + 43 +88.1940324 + 44 +56.8190888 + 45 +0.0008534187 + 46 +-16.9336313627 + 79 +2 + 49 +0.52391818 + 49 +-135.997696 + 53 +243.44 + 43 +88.5043696 + 44 +57.2945768 + 45 +0.0014916558 + 46 +-16.9328188651 + 79 +2 + 49 +0.55751222 + 49 +-37.3071136 + 53 +225.0 + 43 +88.98928862 + 44 +57.6717668 + 45 +-0.0 + 46 +-16.9331709419 + 79 +2 + 49 +0.603758 + 49 +-23.343362 + 53 +187.13 + 43 +89.5304788 + 44 +57.73528458 + 45 +-118.5322702695 + 46 +-16.9431028479 + 79 +2 + 49 +0.52296822 + 49 +-135.99864342 + 53 +172.88 + 43 +89.9034524 + 44 +57.74498738 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.38306502 + 49 +-136.13854662 + 53 +172.88 + 43 +90.4729204 + 44 +57.66264058 + 45 +-118.5352255934 + 46 +16.9224148061 + 79 +2 + 49 +0.578866 + 49 +-135.94274818 + 53 +135.0 + 43 +90.91169778 + 44 +57.24223502 + 45 +-16.9331709419 + 46 +0.0 + 79 +2 + 49 +0.59077098 + 49 +-23.35634902 + 53 +116.57 + 43 +91.2195788 + 44 +56.79062302 + 45 +-16.9342802744 + 46 +16.9313563251 + 79 +2 + 49 +0.559816 + 49 +-37.30480982 + 53 +97.13 + 43 +91.3395684 + 44 +56.32676822 + 45 +-16.9431028479 + 46 +118.5322702695 + 79 +2 + 49 +0.49054258 + 49 +-136.0310716 + 53 +82.88 + 43 +91.3456644 + 44 +55.96324342 + 45 +-0.0008534187 + 46 +16.9336313627 + 79 +2 + 49 +0.37036502 + 49 +-136.15124662 + 98 +0 + 0 +LWPOLYLINE + 5 +AB +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +140.0 + 20 +-660.0 + 10 +240.0 + 20 +-660.0 + 10 +240.0 + 20 +-560.0 + 10 +140.0 + 20 +-560.0 + 0 +TEXT + 5 +AC +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-674.0 + 30 +0.0 + 40 +9.0 + 1 +CONCRETE3 + 7 +LABEL + 11 +140.0 + 21 +-674.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +AD +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +CORK + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +-660.0 + 10 +380.0 + 20 +-660.0 + 10 +380.0 + 20 +-560.0 + 10 +280.0 + 20 +-560.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +4 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +3.81 + 79 +0 + 53 +135.0 + 43 +1.905 + 44 +-1.905 + 45 +-7.6199999872 + 46 +-7.6199999872 + 79 +2 + 49 +5.3881536941 + 49 +-5.3881536941 + 53 +135.0 + 43 +2.8575 + 44 +-1.905 + 45 +-7.6199999872 + 46 +-7.6199999872 + 79 +2 + 49 +5.3881536941 + 49 +-5.3881536941 + 53 +135.0 + 43 +3.81 + 44 +-1.905 + 45 +-7.6199999872 + 46 +-7.6199999872 + 79 +2 + 49 +5.3881536941 + 49 +-5.3881536941 + 98 +0 + 0 +LWPOLYLINE + 5 +AE +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +280.0 + 20 +-660.0 + 10 +380.0 + 20 +-660.0 + 10 +380.0 + 20 +-560.0 + 10 +280.0 + 20 +-560.0 + 0 +TEXT + 5 +AF +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-674.0 + 30 +0.0 + 40 +9.0 + 1 +CORK + 7 +LABEL + 11 +280.0 + 21 +-674.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +B0 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +CROSS + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +-660.0 + 10 +520.0 + 20 +-660.0 + 10 +520.0 + 20 +-560.0 + 10 +420.0 + 20 +-560.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +6.35 + 46 +6.35 + 79 +2 + 49 +3.175 + 49 +-9.525 + 53 +90.0 + 43 +1.5875 + 44 +-1.5875 + 45 +-6.35 + 46 +6.35 + 79 +2 + 49 +3.175 + 49 +-9.525 + 98 +0 + 0 +LWPOLYLINE + 5 +B1 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +420.0 + 20 +-660.0 + 10 +520.0 + 20 +-660.0 + 10 +520.0 + 20 +-560.0 + 10 +420.0 + 20 +-560.0 + 0 +TEXT + 5 +B2 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-674.0 + 30 +0.0 + 40 +9.0 + 1 +CROSS + 7 +LABEL + 11 +420.0 + 21 +-674.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +B3 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +CROSSES + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +-660.0 + 10 +660.0 + 20 +-660.0 + 10 +660.0 + 20 +-560.0 + 10 +560.0 + 20 +-560.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +-4.48945 + 44 +0.0 + 45 +-8.9802561211 + 46 +8.9802561211 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +135.0 + 43 +0.0 + 44 +0.0 + 45 +-8.9802561211 + 46 +-8.9802561211 + 79 +2 + 49 +6.35 + 49 +-6.35 + 98 +0 + 0 +LWPOLYLINE + 5 +B4 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +560.0 + 20 +-660.0 + 10 +660.0 + 20 +-660.0 + 10 +660.0 + 20 +-560.0 + 10 +560.0 + 20 +-560.0 + 0 +TEXT + 5 +B5 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-674.0 + 30 +0.0 + 40 +9.0 + 1 +CROSSES + 7 +LABEL + 11 +560.0 + 21 +-674.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +B6 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +DOLMIT + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +-660.0 + 10 +800.0 + 20 +-660.0 + 10 +800.0 + 20 +-560.0 + 10 +700.0 + 20 +-560.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +6.35 + 79 +0 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-12.6999913435 + 46 +12.6999913435 + 79 +2 + 49 +8.9802561211 + 49 +-17.9605122421 + 98 +0 + 0 +LWPOLYLINE + 5 +B7 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +700.0 + 20 +-660.0 + 10 +800.0 + 20 +-660.0 + 10 +800.0 + 20 +-560.0 + 10 +700.0 + 20 +-560.0 + 0 +TEXT + 5 +B8 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-674.0 + 30 +0.0 + 40 +9.0 + 1 +DOLMIT + 7 +LABEL + 11 +700.0 + 21 +-674.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +B9 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +DOTS + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +-660.0 + 10 +940.0 + 20 +-660.0 + 10 +940.0 + 20 +-560.0 + 10 +840.0 + 20 +-560.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +1 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.79375 + 46 +1.5875 + 79 +2 + 49 +0.0 + 49 +-1.5875 + 98 +0 + 0 +LWPOLYLINE + 5 +BA +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +-660.0 + 10 +940.0 + 20 +-660.0 + 10 +940.0 + 20 +-560.0 + 10 +840.0 + 20 +-560.0 + 0 +TEXT + 5 +BB +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-674.0 + 30 +0.0 + 40 +9.0 + 1 +DOTS + 7 +LABEL + 11 +840.0 + 21 +-674.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +BC +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +DOTS1 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +980.0 + 20 +-660.0 + 10 +1080.0 + 20 +-660.0 + 10 +1080.0 + 20 +-560.0 + 10 +980.0 + 20 +-560.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +1 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.79375 + 46 +1.5875 + 79 +2 + 49 +0.0 + 49 +-1.5875 + 98 +0 + 0 +LWPOLYLINE + 5 +BD +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +980.0 + 20 +-660.0 + 10 +1080.0 + 20 +-660.0 + 10 +1080.0 + 20 +-560.0 + 10 +980.0 + 20 +-560.0 + 0 +TEXT + 5 +BE +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +980.0 + 20 +-674.0 + 30 +0.0 + 40 +9.0 + 1 +DOTS1 + 7 +LABEL + 11 +980.0 + 21 +-674.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +BF +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +DOTS2 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-825.0 + 10 +100.0 + 20 +-825.0 + 10 +100.0 + 20 +-725.0 + 10 +0.0 + 20 +-725.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +17 + 53 +0.0 + 43 +1.016 + 44 +0.508 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +9.144 + 44 +1.016 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +4.572 + 44 +6.604 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +10.16 + 44 +10.668 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +3.556 + 44 +12.192 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +5.588 + 44 +18.288 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +2.032 + 44 +22.86 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +10.16 + 44 +22.352 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +16.256 + 44 +23.368 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +19.304 + 44 +17.272 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +24.384 + 44 +16.764 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +24.384 + 44 +7.62 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +19.812 + 44 +4.572 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +12.7 + 44 +6.096 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +17.272 + 44 +10.668 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +12.7 + 44 +15.24 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 53 +0.0 + 43 +23.368 + 44 +1.016 + 45 +0.0 + 46 +25.4 + 79 +2 + 49 +0.0 + 49 +-25.4 + 98 +0 + 0 +LWPOLYLINE + 5 +C0 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-825.0 + 10 +100.0 + 20 +-825.0 + 10 +100.0 + 20 +-725.0 + 10 +0.0 + 20 +-725.0 + 0 +TEXT + 5 +C1 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-839.0 + 30 +0.0 + 40 +9.0 + 1 +DOTS2 + 7 +LABEL + 11 +0.0 + 21 +-839.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +C2 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +EARTH + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +-825.0 + 10 +240.0 + 20 +-825.0 + 10 +240.0 + 20 +-725.0 + 10 +140.0 + 20 +-725.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +6 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +0.0 + 43 +0.0 + 44 +2.38125 + 45 +6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +0.0 + 43 +0.0 + 44 +4.7625 + 45 +6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +0.79375 + 44 +5.55625 + 45 +-6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +3.175 + 44 +5.55625 + 45 +-6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +5.55625 + 44 +5.55625 + 45 +-6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 98 +0 + 0 +LWPOLYLINE + 5 +C3 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +140.0 + 20 +-825.0 + 10 +240.0 + 20 +-825.0 + 10 +240.0 + 20 +-725.0 + 10 +140.0 + 20 +-725.0 + 0 +TEXT + 5 +C4 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-839.0 + 30 +0.0 + 40 +9.0 + 1 +EARTH + 7 +LABEL + 11 +140.0 + 21 +-839.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +C5 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +EARTH1 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +-825.0 + 10 +380.0 + 20 +-825.0 + 10 +380.0 + 20 +-725.0 + 10 +280.0 + 20 +-725.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +6 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +0.0 + 43 +0.0 + 44 +2.38125 + 45 +6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +0.0 + 43 +0.0 + 44 +4.7625 + 45 +6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +0.79375 + 44 +5.55625 + 45 +-6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +3.175 + 44 +5.55625 + 45 +-6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +5.55625 + 44 +5.55625 + 45 +-6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 98 +0 + 0 +LWPOLYLINE + 5 +C6 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +280.0 + 20 +-825.0 + 10 +380.0 + 20 +-825.0 + 10 +380.0 + 20 +-725.0 + 10 +280.0 + 20 +-725.0 + 0 +TEXT + 5 +C7 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-839.0 + 30 +0.0 + 40 +9.0 + 1 +EARTH1 + 7 +LABEL + 11 +280.0 + 21 +-839.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +C8 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +EARTH2 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +-825.0 + 10 +520.0 + 20 +-825.0 + 10 +520.0 + 20 +-725.0 + 10 +420.0 + 20 +-725.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +12 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +13.4703841816 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +45.0 + 43 +-1.3470384159 + 44 +1.3470384159 + 45 +0.0 + 46 +13.4703841816 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +45.0 + 43 +-2.6940768128 + 44 +2.6940768128 + 45 +0.0 + 46 +13.4703841816 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +45.0 + 43 +-4.0411152668 + 44 +4.0411152668 + 45 +0.0 + 46 +13.4703841816 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +45.0 + 43 +-5.388153435 + 44 +5.388153435 + 45 +0.0 + 46 +13.4703841816 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +45.0 + 43 +-6.73519227 + 44 +6.73519227 + 45 +0.0 + 46 +13.4703841816 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +135.0 + 43 +0.0 + 44 +0.0 + 45 +-13.4703841816 + 46 +0.0 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +135.0 + 43 +-1.3470384159 + 44 +-1.3470384159 + 45 +-13.4703841816 + 46 +0.0 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +135.0 + 43 +-2.6940768128 + 44 +-2.6940768128 + 45 +-13.4703841816 + 46 +0.0 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +135.0 + 43 +-4.0411152668 + 44 +-4.0411152668 + 45 +-13.4703841816 + 46 +0.0 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +135.0 + 43 +-5.388153435 + 44 +-5.388153435 + 45 +-13.4703841816 + 46 +0.0 + 79 +2 + 49 +9.525 + 49 +-9.525 + 53 +135.0 + 43 +-6.73519227 + 44 +-6.73519227 + 45 +-13.4703841816 + 46 +0.0 + 79 +2 + 49 +9.525 + 49 +-9.525 + 98 +0 + 0 +LWPOLYLINE + 5 +C9 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +420.0 + 20 +-825.0 + 10 +520.0 + 20 +-825.0 + 10 +520.0 + 20 +-725.0 + 10 +420.0 + 20 +-725.0 + 0 +TEXT + 5 +CA +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-839.0 + 30 +0.0 + 40 +9.0 + 1 +EARTH2 + 7 +LABEL + 11 +420.0 + 21 +-839.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +CB +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ESCHER + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +-825.0 + 10 +660.0 + 20 +-825.0 + 10 +660.0 + 20 +-725.0 + 10 +560.0 + 20 +-725.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +21 + 53 +60.0 + 43 +0.0 + 44 +0.0 + 45 +-30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +27.94 + 49 +-2.54 + 53 +180.0 + 43 +0.0 + 44 +0.0 + 45 +15.24 + 46 +-26.3964542936 + 79 +2 + 49 +27.94 + 49 +-2.54 + 53 +300.0 + 43 +0.0 + 44 +0.0 + 45 +30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +27.94 + 49 +-2.54 + 53 +60.0 + 43 +2.54 + 44 +0.0 + 45 +-30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +300.0 + 43 +2.54 + 44 +0.0 + 45 +30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +60.0 + 43 +-1.27 + 44 +2.199704516 + 45 +-30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +180.0 + 43 +-1.27 + 44 +2.199704516 + 45 +15.24 + 46 +-26.3964542936 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +300.0 + 43 +-1.27 + 44 +-2.199704516 + 45 +30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +180.0 + 43 +-1.27 + 44 +-2.199704516 + 45 +15.24 + 46 +-26.3964542936 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +60.0 + 43 +-10.16 + 44 +0.0 + 45 +-30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +300.0 + 43 +-10.16 + 44 +0.0 + 45 +30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +60.0 + 43 +5.08 + 44 +-8.7988180894 + 45 +-30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +180.0 + 43 +5.08 + 44 +-8.7988180894 + 45 +15.24 + 46 +-26.3964542936 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +300.0 + 43 +5.08 + 44 +8.7988180894 + 45 +30.4799999881 + 46 +-6.9e-09 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +180.0 + 43 +5.08 + 44 +8.7988180894 + 45 +15.24 + 46 +-26.3964542936 + 79 +2 + 49 +5.08 + 49 +-25.4 + 53 +0.0 + 43 +5.08 + 44 +4.3994090574 + 45 +-15.24 + 46 +26.3964542936 + 79 +2 + 49 +17.78 + 49 +-12.7 + 53 +0.0 + 43 +5.08 + 44 +-4.3994090574 + 45 +-15.24 + 46 +26.3964542936 + 79 +2 + 49 +17.78 + 49 +-12.7 + 53 +120.0 + 43 +1.27 + 44 +6.5991135734 + 45 +-30.4799999881 + 46 +6.9e-09 + 79 +2 + 49 +17.78 + 49 +-12.7 + 53 +120.0 + 43 +-6.35 + 44 +2.199704516 + 45 +-30.4799999881 + 46 +6.9e-09 + 79 +2 + 49 +17.78 + 49 +-12.7 + 53 +240.0 + 43 +-6.35 + 44 +-2.199704516 + 45 +15.2399999881 + 46 +-26.3964543005 + 79 +2 + 49 +17.78 + 49 +-12.7 + 53 +240.0 + 43 +1.27 + 44 +-6.5991135734 + 45 +15.2399999881 + 46 +-26.3964543005 + 79 +2 + 49 +17.78 + 49 +-12.7 + 98 +0 + 0 +LWPOLYLINE + 5 +CC +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +560.0 + 20 +-825.0 + 10 +660.0 + 20 +-825.0 + 10 +660.0 + 20 +-725.0 + 10 +560.0 + 20 +-725.0 + 0 +TEXT + 5 +CD +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-839.0 + 30 +0.0 + 40 +9.0 + 1 +ESCHER + 7 +LABEL + 11 +560.0 + 21 +-839.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +CE +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +GRASS + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +-825.0 + 10 +800.0 + 20 +-825.0 + 10 +800.0 + 20 +-725.0 + 10 +700.0 + 20 +-725.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-17.96051224 + 46 +17.96051224 + 79 +2 + 49 +4.7625 + 49 +-31.15852448 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-17.9605122421 + 46 +17.9605122421 + 79 +2 + 49 +4.7625 + 49 +-20.6375 + 53 +135.0 + 43 +0.0 + 44 +0.0 + 45 +-17.9605122421 + 46 +-17.9605122421 + 79 +2 + 49 +4.7625 + 49 +-20.6375 + 98 +0 + 0 +LWPOLYLINE + 5 +CF +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +700.0 + 20 +-825.0 + 10 +800.0 + 20 +-825.0 + 10 +800.0 + 20 +-725.0 + 10 +700.0 + 20 +-725.0 + 0 +TEXT + 5 +D0 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-839.0 + 30 +0.0 + 40 +9.0 + 1 +GRASS + 7 +LABEL + 11 +700.0 + 21 +-839.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +D1 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +GRASS1 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +-825.0 + 10 +940.0 + 20 +-825.0 + 10 +940.0 + 20 +-725.0 + 10 +840.0 + 20 +-725.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-14.3684097899 + 46 +14.3684097899 + 79 +2 + 49 +3.81 + 49 +-24.9268196002 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-14.3684097937 + 46 +14.3684097937 + 79 +2 + 49 +3.81 + 49 +-16.51 + 53 +135.0 + 43 +0.0 + 44 +0.0 + 45 +-14.3684097937 + 46 +-14.3684097937 + 79 +2 + 49 +3.81 + 49 +-16.51 + 98 +0 + 0 +LWPOLYLINE + 5 +D2 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +-825.0 + 10 +940.0 + 20 +-825.0 + 10 +940.0 + 20 +-725.0 + 10 +840.0 + 20 +-725.0 + 0 +TEXT + 5 +D3 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-839.0 + 30 +0.0 + 40 +9.0 + 1 +GRASS1 + 7 +LABEL + 11 +840.0 + 21 +-839.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +D4 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +GRASS2 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +980.0 + 20 +-825.0 + 10 +1080.0 + 20 +-825.0 + 10 +1080.0 + 20 +-725.0 + 10 +980.0 + 20 +-725.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +6 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +10.16 + 46 +17.597636189 + 79 +2 + 49 +2.54 + 49 +-17.78 + 53 +90.0 + 43 +1.27 + 44 +0.0 + 45 +-10.16 + 46 +17.597636189 + 79 +2 + 49 +1.27 + 49 +-33.9252723779 + 53 +90.0 + 43 +1.5875 + 44 +0.0 + 45 +-10.16 + 46 +17.597636189 + 79 +2 + 49 +1.016 + 49 +-34.1792723779 + 53 +90.0 + 43 +0.9525 + 44 +0.0 + 45 +-10.16 + 46 +17.597636189 + 79 +2 + 49 +1.016 + 49 +-34.1792723779 + 53 +60.0 + 43 +1.905 + 44 +0.0 + 45 +-10.1599999862 + 46 +17.5976361969 + 79 +2 + 49 +0.8128 + 49 +-19.5072 + 53 +120.0 + 43 +0.635 + 44 +0.0 + 45 +-20.3199999862 + 46 +8e-09 + 79 +2 + 49 +0.8128 + 49 +-19.5072 + 98 +0 + 0 +LWPOLYLINE + 5 +D5 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +980.0 + 20 +-825.0 + 10 +1080.0 + 20 +-825.0 + 10 +1080.0 + 20 +-725.0 + 10 +980.0 + 20 +-725.0 + 0 +TEXT + 5 +D6 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +980.0 + 20 +-839.0 + 30 +0.0 + 40 +9.0 + 1 +GRASS2 + 7 +LABEL + 11 +980.0 + 21 +-839.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +D7 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +GRAVEL + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-990.0 + 10 +100.0 + 20 +-990.0 + 10 +100.0 + 20 +-890.0 + 10 +0.0 + 20 +-890.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +41 + 53 +228.0127875 + 43 +18.288 + 44 +25.4 + 45 +-203.200000016 + 46 +-228.5999999858 + 79 +2 + 49 +3.4172144 + 49 +-338.3048363956 + 53 +184.969741 + 43 +16.002 + 44 +22.86 + 45 +304.7999998794 + 46 +25.400001447 + 79 +2 + 49 +5.8640472 + 49 +-580.5404889352 + 53 +132.5104471 + 43 +10.16 + 44 +22.352 + 45 +254.0000001073 + 46 +-279.3999999025 + 79 +2 + 49 +4.1348152 + 49 +-409.347227941 + 53 +267.273689 + 43 +0.254 + 44 +16.002 + 45 +25.4000000532 + 46 +507.9999999973 + 79 +2 + 49 +5.3400452 + 49 +-528.6643742574 + 53 +292.83365418 + 43 +0.0 + 44 +10.668 + 45 +-127.0000000106 + 46 +304.7999999956 + 79 +2 + 49 +5.236337 + 49 +-518.3980774534 + 53 +357.273689 + 43 +2.032 + 44 +5.842 + 45 +-507.9999999973 + 46 +25.4000000532 + 79 +2 + 49 +5.3400452 + 49 +-528.6643742574 + 53 +37.69424047 + 43 +7.366 + 44 +5.588 + 45 +-330.1999999854 + 46 +-254.000000019 + 79 +2 + 49 +7.0619366 + 49 +-699.1311531425 + 53 +72.25532837 + 43 +12.954 + 44 +9.906 + 45 +177.8000000478 + 46 +558.7999999848 + 79 +2 + 49 +6.6671952 + 49 +-660.0525660191 + 53 +121.42956562 + 43 +14.986 + 44 +16.256 + 45 +-203.2000000288 + 46 +330.1999999823 + 79 +2 + 49 +5.35813 + 49 +-530.4554569871 + 53 +175.2363583 + 43 +12.192 + 44 +20.828 + 45 +279.399999996 + 46 +-25.4000000439 + 79 +2 + 49 +6.1171328 + 49 +-299.7393695 + 53 +222.3974378 + 43 +6.096 + 44 +21.336 + 45 +-304.7999999902 + 46 +-279.4000000106 + 79 +2 + 49 +7.9107792 + 49 +-783.1677251218 + 53 +138.81407483 + 43 +25.4 + 44 +15.748 + 45 +-177.7999999893 + 46 +152.4000000124 + 79 +2 + 49 +2.7000454 + 49 +-267.3056582434 + 53 +171.4692344 + 43 +23.368 + 44 +17.526 + 45 +330.2000000089 + 46 +-50.7999999424 + 79 +2 + 49 +5.1368198 + 49 +-508.5463899704 + 53 +225.0 + 43 +18.288 + 44 +18.288 + 45 +-0.0 + 46 +-25.4 + 79 +2 + 49 +3.5920934 + 49 +-32.3289310843 + 53 +203.19859051 + 43 +16.51 + 44 +21.336 + 45 +127.0000000035 + 46 +50.7999999911 + 79 +2 + 49 +1.9344132 + 49 +-191.5062236889 + 53 +291.80140949 + 43 +14.732 + 44 +20.574 + 45 +-25.4000000053 + 46 +76.1999999982 + 79 +2 + 49 +2.7356562 + 49 +-134.0475299 + 53 +30.96375653 + 43 +15.748 + 44 +18.034 + 45 +76.2000000019 + 46 +50.7999999972 + 79 +2 + 49 +4.4431966 + 49 +-143.6629815291 + 53 +161.56505118 + 43 +19.558 + 44 +20.32 + 45 +50.8000000013 + 46 +-25.3999999973 + 79 +2 + 49 +3.2128714 + 49 +-77.1089811683 + 53 +16.389540334 + 43 +0.0 + 44 +20.574 + 45 +254.0 + 46 +76.2 + 79 +2 + 49 +4.50088 + 49 +-445.5882667254 + 53 +70.34617594 + 43 +4.318 + 44 +21.844 + 45 +-101.6000000093 + 46 +-279.3999999967 + 79 +2 + 49 +3.7759894 + 49 +-373.822156782 + 53 +293.19859051 + 43 +19.558 + 44 +25.4 + 45 +-50.7999999911 + 46 +127.0000000036 + 79 +2 + 49 +3.868801 + 49 +-189.5718358889 + 53 +343.61045967 + 43 +21.082 + 44 +21.844 + 45 +-254.0000000053 + 46 +76.1999999822 + 79 +2 + 49 +4.50088 + 49 +-445.5882667254 + 53 +339.44395478 + 43 +0.0 + 44 +4.826 + 45 +-127.0 + 46 +50.8 + 79 +2 + 49 +4.340352 + 49 +-212.6773431311 + 53 +294.7751406 + 43 +4.064 + 44 +3.302 + 45 +-127.0000001512 + 46 +279.3999999313 + 79 +2 + 49 +3.6367212 + 49 +-360.0359338072 + 53 +66.80140949 + 43 +19.812 + 44 +0.0 + 45 +50.799999992 + 46 +127.0000000032 + 79 +2 + 49 +3.868801 + 49 +-189.5718358889 + 53 +17.35402464 + 43 +21.336 + 44 +3.556 + 45 +-330.1999999934 + 46 +-101.6000000213 + 79 +2 + 49 +4.2578274 + 49 +-421.523759802 + 53 +69.44395478 + 43 +7.366 + 44 +0.0 + 45 +-50.8000000009 + 46 +-126.9999999996 + 79 +2 + 49 +2.170176 + 49 +-214.8475191311 + 53 +101.309932474 + 43 +18.288 + 44 +0.0 + 45 +-25.4 + 46 +101.6 + 79 +2 + 49 +1.295146 + 49 +-128.2199496453 + 53 +165.963756532 + 43 +18.034 + 44 +1.27 + 45 +76.2 + 46 +-25.4 + 79 +2 + 49 +5.236337 + 49 +-99.4905458907 + 53 +186.00900596 + 43 +12.954 + 44 +2.54 + 45 +253.9999999987 + 46 +25.4000000133 + 79 +2 + 49 +4.85267 + 49 +-480.4136486334 + 53 +303.69006753 + 43 +15.748 + 44 +15.748 + 45 +-25.4000000035 + 46 +50.7999999982 + 79 +2 + 49 +3.6632388 + 49 +-87.9177635968 + 53 +353.15722659 + 43 +17.78 + 44 +12.7 + 45 +431.8000000027 + 46 +-50.7999999774 + 79 +2 + 49 +6.3955676 + 49 +-633.1600906503 + 53 +60.9453959 + 43 +24.13 + 44 +11.938 + 45 +-101.6000000028 + 46 +-177.7999999984 + 79 +2 + 49 +2.6150824 + 49 +-258.8939231811 + 53 +90.0 + 43 +25.4 + 44 +14.224 + 45 +-25.4 + 46 +25.4 + 79 +2 + 49 +1.524 + 49 +-23.876 + 53 +120.25643716 + 43 +12.446 + 44 +3.302 + 45 +101.5999999876 + 46 +-177.8000000071 + 79 +2 + 49 +3.5286696 + 49 +-349.339407732 + 53 +48.0127875 + 43 +10.668 + 44 +6.35 + 45 +203.2000000168 + 46 +228.5999999851 + 79 +2 + 49 +6.8344288 + 49 +-334.8876219957 + 53 +0.0 + 43 +15.24 + 44 +11.43 + 45 +25.4 + 46 +25.4 + 79 +2 + 49 +6.604 + 49 +-18.796 + 53 +325.3048465 + 43 +21.844 + 44 +11.43 + 45 +254.0000000962 + 46 +-177.7999998625 + 79 +2 + 49 +4.0160956 + 49 +-397.5931672414 + 53 +254.0546041 + 43 +25.146 + 44 +9.144 + 45 +-25.3999999982 + 46 +-101.6000000004 + 79 +2 + 49 +3.6982908 + 49 +-181.2165003877 + 53 +207.64597536 + 43 +24.13 + 44 +5.588 + 45 +-482.6000000177 + 46 +-253.9999999663 + 79 +2 + 49 +6.021451 + 49 +-596.1246442294 + 53 +175.42607874 + 43 +18.796 + 44 +2.794 + 45 +-330.2 + 46 +25.4 + 79 +2 + 49 +6.3702946 + 49 +-630.6584645624 + 98 +0 + 0 +LWPOLYLINE + 5 +D8 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-990.0 + 10 +100.0 + 20 +-990.0 + 10 +100.0 + 20 +-890.0 + 10 +0.0 + 20 +-890.0 + 0 +TEXT + 5 +D9 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-1004.0 + 30 +0.0 + 40 +9.0 + 1 +GRAVEL + 7 +LABEL + 11 +0.0 + 21 +-1004.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +DA +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +GRAVEL1 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +-990.0 + 10 +240.0 + 20 +-990.0 + 10 +240.0 + 20 +-890.0 + 10 +140.0 + 20 +-890.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +41 + 53 +228.0128 + 43 +12.9600000032 + 44 +17.9999999816 + 45 +-143.9999681763 + 46 +-162.000024158 + 79 +2 + 49 +2.4216479939 + 49 +-239.7435839968 + 53 +184.9697 + 43 +11.3399999971 + 44 +16.2000000155 + 45 +216.0000189102 + 46 +17.9998435066 + 79 +2 + 49 +4.1556239824 + 49 +-411.4066499867 + 53 +132.5104 + 43 +7.2000000018 + 44 +15.840000004 + 45 +179.9998329469 + 46 +-198.0001561228 + 79 +2 + 49 +2.9301840101 + 49 +-290.0885759914 + 53 +267.2737 + 43 +0.1800000058 + 44 +11.3399999971 + 45 +17.9999283207 + 46 +360.0000052976 + 79 +2 + 49 +3.7842839935 + 49 +-374.6440439896 + 53 +292.8337 + 43 +0.0 + 44 +7.5600000133 + 45 +-90.0001782992 + 46 +215.999935404 + 79 +2 + 49 +3.7107900079 + 49 +-367.3687139784 + 53 +357.2737 + 43 +1.4400000004 + 44 +4.1399999953 + 45 +-360.0000052976 + 46 +17.9999283207 + 79 +2 + 49 +3.7842839935 + 49 +-374.6440439896 + 53 +37.6942 + 43 +5.2199999842 + 44 +3.9599999896 + 45 +-234.0001322119 + 46 +-179.9998272812 + 79 +2 + 49 +5.0045219899 + 49 +-495.4472817984 + 53 +72.2553 + 43 +9.1800000194 + 44 +7.019999996 + 45 +126.0001937207 + 46 +395.9999321528 + 79 +2 + 49 +4.724783987 + 49 +-467.7537780288 + 53 +121.4296 + 43 +10.6200000198 + 44 +11.5200000029 + 45 +-144.0001482242 + 46 +233.9999106564 + 79 +2 + 49 +3.7970999953 + 49 +-375.9133139885 + 53 +175.2364 + 43 +8.6400000022 + 44 +14.7600000151 + 45 +198.0000170247 + 46 +-17.9998475922 + 79 +2 + 49 +4.3349759986 + 49 +-212.4137339982 + 53 +222.3974 + 43 +4.3200000011 + 44 +15.1199999809 + 45 +-216.0001323074 + 46 +-197.9998574727 + 79 +2 + 49 +5.6060640076 + 49 +-555.0007501944 + 53 +138.8141 + 43 +17.9999999816 + 44 +11.1599999914 + 45 +-126.0000530418 + 46 +107.9999516683 + 79 +2 + 49 +1.9134179816 + 49 +-189.4291919867 + 53 +171.4692 + 43 +16.5599999813 + 44 +12.419999986 + 45 +233.9999691401 + 46 +-36.0001423904 + 79 +2 + 49 +3.6402659935 + 49 +-360.3871980151 + 53 +225.0 + 43 +12.9600000032 + 44 +12.9600000032 + 45 +-0.0 + 46 +-18.000005591 + 79 +2 + 49 +2.5455779975 + 49 +-22.9102560184 + 53 +203.1986 + 43 +11.7000000086 + 44 +15.1199999809 + 45 +89.9999951667 + 46 +36.0000238407 + 79 +2 + 49 +1.3708440198 + 49 +-135.7130699906 + 53 +291.8014 + 43 +10.440000014 + 44 +14.5800000094 + 45 +-17.9999983704 + 46 +54.0000049026 + 79 +2 + 49 +1.9386540043 + 49 +-94.9943160054 + 53 +30.9638 + 43 +11.1599999914 + 44 +12.7799999975 + 45 +53.9999644169 + 46 +36.0000446951 + 79 +2 + 49 +3.1487219982 + 49 +-101.8084140155 + 53 +161.5651 + 43 +13.8599999863 + 44 +14.4000000036 + 45 +36.0000078261 + 46 +-17.9999712785 + 79 +2 + 49 +2.2768379863 + 49 +-54.644165987 + 53 +16.3895 + 43 +0.0 + 44 +14.5800000094 + 45 +180.0000395482 + 46 +53.999868792 + 79 +2 + 49 +3.1896000216 + 49 +-315.7712100108 + 53 +70.3462 + 43 +3.0600000065 + 44 +15.4799999924 + 45 +-71.9999144577 + 46 +-198.0000384368 + 79 +2 + 49 +2.6758980112 + 49 +-264.913343995 + 53 +293.1986 + 43 +13.8599999863 + 44 +17.9999999816 + 45 +-36.0000238407 + 46 +89.9999951667 + 79 +2 + 49 +2.7416699802 + 49 +-134.3422439845 + 53 +343.6105 + 43 +14.9400000209 + 44 +15.4799999924 + 45 +-180.0000395482 + 46 +53.999868792 + 79 +2 + 49 +3.1896000216 + 49 +-315.7712100108 + 53 +339.444 + 43 +0.0 + 44 +3.420000018 + 45 +-90.000033183 + 46 +35.9999279355 + 79 +2 + 49 +3.0758400205 + 49 +-150.7162320086 + 53 +294.7751 + 43 +2.8800000007 + 44 +2.3399999834 + 45 +-89.9998594132 + 46 +198.0000615975 + 79 +2 + 49 +2.5772039845 + 49 +-255.1435739892 + 53 +66.8014 + 43 +14.0399999921 + 44 +0.0 + 45 +36.0000238407 + 46 +89.9999951667 + 79 +2 + 49 +2.7416699802 + 49 +-134.3422439845 + 53 +17.354 + 43 +15.1199999809 + 44 +2.5199999892 + 45 +-234.0000363497 + 46 +-71.9999010651 + 79 +2 + 49 +3.017358014 + 49 +-298.7176319971 + 53 +69.444 + 43 +5.2199999842 + 44 +0.0 + 45 +-35.9999279355 + 46 +-90.000033183 + 79 +2 + 49 +1.5379199874 + 49 +-152.254151996 + 53 +101.3099 + 43 +12.9600000032 + 44 +0.0 + 45 +-17.9999573944 + 46 +72.0000135294 + 79 +2 + 49 +0.9178199932 + 49 +-90.8645219942 + 53 +165.9638 + 43 +12.7799999975 + 44 +0.8999999831 + 45 +54.0000098409 + 46 +-17.9999650276 + 79 +2 + 49 +3.7107900079 + 49 +-70.5050999903 + 53 +186.009 + 43 +9.1800000194 + 44 +1.8000000119 + 45 +179.9999954963 + 46 +17.9999876948 + 79 +2 + 49 +3.4388999802 + 49 +-340.4506140022 + 53 +303.6901 + 43 +11.1599999914 + 44 +11.1599999914 + 45 +-18.0000240412 + 46 +35.9999920382 + 79 +2 + 49 +2.5959960018 + 49 +-62.3039220112 + 53 +353.1572 + 43 +12.5999999917 + 44 +9.0000000137 + 45 +305.9999885795 + 46 +-36.0001439522 + 79 +2 + 49 +4.5322919971 + 49 +-448.6961339953 + 53 +60.9454 + 43 +17.0999999986 + 44 +8.4599999964 + 45 +-72.0000005012 + 46 +-126.0000069224 + 79 +2 + 49 +1.8532079878 + 49 +-183.4681320216 + 53 +90.0 + 43 +17.9999999816 + 44 +10.0800000025 + 45 +-17.9999999816 + 46 +17.9999999816 + 79 +2 + 49 +1.0799999888 + 49 +-16.9199999928 + 53 +120.2564 + 43 +8.8200000079 + 44 +2.3399999834 + 45 +71.9999079614 + 46 +-126.0000441964 + 79 +2 + 49 +2.5006319914 + 49 +-247.5633600209 + 53 +48.0128 + 43 +7.5600000133 + 44 +4.5000000068 + 45 +143.9999681763 + 46 +162.000024158 + 79 +2 + 49 +4.8432959878 + 49 +-237.3219360029 + 53 +0.0 + 43 +10.7999999798 + 44 +8.0999999849 + 45 +17.9999999816 + 46 +17.9999999816 + 79 +2 + 49 +4.6800000126 + 49 +-13.3200000148 + 53 +325.3048 + 43 +15.4799999924 + 44 +8.0999999849 + 45 +-179.9998966718 + 46 +126.0001549797 + 79 +2 + 49 +2.8460519863 + 49 +-281.7589319903 + 53 +254.0546 + 43 +17.8200000216 + 44 +6.4799999788 + 45 +-17.9999980057 + 46 +-72.0000022974 + 79 +2 + 49 +2.6208359978 + 49 +-128.4211439852 + 53 +207.646 + 43 +17.0999999986 + 44 +3.9599999896 + 45 +-341.999924501 + 46 +-180.0001388421 + 79 +2 + 49 +4.2671699924 + 49 +-422.4505320137 + 53 +175.4261 + 43 +13.320007142 + 44 +1.980001628 + 45 +-233.9997863523 + 46 +17.9999001623 + 79 +2 + 49 +4.51438391 + 49 +-446.9228933 + 98 +0 + 0 +LWPOLYLINE + 5 +DB +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +140.0 + 20 +-990.0 + 10 +240.0 + 20 +-990.0 + 10 +240.0 + 20 +-890.0 + 10 +140.0 + 20 +-890.0 + 0 +TEXT + 5 +DC +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-1004.0 + 30 +0.0 + 40 +9.0 + 1 +GRAVEL1 + 7 +LABEL + 11 +140.0 + 21 +-1004.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +DD +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +HEX + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +-990.0 + 10 +380.0 + 20 +-990.0 + 10 +380.0 + 20 +-890.0 + 10 +280.0 + 20 +-890.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +5.4992613154 + 79 +2 + 49 +3.175 + 49 +-6.35 + 53 +120.0 + 43 +0.0 + 44 +0.0 + 45 +-4.7625000012 + 46 +-2.7496306577 + 79 +2 + 49 +3.175 + 49 +-6.35 + 53 +60.0 + 43 +3.175 + 44 +0.0 + 45 +-4.7625000012 + 46 +2.7496306577 + 79 +2 + 49 +3.175 + 49 +-6.35 + 98 +0 + 0 +LWPOLYLINE + 5 +DE +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +280.0 + 20 +-990.0 + 10 +380.0 + 20 +-990.0 + 10 +380.0 + 20 +-890.0 + 10 +280.0 + 20 +-890.0 + 0 +TEXT + 5 +DF +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-1004.0 + 30 +0.0 + 40 +9.0 + 1 +HEX + 7 +LABEL + 11 +280.0 + 21 +-1004.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +E0 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +HEXAGONS + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +-990.0 + 10 +520.0 + 20 +-990.0 + 10 +520.0 + 20 +-890.0 + 10 +420.0 + 20 +-890.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +6.5991135785 + 79 +2 + 49 +3.81 + 49 +-7.62 + 53 +120.0 + 43 +0.0 + 44 +0.0 + 45 +-5.7150000014 + 46 +-3.2995567892 + 79 +2 + 49 +3.81 + 49 +-7.62 + 53 +60.0 + 43 +3.81 + 44 +0.0 + 45 +-5.7150000014 + 46 +3.2995567892 + 79 +2 + 49 +3.81 + 49 +-7.62 + 98 +0 + 0 +LWPOLYLINE + 5 +E1 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +420.0 + 20 +-990.0 + 10 +520.0 + 20 +-990.0 + 10 +520.0 + 20 +-890.0 + 10 +420.0 + 20 +-890.0 + 0 +TEXT + 5 +E2 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-1004.0 + 30 +0.0 + 40 +9.0 + 1 +HEXAGONS + 7 +LABEL + 11 +420.0 + 21 +-1004.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +E3 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +HONEY + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +-990.0 + 10 +660.0 + 20 +-990.0 + 10 +660.0 + 20 +-890.0 + 10 +560.0 + 20 +-890.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +4.7625 + 46 +2.749630645 + 79 +2 + 49 +3.175 + 49 +-6.35 + 53 +120.0 + 43 +0.0 + 44 +0.0 + 45 +-4.7624999896 + 46 +2.749630663 + 79 +2 + 49 +3.175 + 49 +-6.35 + 53 +60.0 + 43 +0.0 + 44 +0.0 + 45 +1.04e-08 + 46 +5.499261308 + 79 +2 + 49 +-6.35 + 49 +3.175 + 98 +0 + 0 +LWPOLYLINE + 5 +E4 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +560.0 + 20 +-990.0 + 10 +660.0 + 20 +-990.0 + 10 +660.0 + 20 +-890.0 + 10 +560.0 + 20 +-890.0 + 0 +TEXT + 5 +E5 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-1004.0 + 30 +0.0 + 40 +9.0 + 1 +HONEY + 7 +LABEL + 11 +560.0 + 21 +-1004.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +E6 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +HONEYCOMB + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +-990.0 + 10 +800.0 + 20 +-990.0 + 10 +800.0 + 20 +-890.0 + 10 +700.0 + 20 +-890.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +5.715 + 46 +3.299556774 + 79 +2 + 49 +3.81 + 49 +-7.62 + 53 +120.0 + 43 +0.0 + 44 +0.0 + 45 +-5.7149999875 + 46 +3.2995567956 + 79 +2 + 49 +3.81 + 49 +-7.62 + 53 +60.0 + 43 +0.0 + 44 +0.0 + 45 +1.25e-08 + 46 +6.5991135696 + 79 +2 + 49 +-7.62 + 49 +3.81 + 98 +0 + 0 +LWPOLYLINE + 5 +E7 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +700.0 + 20 +-990.0 + 10 +800.0 + 20 +-990.0 + 10 +800.0 + 20 +-890.0 + 10 +700.0 + 20 +-890.0 + 0 +TEXT + 5 +E8 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-1004.0 + 30 +0.0 + 40 +9.0 + 1 +HONEYCOMB + 7 +LABEL + 11 +700.0 + 21 +-1004.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +E9 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +INSUL + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +-990.0 + 10 +940.0 + 20 +-990.0 + 10 +940.0 + 20 +-890.0 + 10 +840.0 + 20 +-890.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +9.525 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +3.175 + 45 +0.0 + 46 +9.525 + 79 +2 + 49 +3.175 + 49 +-3.175 + 53 +0.0 + 43 +0.0 + 44 +6.35 + 45 +0.0 + 46 +9.525 + 79 +2 + 49 +3.175 + 49 +-3.175 + 98 +0 + 0 +LWPOLYLINE + 5 +EA +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +-990.0 + 10 +940.0 + 20 +-990.0 + 10 +940.0 + 20 +-890.0 + 10 +840.0 + 20 +-890.0 + 0 +TEXT + 5 +EB +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-1004.0 + 30 +0.0 + 40 +9.0 + 1 +INSUL + 7 +LABEL + 11 +840.0 + 21 +-1004.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +EC +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +INSULATION + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +980.0 + 20 +-990.0 + 10 +1080.0 + 20 +-990.0 + 10 +1080.0 + 20 +-890.0 + 10 +980.0 + 20 +-890.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-2.5144717139 + 46 +2.5144717139 + 79 +0 + 53 +135.0 + 43 +0.0 + 44 +0.0 + 45 +-2.5144717139 + 46 +-2.5144717139 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +ED +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +980.0 + 20 +-990.0 + 10 +1080.0 + 20 +-990.0 + 10 +1080.0 + 20 +-890.0 + 10 +980.0 + 20 +-890.0 + 0 +TEXT + 5 +EE +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +980.0 + 20 +-1004.0 + 30 +0.0 + 40 +9.0 + 1 +INSULATION + 7 +LABEL + 11 +980.0 + 21 +-1004.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +EF +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +LINE + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-1155.0 + 10 +100.0 + 20 +-1155.0 + 10 +100.0 + 20 +-1055.0 + 10 +0.0 + 20 +-1055.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +1 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +3.175 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +F0 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-1155.0 + 10 +100.0 + 20 +-1155.0 + 10 +100.0 + 20 +-1055.0 + 10 +0.0 + 20 +-1055.0 + 0 +TEXT + 5 +F1 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-1169.0 + 30 +0.0 + 40 +9.0 + 1 +LINE + 7 +LABEL + 11 +0.0 + 21 +-1169.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +F2 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +LINES + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +-1155.0 + 10 +240.0 + 20 +-1155.0 + 10 +240.0 + 20 +-1055.0 + 10 +140.0 + 20 +-1055.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +1 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +2.54 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +F3 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +140.0 + 20 +-1155.0 + 10 +240.0 + 20 +-1155.0 + 10 +240.0 + 20 +-1055.0 + 10 +140.0 + 20 +-1055.0 + 0 +TEXT + 5 +F4 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-1169.0 + 30 +0.0 + 40 +9.0 + 1 +LINES + 7 +LABEL + 11 +140.0 + 21 +-1169.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +F5 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +MUDST + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +-1155.0 + 10 +380.0 + 20 +-1155.0 + 10 +380.0 + 20 +-1055.0 + 10 +280.0 + 20 +-1055.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +1 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +12.7 + 46 +6.35 + 79 +6 + 49 +6.35 + 49 +-6.35 + 49 +0.0 + 49 +-6.35 + 49 +0.0 + 49 +-6.35 + 98 +0 + 0 +LWPOLYLINE + 5 +F6 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +280.0 + 20 +-1155.0 + 10 +380.0 + 20 +-1155.0 + 10 +380.0 + 20 +-1055.0 + 10 +280.0 + 20 +-1055.0 + 0 +TEXT + 5 +F7 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-1169.0 + 30 +0.0 + 40 +9.0 + 1 +MUDST + 7 +LABEL + 11 +280.0 + 21 +-1169.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +F8 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +NET + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +-1155.0 + 10 +520.0 + 20 +-1155.0 + 10 +520.0 + 20 +-1055.0 + 10 +420.0 + 20 +-1055.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +3.175 + 79 +0 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-3.175 + 46 +0.0 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +F9 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +420.0 + 20 +-1155.0 + 10 +520.0 + 20 +-1155.0 + 10 +520.0 + 20 +-1055.0 + 10 +420.0 + 20 +-1055.0 + 0 +TEXT + 5 +FA +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-1169.0 + 30 +0.0 + 40 +9.0 + 1 +NET + 7 +LABEL + 11 +420.0 + 21 +-1169.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +FB +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +NET3 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +-1155.0 + 10 +660.0 + 20 +-1155.0 + 10 +660.0 + 20 +-1055.0 + 10 +560.0 + 20 +-1055.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +3.175 + 79 +0 + 53 +60.0 + 43 +0.0 + 44 +0.0 + 45 +-2.749630657 + 46 +1.5875 + 79 +0 + 53 +120.0 + 43 +0.0 + 44 +0.0 + 45 +-2.749630657 + 46 +-1.5875 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +FC +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +560.0 + 20 +-1155.0 + 10 +660.0 + 20 +-1155.0 + 10 +660.0 + 20 +-1055.0 + 10 +560.0 + 20 +-1055.0 + 0 +TEXT + 5 +FD +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-1169.0 + 30 +0.0 + 40 +9.0 + 1 +NET3 + 7 +LABEL + 11 +560.0 + 21 +-1169.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +FE +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +PLAST + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +-1155.0 + 10 +800.0 + 20 +-1155.0 + 10 +800.0 + 20 +-1055.0 + 10 +700.0 + 20 +-1055.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +6.35 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +0.79375 + 45 +0.0 + 46 +6.35 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +1.5875 + 45 +0.0 + 46 +6.35 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +FF +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +700.0 + 20 +-1155.0 + 10 +800.0 + 20 +-1155.0 + 10 +800.0 + 20 +-1055.0 + 10 +700.0 + 20 +-1055.0 + 0 +TEXT + 5 +100 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-1169.0 + 30 +0.0 + 40 +9.0 + 1 +PLAST + 7 +LABEL + 11 +700.0 + 21 +-1169.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +101 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +PLASTI + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +-1155.0 + 10 +940.0 + 20 +-1155.0 + 10 +940.0 + 20 +-1055.0 + 10 +840.0 + 20 +-1055.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +4 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +6.35 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +0.79375 + 45 +0.0 + 46 +6.35 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +1.5875 + 45 +0.0 + 46 +6.35 + 79 +0 + 53 +0.0 + 43 +0.0 + 44 +3.96875 + 45 +0.0 + 46 +6.35 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +102 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +-1155.0 + 10 +940.0 + 20 +-1155.0 + 10 +940.0 + 20 +-1055.0 + 10 +840.0 + 20 +-1055.0 + 0 +TEXT + 5 +103 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-1169.0 + 30 +0.0 + 40 +9.0 + 1 +PLASTI + 7 +LABEL + 11 +840.0 + 21 +-1169.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +104 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SQUARE + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +980.0 + 20 +-1155.0 + 10 +1080.0 + 20 +-1155.0 + 10 +1080.0 + 20 +-1055.0 + 10 +980.0 + 20 +-1055.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +3.175 + 79 +2 + 49 +3.175 + 49 +-3.175 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-3.175 + 46 +0.0 + 79 +2 + 49 +3.175 + 49 +-3.175 + 98 +0 + 0 +LWPOLYLINE + 5 +105 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +980.0 + 20 +-1155.0 + 10 +1080.0 + 20 +-1155.0 + 10 +1080.0 + 20 +-1055.0 + 10 +980.0 + 20 +-1055.0 + 0 +TEXT + 5 +106 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +980.0 + 20 +-1169.0 + 30 +0.0 + 40 +9.0 + 1 +SQUARE + 7 +LABEL + 11 +980.0 + 21 +-1169.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +107 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SQUARES + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-1320.0 + 10 +100.0 + 20 +-1320.0 + 10 +100.0 + 20 +-1220.0 + 10 +0.0 + 20 +-1220.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +3.81 + 79 +2 + 49 +3.81 + 49 +-3.81 + 53 +90.0 + 43 +0.0 + 44 +0.0 + 45 +-3.81 + 46 +0.0 + 79 +2 + 49 +3.81 + 49 +-3.81 + 98 +0 + 0 +LWPOLYLINE + 5 +108 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-1320.0 + 10 +100.0 + 20 +-1320.0 + 10 +100.0 + 20 +-1220.0 + 10 +0.0 + 20 +-1220.0 + 0 +TEXT + 5 +109 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-1334.0 + 30 +0.0 + 40 +9.0 + 1 +SQUARES + 7 +LABEL + 11 +0.0 + 21 +-1334.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +10A +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +STARS + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +-1320.0 + 10 +240.0 + 20 +-1320.0 + 10 +240.0 + 20 +-1220.0 + 10 +140.0 + 20 +-1220.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +5.4992613154 + 79 +2 + 49 +3.175 + 49 +-3.175 + 53 +60.0 + 43 +0.0 + 44 +0.0 + 45 +-4.7625000012 + 46 +2.7496306577 + 79 +2 + 49 +3.175 + 49 +-3.175 + 53 +120.0 + 43 +1.5875 + 44 +2.7496306704 + 45 +-4.7625000012 + 46 +-2.7496306577 + 79 +2 + 49 +3.175 + 49 +-3.175 + 98 +0 + 0 +LWPOLYLINE + 5 +10B +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +140.0 + 20 +-1320.0 + 10 +240.0 + 20 +-1320.0 + 10 +240.0 + 20 +-1220.0 + 10 +140.0 + 20 +-1220.0 + 0 +TEXT + 5 +10C +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-1334.0 + 30 +0.0 + 40 +9.0 + 1 +STARS + 7 +LABEL + 11 +140.0 + 21 +-1334.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +10D +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +STEEL + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +-1320.0 + 10 +380.0 + 20 +-1320.0 + 10 +380.0 + 20 +-1220.0 + 10 +280.0 + 20 +-1220.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +8 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +0 + 53 +45.0 + 43 +4.318 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +0 + 53 +45.0 + 43 +4.572 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +0 + 53 +45.0 + 43 +4.826 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +0 + 53 +45.0 + 43 +5.08 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +0 + 53 +45.0 + 43 +5.334 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +0 + 53 +45.0 + 43 +5.588 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +0 + 53 +45.0 + 43 +5.842 + 44 +0.0 + 45 +-5.3881536726 + 46 +5.3881536726 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +10E +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +280.0 + 20 +-1320.0 + 10 +380.0 + 20 +-1320.0 + 10 +380.0 + 20 +-1220.0 + 10 +280.0 + 20 +-1220.0 + 0 +TEXT + 5 +10F +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-1334.0 + 30 +0.0 + 40 +9.0 + 1 +STEEL + 7 +LABEL + 11 +280.0 + 21 +-1334.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +110 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SWAMP + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +-1320.0 + 10 +520.0 + 20 +-1320.0 + 10 +520.0 + 20 +-1220.0 + 10 +420.0 + 20 +-1220.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +6 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +12.7 + 46 +21.9970452362 + 79 +2 + 49 +3.175 + 49 +-22.225 + 53 +90.0 + 43 +1.5875 + 44 +0.0 + 45 +-12.7 + 46 +21.9970452362 + 79 +2 + 49 +1.5875 + 49 +-42.4065904724 + 53 +90.0 + 43 +1.984375 + 44 +0.0 + 45 +-12.7 + 46 +21.9970452362 + 79 +2 + 49 +1.27 + 49 +-42.7240904724 + 53 +90.0 + 43 +1.190625 + 44 +0.0 + 45 +-12.7 + 46 +21.9970452362 + 79 +2 + 49 +1.27 + 49 +-42.7240904724 + 53 +60.0 + 43 +2.38125 + 44 +0.0 + 45 +-12.6999999827 + 46 +21.9970452462 + 79 +2 + 49 +1.016 + 49 +-24.384 + 53 +120.0 + 43 +0.79375 + 44 +0.0 + 45 +-25.3999999827 + 46 +1e-08 + 79 +2 + 49 +1.016 + 49 +-24.384 + 98 +0 + 0 +LWPOLYLINE + 5 +111 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +420.0 + 20 +-1320.0 + 10 +520.0 + 20 +-1320.0 + 10 +520.0 + 20 +-1220.0 + 10 +420.0 + 20 +-1220.0 + 0 +TEXT + 5 +112 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-1334.0 + 30 +0.0 + 40 +9.0 + 1 +SWAMP + 7 +LABEL + 11 +420.0 + 21 +-1334.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +113 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +TRIANG + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +-1320.0 + 10 +660.0 + 20 +-1320.0 + 10 +660.0 + 20 +-1220.0 + 10 +560.0 + 20 +-1220.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +60.0 + 43 +0.0 + 44 +0.0 + 45 +-4.7624999908 + 46 +8.2488919657 + 79 +2 + 49 +4.7625 + 49 +-4.7625 + 53 +120.0 + 43 +0.0 + 44 +0.0 + 45 +-9.5249999908 + 46 +5.3e-09 + 79 +2 + 49 +4.7625 + 49 +-4.7625 + 53 +0.0 + 43 +-2.38125 + 44 +4.1244459802 + 45 +4.7625 + 46 +8.2488919604 + 79 +2 + 49 +4.7625 + 49 +-4.7625 + 98 +0 + 0 +LWPOLYLINE + 5 +114 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +560.0 + 20 +-1320.0 + 10 +660.0 + 20 +-1320.0 + 10 +660.0 + 20 +-1220.0 + 10 +560.0 + 20 +-1220.0 + 0 +TEXT + 5 +115 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-1334.0 + 30 +0.0 + 40 +9.0 + 1 +TRIANG + 7 +LABEL + 11 +560.0 + 21 +-1334.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +116 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +TRIANGLES + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +-1320.0 + 10 +800.0 + 20 +-1320.0 + 10 +800.0 + 20 +-1220.0 + 10 +700.0 + 20 +-1220.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +60.0 + 43 +0.0 + 44 +0.0 + 45 +-5.7149999889 + 46 +9.8986703589 + 79 +2 + 49 +5.715 + 49 +-5.715 + 53 +120.0 + 43 +0.0 + 44 +0.0 + 45 +-11.4299999889 + 46 +6.4e-09 + 79 +2 + 49 +5.715 + 49 +-5.715 + 53 +0.0 + 43 +-2.8575 + 44 +4.9493351762 + 45 +5.715 + 46 +9.8986703525 + 79 +2 + 49 +5.715 + 49 +-5.715 + 98 +0 + 0 +LWPOLYLINE + 5 +117 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +700.0 + 20 +-1320.0 + 10 +800.0 + 20 +-1320.0 + 10 +800.0 + 20 +-1220.0 + 10 +700.0 + 20 +-1220.0 + 0 +TEXT + 5 +118 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-1334.0 + 30 +0.0 + 40 +9.0 + 1 +TRIANGLES + 7 +LABEL + 11 +700.0 + 21 +-1334.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +119 +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ZIGZAG + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +-1320.0 + 10 +940.0 + 20 +-1320.0 + 10 +940.0 + 20 +-1220.0 + 10 +840.0 + 20 +-1220.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +2 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 53 +90.0 + 43 +6.35 + 44 +0.0 + 45 +-6.35 + 46 +6.35 + 79 +2 + 49 +6.35 + 49 +-6.35 + 98 +0 + 0 +LWPOLYLINE + 5 +11A +330 +17 +100 +AcDbEntity + 8 +PATTERNS + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +-1320.0 + 10 +940.0 + 20 +-1320.0 + 10 +940.0 + 20 +-1220.0 + 10 +840.0 + 20 +-1220.0 + 0 +TEXT + 5 +11B +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-1334.0 + 30 +0.0 + 40 +9.0 + 1 +ZIGZAG + 7 +LABEL + 11 +840.0 + 21 +-1334.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +11C +330 +17 +100 +AcDbEntity + 8 +GRADIENTS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-1585.0 + 10 +100.0 + 20 +-1585.0 + 10 +100.0 + 20 +-1485.0 + 10 +0.0 + 20 +-1485.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +0.0 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +16768080 +463 +1 +421 +1326220 +470 +LINEAR + 0 +TEXT + 5 +11D +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-1599.0 + 30 +0.0 + 40 +9.0 + 1 +linear + 7 +LABEL + 11 +0.0 + 21 +-1599.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +11E +330 +17 +100 +AcDbEntity + 8 +GRADIENTS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +140.0 + 20 +-1585.0 + 10 +240.0 + 20 +-1585.0 + 10 +240.0 + 20 +-1485.0 + 10 +140.0 + 20 +-1485.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +0.5235987755982988 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +16768080 +463 +1 +421 +1326220 +470 +CYLINDER + 0 +TEXT + 5 +11F +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +140.0 + 20 +-1599.0 + 30 +0.0 + 40 +9.0 + 1 +cylinder + 7 +LABEL + 11 +140.0 + 21 +-1599.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +120 +330 +17 +100 +AcDbEntity + 8 +GRADIENTS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +280.0 + 20 +-1585.0 + 10 +380.0 + 20 +-1585.0 + 10 +380.0 + 20 +-1485.0 + 10 +280.0 + 20 +-1485.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +0.0 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +16768080 +463 +1 +421 +1326220 +470 +INVCYLINDER + 0 +TEXT + 5 +121 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +280.0 + 20 +-1599.0 + 30 +0.0 + 40 +9.0 + 1 +invcylinder + 7 +LABEL + 11 +280.0 + 21 +-1599.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +122 +330 +17 +100 +AcDbEntity + 8 +GRADIENTS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +420.0 + 20 +-1585.0 + 10 +520.0 + 20 +-1585.0 + 10 +520.0 + 20 +-1485.0 + 10 +420.0 + 20 +-1485.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +0.5235987755982988 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +16768080 +463 +1 +421 +1326220 +470 +SPHERICAL + 0 +TEXT + 5 +123 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +420.0 + 20 +-1599.0 + 30 +0.0 + 40 +9.0 + 1 +spherical + 7 +LABEL + 11 +420.0 + 21 +-1599.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +124 +330 +17 +100 +AcDbEntity + 8 +GRADIENTS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +560.0 + 20 +-1585.0 + 10 +660.0 + 20 +-1585.0 + 10 +660.0 + 20 +-1485.0 + 10 +560.0 + 20 +-1485.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +0.0 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +16768080 +463 +1 +421 +1326220 +470 +INVSPHERICAL + 0 +TEXT + 5 +125 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +560.0 + 20 +-1599.0 + 30 +0.0 + 40 +9.0 + 1 +invspherical + 7 +LABEL + 11 +560.0 + 21 +-1599.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +126 +330 +17 +100 +AcDbEntity + 8 +GRADIENTS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +700.0 + 20 +-1585.0 + 10 +800.0 + 20 +-1585.0 + 10 +800.0 + 20 +-1485.0 + 10 +700.0 + 20 +-1485.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +0.5235987755982988 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +16768080 +463 +1 +421 +1326220 +470 +HEMISPHERICAL + 0 +TEXT + 5 +127 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +700.0 + 20 +-1599.0 + 30 +0.0 + 40 +9.0 + 1 +hemispherical + 7 +LABEL + 11 +700.0 + 21 +-1599.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +128 +330 +17 +100 +AcDbEntity + 8 +GRADIENTS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +840.0 + 20 +-1585.0 + 10 +940.0 + 20 +-1585.0 + 10 +940.0 + 20 +-1485.0 + 10 +840.0 + 20 +-1485.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +0.0 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +16768080 +463 +1 +421 +1326220 +470 +INVHEMISPHERICAL + 0 +TEXT + 5 +129 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +840.0 + 20 +-1599.0 + 30 +0.0 + 40 +9.0 + 1 +invhemispherical + 7 +LABEL + 11 +840.0 + 21 +-1599.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +12A +330 +17 +100 +AcDbEntity + 8 +GRADIENTS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +980.0 + 20 +-1585.0 + 10 +1080.0 + 20 +-1585.0 + 10 +1080.0 + 20 +-1485.0 + 10 +980.0 + 20 +-1485.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +0.5235987755982988 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +16768080 +463 +1 +421 +1326220 +470 +CURVED + 0 +TEXT + 5 +12B +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +980.0 + 20 +-1599.0 + 30 +0.0 + 40 +9.0 + 1 +curved + 7 +LABEL + 11 +980.0 + 21 +-1599.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +12C +330 +17 +100 +AcDbEntity + 8 +GRADIENTS + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-1750.0 + 10 +100.0 + 20 +-1750.0 + 10 +100.0 + 20 +-1650.0 + 10 +0.0 + 20 +-1650.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +0.0 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +16768080 +463 +1 +421 +1326220 +470 +INVCURVED + 0 +TEXT + 5 +12D +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-1764.0 + 30 +0.0 + 40 +9.0 + 1 +invcurved + 7 +LABEL + 11 +0.0 + 21 +-1764.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +12E +330 +17 +100 +AcDbEntity + 8 +ISLANDS + 62 +1 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +3 + 92 +19 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-2015.0 + 10 +200.0 + 20 +-2015.0 + 10 +200.0 + 20 +-1815.0 + 10 +0.0 + 20 +-1815.0 + 97 +0 + 92 +16 + 93 +1 + 72 +2 + 10 +100.0 + 20 +-1915.0 + 40 +70 + 50 +0 + 51 +360 + 73 +1 + 97 +0 + 92 +18 + 72 +0 + 73 +1 + 93 +3 + 10 +75.0 + 20 +-1935.0 + 10 +125.0 + 20 +-1935.0 + 10 +100.0 + 20 +-1887.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.4 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-3.1430896424 + 46 +3.1430896424 + 79 +0 + 98 +0 + 0 +TEXT + 5 +12F +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +0.0 + 20 +-2029.0 + 30 +0.0 + 40 +9.0 + 1 +normal: nested islands + 7 +LABEL + 11 +0.0 + 21 +-2029.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +130 +330 +17 +100 +AcDbEntity + 8 +ISLANDS + 62 +1 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +3 + 92 +19 + 72 +0 + 73 +1 + 93 +4 + 10 +240.0 + 20 +-2015.0 + 10 +440.0 + 20 +-2015.0 + 10 +440.0 + 20 +-1815.0 + 10 +240.0 + 20 +-1815.0 + 97 +0 + 92 +16 + 93 +1 + 72 +2 + 10 +340.0 + 20 +-1915.0 + 40 +70 + 50 +0 + 51 +360 + 73 +1 + 97 +0 + 92 +18 + 72 +0 + 73 +1 + 93 +3 + 10 +315.0 + 20 +-1935.0 + 10 +365.0 + 20 +-1935.0 + 10 +340.0 + 20 +-1887.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.4 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-3.1430896424 + 46 +3.1430896424 + 79 +0 + 98 +0 + 0 +TEXT + 5 +131 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +240.0 + 20 +-2029.0 + 30 +0.0 + 40 +9.0 + 1 +outer only + 7 +LABEL + 11 +240.0 + 21 +-2029.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +132 +330 +17 +100 +AcDbEntity + 8 +ISLANDS + 62 +1 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +3 + 92 +19 + 72 +0 + 73 +1 + 93 +4 + 10 +480.0 + 20 +-2015.0 + 10 +680.0 + 20 +-2015.0 + 10 +680.0 + 20 +-1815.0 + 10 +480.0 + 20 +-1815.0 + 97 +0 + 92 +16 + 93 +1 + 72 +2 + 10 +580.0 + 20 +-1915.0 + 40 +70 + 50 +0 + 51 +360 + 73 +1 + 97 +0 + 92 +18 + 72 +0 + 73 +1 + 93 +3 + 10 +555.0 + 20 +-1935.0 + 10 +605.0 + 20 +-1935.0 + 10 +580.0 + 20 +-1887.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.4 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-3.1430896424 + 46 +3.1430896424 + 79 +0 + 98 +0 + 0 +TEXT + 5 +133 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +480.0 + 20 +-2029.0 + 30 +0.0 + 40 +9.0 + 1 +ignore: all filled + 7 +LABEL + 11 +480.0 + 21 +-2029.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +134 +330 +17 +100 +AcDbEntity + 8 +EDGES + 62 +3 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +HONEY + 70 +0 + 71 +0 + 91 +1 + 92 +1 + 93 +5 + 72 +1 + 10 +720.0 + 20 +-2015.0 + 11 +880.0 + 21 +-2015.0 + 72 +2 + 10 +880.0 + 20 +-1975.0 + 40 +40 + 50 +270 + 51 +90 + 73 +1 + 72 +1 + 10 +880.0 + 20 +-1935.0 + 11 +840.0 + 21 +-1935.0 + 72 +4 + 94 +3 + 73 +0 + 74 +0 + 95 +8 + 96 +0 + 40 +0.0 + 40 +0.0 + 40 +0.0 + 40 +0.0 + 40 +-3.0 + 40 +-3.0 + 40 +-3.0 + 40 +-3.0 + 97 +5 + 11 +840.0 + 21 +-1935.0 + 11 +820.0 + 21 +-1875.0 + 11 +780.0 + 21 +-1975.0 + 11 +760.0 + 21 +-1895.0 + 11 +720.0 + 21 +-1915.0 + 72 +1 + 10 +720.0 + 20 +-1915.0 + 11 +720.0 + 21 +-2015.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +1.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +4.7625 + 46 +2.749630645 + 79 +2 + 49 +3.175 + 49 +-6.35 + 53 +120.0 + 43 +0.0 + 44 +0.0 + 45 +-4.7624999896 + 46 +2.749630663 + 79 +2 + 49 +3.175 + 49 +-6.35 + 53 +60.0 + 43 +0.0 + 44 +0.0 + 45 +1.04e-08 + 46 +5.499261308 + 79 +2 + 49 +-6.35 + 49 +3.175 + 98 +0 + 0 +TEXT + 5 +135 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +720.0 + 20 +-2029.0 + 30 +0.0 + 40 +9.0 + 1 +edges: line, arc, spline + 7 +LABEL + 11 +720.0 + 21 +-2029.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +136 +330 +17 +100 +AcDbEntity + 8 +EDGES + 62 +6 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +1 + 93 +1 + 72 +3 + 10 +1010.0 + 20 +-1945.0 + 11 +70.0 + 21 +0.0 + 40 +0.55 + 50 +0 + 51 +360 + 73 +1 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +TEXT + 5 +137 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +940.0 + 20 +-2029.0 + 30 +0.0 + 40 +9.0 + 1 +ellipse, solid + 7 +LABEL + 11 +940.0 + 21 +-2029.0 + 31 +0.0 +100 +AcDbText + 0 +HATCH + 5 +138 +330 +17 +100 +AcDbEntity + 8 +EDGES + 62 +2 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI37 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +1120.0 + 20 +-2015.0 + 10 +1260.0 + 20 +-2015.0 + 10 +1260.0 + 20 +-1875.0 + 10 +1120.0 + 20 +-1875.0 + 97 +0 + 75 +1 + 76 +1 + 52 +15.0 + 41 +2.5 + 77 +0 + 78 +2 + 53 +60.0 + 43 +0.0 + 44 +0.0 + 45 +-6.8740766426 + 46 +3.9687500001 + 79 +0 + 53 +150.0 + 43 +0.0 + 44 +0.0 + 45 +-3.9687500001 + 46 +-6.8740766426 + 79 +0 + 98 +0 + 0 +TEXT + 5 +139 +330 +17 +100 +AcDbEntity + 8 +LABELS +100 +AcDbText + 10 +1120.0 + 20 +-2029.0 + 30 +0.0 + 40 +9.0 + 1 +angle 15, scale 2.5 + 7 +LABEL + 11 +1120.0 + 21 +-2029.0 + 31 +0.0 +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +13D + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +DICTIONARYVAR + 5 +13D +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/house-elevations.dxf b/crates/exav-viewer/demo/examples/files/cad/house-elevations.dxf new file mode 100644 index 00000000..e22260b4 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/house-elevations.dxf @@ -0,0 +1,18904 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1032 + 9 +$ACADMAINTVER + 90 +4 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$REQUIREDVERSIONS +160 +0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +23A + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +236 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +20684.586738111186 + 22 +-5270.0 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +29358.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +14 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +E-GROUND + 70 +0 + 62 +3 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +E-WALL + 70 +0 + 62 +7 + 6 +Continuous +370 +35 +390 +13 +347 +21 + 0 +LAYER + 5 +43 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +E-RENDER + 70 +0 + 62 +8 + 6 +Continuous +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +44 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +E-PLINTH + 70 +0 + 62 +8 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +45 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +E-ROOF + 70 +0 + 62 +1 + 6 +Continuous +370 +35 +390 +13 +347 +21 + 0 +LAYER + 5 +46 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +E-FLOOR + 70 +0 + 62 +9 + 6 +DASHDOT +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +47 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +E-DOOR + 70 +0 + 62 +30 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +48 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +E-STEP + 70 +0 + 62 +8 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +49 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-WIND + 70 +0 + 62 +5 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +4A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-ANNO + 70 +0 + 62 +2 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +4B +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-DIMS + 70 +0 + 62 +3 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +4C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-FIXT + 70 +0 + 62 +4 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +4D +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +ARCH-TEXT + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +STYLE + 5 +4E +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +ARCH-TITLE + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +times.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +237 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +238 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +2 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +4F +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +ELEV50 + 70 +0 + 40 +1.0 + 41 +128.0 + 42 +128.0 + 43 +3.75 + 44 +96.0 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +160.0 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +48.0 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +3 +177 +3 +178 +2 +179 +2 +271 +0 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +340 +4D +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +26 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +50 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +DOOR +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +57 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +WINDOW +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +60 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +BED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +67 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +TABLE +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +73 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SOFA +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +7B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +WC +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +80 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SINK +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +86 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +BATH +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +AXIS +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +90 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +NORTH +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +96 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +LEVEL +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +D9 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D1 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +DE +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDFILLED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +EC +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D2 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +FB +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D3 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +138 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D4 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +147 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D5 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +156 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D6 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +193 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D7 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1A2 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D8 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B1 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D9 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1E9 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D10 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1F8 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D11 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +207 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D12 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +51 +330 +50 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +DOOR + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +DOOR + 1 + + 0 +LINE + 5 +53 +330 +50 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +900.0 + 31 +0.0 + 0 +ARC + 5 +54 +330 +50 +100 +AcDbEntity + 8 +A-DOOR + 6 +DASHED +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +900.0 +100 +AcDbArc + 50 +0.0 + 51 +90.0 + 0 +LWPOLYLINE + 5 +55 +330 +50 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +900.0 + 20 +0.0 + 10 +900.0 + 20 +40.0 + 10 +0.0 + 20 +40.0 + 0 +ATTDEF + 5 +56 +330 +50 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +350.0 + 20 +-330.0 + 30 +0.0 + 40 +110.0 + 1 +D-000 +100 +AcDbAttributeDefinition + 3 + + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 0 +ENDBLK + 5 +52 +330 +50 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +58 +330 +57 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +WINDOW + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +WINDOW + 1 + + 0 +LINE + 5 +5A +330 +57 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +1.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +5B +330 +57 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.35 + 30 +0.0 + 11 +1.0 + 21 +0.35 + 31 +0.0 + 0 +LINE + 5 +5C +330 +57 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.65 + 30 +0.0 + 11 +1.0 + 21 +0.65 + 31 +0.0 + 0 +LINE + 5 +5D +330 +57 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +1.0 + 30 +0.0 + 11 +1.0 + 21 +1.0 + 31 +0.0 + 0 +LINE + 5 +5E +330 +57 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +1.0 + 31 +0.0 + 0 +LINE + 5 +5F +330 +57 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +1.0 + 20 +0.0 + 30 +0.0 + 11 +1.0 + 21 +1.0 + 31 +0.0 + 0 +ENDBLK + 5 +59 +330 +57 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +61 +330 +60 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +BED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +BED + 1 + + 0 +LWPOLYLINE + 5 +63 +330 +60 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1600.0 + 20 +0.0 + 10 +1600.0 + 20 +2000.0 + 10 +0.0 + 20 +2000.0 + 0 +LWPOLYLINE + 5 +64 +330 +60 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +80.0 + 20 +1300.0 + 10 +760.0 + 20 +1300.0 + 10 +760.0 + 20 +1920.0 + 10 +80.0 + 20 +1920.0 + 0 +LWPOLYLINE + 5 +65 +330 +60 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +1300.0 + 10 +1520.0 + 20 +1300.0 + 10 +1520.0 + 20 +1920.0 + 10 +840.0 + 20 +1920.0 + 0 +LINE + 5 +66 +330 +60 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbLine + 10 +0.0 + 20 +1200.0 + 30 +0.0 + 11 +1600.0 + 21 +1200.0 + 31 +0.0 + 0 +ENDBLK + 5 +62 +330 +60 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +68 +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +TABLE + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +TABLE + 1 + + 0 +LWPOLYLINE + 5 +6A +330 +67 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1800.0 + 20 +0.0 + 10 +1800.0 + 20 +900.0 + 10 +0.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +6B +330 +67 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +-450.0 + 10 +550.0 + 20 +-450.0 + 10 +550.0 + 20 +-50.0 + 10 +150.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +6C +330 +67 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +950.0 + 10 +550.0 + 20 +950.0 + 10 +550.0 + 20 +1350.0 + 10 +150.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +6D +330 +67 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +600.0 + 20 +-450.0 + 10 +1000.0 + 20 +-450.0 + 10 +1000.0 + 20 +-50.0 + 10 +600.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +6E +330 +67 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +600.0 + 20 +950.0 + 10 +1000.0 + 20 +950.0 + 10 +1000.0 + 20 +1350.0 + 10 +600.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +6F +330 +67 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1050.0 + 20 +-450.0 + 10 +1450.0 + 20 +-450.0 + 10 +1450.0 + 20 +-50.0 + 10 +1050.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +70 +330 +67 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1050.0 + 20 +950.0 + 10 +1450.0 + 20 +950.0 + 10 +1450.0 + 20 +1350.0 + 10 +1050.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +71 +330 +67 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +-450.0 + 10 +1900.0 + 20 +-450.0 + 10 +1900.0 + 20 +-50.0 + 10 +1500.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +72 +330 +67 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +950.0 + 10 +1900.0 + 20 +950.0 + 10 +1900.0 + 20 +1350.0 + 10 +1500.0 + 20 +1350.0 + 0 +ENDBLK + 5 +69 +330 +67 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +74 +330 +73 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SOFA + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SOFA + 1 + + 0 +LWPOLYLINE + 5 +76 +330 +73 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +2200.0 + 20 +0.0 + 10 +2200.0 + 20 +950.0 + 10 +0.0 + 20 +950.0 + 0 +LWPOLYLINE + 5 +77 +330 +73 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +2200.0 + 20 +0.0 + 10 +2200.0 + 20 +250.0 + 10 +0.0 + 20 +250.0 + 0 +LWPOLYLINE + 5 +78 +330 +73 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +120.0 + 20 +300.0 + 10 +720.0 + 20 +300.0 + 10 +720.0 + 20 +900.0 + 10 +120.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +79 +330 +73 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +770.0 + 20 +300.0 + 10 +1370.0 + 20 +300.0 + 10 +1370.0 + 20 +900.0 + 10 +770.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +7A +330 +73 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1420.0 + 20 +300.0 + 10 +2020.0 + 20 +300.0 + 10 +2020.0 + 20 +900.0 + 10 +1420.0 + 20 +900.0 + 0 +ENDBLK + 5 +75 +330 +73 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +7C +330 +7B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +WC + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +WC + 1 + + 0 +LWPOLYLINE + 5 +7E +330 +7B +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +400.0 + 20 +0.0 + 10 +400.0 + 20 +200.0 + 10 +0.0 + 20 +200.0 + 0 +ELLIPSE + 5 +7F +330 +7B +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbEllipse + 10 +200.0 + 20 +-250.0 + 30 +0.0 + 11 +0.0 + 21 +230.0 + 31 +0.0 + 40 +0.8 + 41 +0.0 + 42 +6.283185307179586 + 0 +ENDBLK + 5 +7D +330 +7B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +81 +330 +80 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SINK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SINK + 1 + + 0 +LWPOLYLINE + 5 +83 +330 +80 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +600.0 + 20 +0.0 + 10 +600.0 + 20 +450.0 + 10 +0.0 + 20 +450.0 + 0 +CIRCLE + 5 +84 +330 +80 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbCircle + 10 +300.0 + 20 +225.0 + 30 +0.0 + 40 +160.0 + 0 +CIRCLE + 5 +85 +330 +80 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbCircle + 10 +300.0 + 20 +225.0 + 30 +0.0 + 40 +25.0 + 0 +ENDBLK + 5 +82 +330 +80 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +87 +330 +86 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +BATH + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +BATH + 1 + + 0 +LWPOLYLINE + 5 +89 +330 +86 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1700.0 + 20 +0.0 + 10 +1700.0 + 20 +750.0 + 10 +0.0 + 20 +750.0 + 0 +LWPOLYLINE + 5 +8A +330 +86 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +5 + 70 +1 + 10 +100.0 + 20 +100.0 + 10 +1500.0 + 20 +100.0 + 10 +1600.0 + 20 +375.0 + 10 +1500.0 + 20 +650.0 + 10 +100.0 + 20 +650.0 + 0 +ENDBLK + 5 +88 +330 +86 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +8C +330 +8B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +AXIS + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +AXIS + 1 + + 0 +CIRCLE + 5 +8E +330 +8B +100 +AcDbEntity + 8 +A-GRID +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 0 +ATTDEF + 5 +8F +330 +8B +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 1 +A + 72 +4 + 11 +0.0 + 21 +0.0 + 31 +0.0 +100 +AcDbAttributeDefinition + 3 + + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 0 +ENDBLK + 5 +8D +330 +8B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +91 +330 +90 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +NORTH + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +NORTH + 1 + + 0 +CIRCLE + 5 +93 +330 +90 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +500.0 + 0 +SOLID + 5 +94 +330 +90 +100 +AcDbEntity + 8 +A-ANNO + 62 +7 +100 +AcDbTrace + 10 +0.0 + 20 +650.0 + 30 +0.0 + 11 +-170.0 + 21 +-400.0 + 31 +0.0 + 12 +0.0 + 22 +-200.0 + 32 +0.0 + 13 +170.0 + 23 +-400.0 + 33 +0.0 + 0 +TEXT + 5 +95 +330 +90 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +800.0 + 30 +0.0 + 40 +300.0 + 1 +N + 72 +1 + 11 +0.0 + 21 +800.0 + 31 +0.0 +100 +AcDbText + 73 +1 + 0 +ENDBLK + 5 +92 +330 +90 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +97 +330 +96 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +LEVEL + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +LEVEL + 1 + + 0 +LWPOLYLINE + 5 +99 +330 +96 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +-250.0 + 20 +250.0 + 10 +0.0 + 20 +0.0 + 10 +250.0 + 20 +250.0 + 0 +LINE + 5 +9A +330 +96 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbLine + 10 +-250.0 + 20 +250.0 + 30 +0.0 + 11 +900.0 + 21 +250.0 + 31 +0.0 + 0 +ATTDEF + 5 +9B +330 +96 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +320.0 + 20 +290.0 + 30 +0.0 + 40 +120.0 + 1 ++0.00 +100 +AcDbAttributeDefinition + 3 + + 2 +H + 70 +0 + 71 +1 + 72 +0 + 0 +ENDBLK + 5 +98 +330 +96 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +DA +330 +D9 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D1 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D1 + 1 + + 0 +LINE + 5 +DC +330 +D9 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-128.0 + 20 +0.0 + 30 +0.0 + 11 +-1996.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +DD +330 +D9 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-128.0 + 20 +5400.0 + 30 +0.0 + 11 +-1996.0 + 21 +5400.0 + 31 +0.0 + 0 +INSERT + 5 +E2 +330 +D9 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-1900.0 + 20 +0.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +270.0 + 0 +INSERT + 5 +E4 +330 +D9 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-1900.0 + 20 +5400.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +90.0 + 0 +LINE + 5 +E6 +330 +D9 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-1900.0 + 20 +128.0 + 30 +0.0 + 11 +-1900.0 + 21 +5272.0 + 31 +0.0 + 0 +MTEXT + 5 +E7 +330 +D9 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-2028.0 + 20 +2700.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +5400 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +E8 +330 +D9 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-1900.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +E9 +330 +D9 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +EA +330 +D9 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +5400.0 + 30 +0.0 + 0 +ENDBLK + 5 +DB +330 +D9 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +DF +330 +DE +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDFILLED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDFILLED + 1 + + 0 +SOLID + 5 +E1 +330 +DE +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbTrace + 10 +-1.0 + 20 +0.16439898372402537 + 30 +0.0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 12 +-1.0 + 22 +-0.16439898372402537 + 32 +0.0 + 13 +-1.0 + 23 +-0.16439898372402537 + 33 +0.0 + 0 +ENDBLK + 5 +E0 +330 +DE +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +ED +330 +EC +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D2 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D2 + 1 + + 0 +LINE + 5 +EF +330 +EC +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-128.0 + 20 +5400.0 + 30 +0.0 + 11 +-1996.0 + 21 +5400.0 + 31 +0.0 + 0 +LINE + 5 +F0 +330 +EC +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-128.0 + 20 +8300.0 + 30 +0.0 + 11 +-1996.0 + 21 +8300.0 + 31 +0.0 + 0 +INSERT + 5 +F1 +330 +EC +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-1900.0 + 20 +5400.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +270.0 + 0 +INSERT + 5 +F3 +330 +EC +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-1900.0 + 20 +8300.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +90.0 + 0 +LINE + 5 +F5 +330 +EC +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-1900.0 + 20 +5528.0 + 30 +0.0 + 11 +-1900.0 + 21 +8172.0 + 31 +0.0 + 0 +MTEXT + 5 +F6 +330 +EC +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-2028.0 + 20 +6850.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +2900 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +F7 +330 +EC +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-1900.0 + 20 +5400.0 + 30 +0.0 + 0 +POINT + 5 +F8 +330 +EC +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +5400.0 + 30 +0.0 + 0 +POINT + 5 +F9 +330 +EC +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +8300.0 + 30 +0.0 + 0 +ENDBLK + 5 +EE +330 +EC +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +FC +330 +FB +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D3 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D3 + 1 + + 0 +LINE + 5 +FE +330 +FB +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-128.0 + 30 +0.0 + 11 +0.0 + 21 +-1196.0 + 31 +0.0 + 0 +LINE + 5 +FF +330 +FB +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +10000.0 + 20 +-128.0 + 30 +0.0 + 11 +10000.0 + 21 +-1196.0 + 31 +0.0 + 0 +INSERT + 5 +100 +330 +FB +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-1100.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +180.0 + 0 +INSERT + 5 +102 +330 +FB +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +10000.0 + 20 +-1100.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 0 +LINE + 5 +104 +330 +FB +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +128.0 + 20 +-1100.0 + 30 +0.0 + 11 +9872.0 + 21 +-1100.0 + 31 +0.0 + 0 +MTEXT + 5 +105 +330 +FB +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +5000.0 + 20 +-972.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +10000 + 7 +ARCH-TEXT + 0 +POINT + 5 +106 +330 +FB +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-1100.0 + 30 +0.0 + 0 +POINT + 5 +107 +330 +FB +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +108 +330 +FB +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +10000.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +FD +330 +FB +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +139 +330 +138 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D4 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D4 + 1 + + 0 +LINE + 5 +13B +330 +138 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +15872.0 + 20 +0.0 + 30 +0.0 + 11 +14004.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +13C +330 +138 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +15872.0 + 20 +5400.0 + 30 +0.0 + 11 +14004.0 + 21 +5400.0 + 31 +0.0 + 0 +INSERT + 5 +13D +330 +138 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14100.0 + 20 +0.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +270.0 + 0 +INSERT + 5 +13F +330 +138 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14100.0 + 20 +5400.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +90.0 + 0 +LINE + 5 +141 +330 +138 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +14100.0 + 20 +128.0 + 30 +0.0 + 11 +14100.0 + 21 +5272.0 + 31 +0.0 + 0 +MTEXT + 5 +142 +330 +138 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +13972.0 + 20 +2700.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +5400 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +143 +330 +138 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14100.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +144 +330 +138 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +145 +330 +138 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +5400.0 + 30 +0.0 + 0 +ENDBLK + 5 +13A +330 +138 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +148 +330 +147 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D5 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D5 + 1 + + 0 +LINE + 5 +14A +330 +147 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +15872.0 + 20 +5400.0 + 30 +0.0 + 11 +14004.0 + 21 +5400.0 + 31 +0.0 + 0 +LINE + 5 +14B +330 +147 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +15872.0 + 20 +8300.0 + 30 +0.0 + 11 +14004.0 + 21 +8300.0 + 31 +0.0 + 0 +INSERT + 5 +14C +330 +147 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14100.0 + 20 +5400.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +270.0 + 0 +INSERT + 5 +14E +330 +147 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14100.0 + 20 +8300.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +90.0 + 0 +LINE + 5 +150 +330 +147 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +14100.0 + 20 +5528.0 + 30 +0.0 + 11 +14100.0 + 21 +8172.0 + 31 +0.0 + 0 +MTEXT + 5 +151 +330 +147 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +13972.0 + 20 +6850.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +2900 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +152 +330 +147 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14100.0 + 20 +5400.0 + 30 +0.0 + 0 +POINT + 5 +153 +330 +147 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +5400.0 + 30 +0.0 + 0 +POINT + 5 +154 +330 +147 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +8300.0 + 30 +0.0 + 0 +ENDBLK + 5 +149 +330 +147 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +157 +330 +156 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D6 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D6 + 1 + + 0 +LINE + 5 +159 +330 +156 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +16000.0 + 20 +-128.0 + 30 +0.0 + 11 +16000.0 + 21 +-1196.0 + 31 +0.0 + 0 +LINE + 5 +15A +330 +156 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +24000.0 + 20 +-128.0 + 30 +0.0 + 11 +24000.0 + 21 +-1196.0 + 31 +0.0 + 0 +INSERT + 5 +15B +330 +156 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +16000.0 + 20 +-1100.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +180.0 + 0 +INSERT + 5 +15D +330 +156 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +24000.0 + 20 +-1100.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 0 +LINE + 5 +15F +330 +156 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +16128.0 + 20 +-1100.0 + 30 +0.0 + 11 +23872.0 + 21 +-1100.0 + 31 +0.0 + 0 +MTEXT + 5 +160 +330 +156 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +20000.0 + 20 +-972.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +8000 + 7 +ARCH-TEXT + 0 +POINT + 5 +161 +330 +156 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +-1100.0 + 30 +0.0 + 0 +POINT + 5 +162 +330 +156 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +163 +330 +156 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +24000.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +158 +330 +156 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +194 +330 +193 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D7 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D7 + 1 + + 0 +LINE + 5 +196 +330 +193 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-128.0 + 20 +-17000.0 + 30 +0.0 + 11 +-1996.0 + 21 +-17000.0 + 31 +0.0 + 0 +LINE + 5 +197 +330 +193 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-128.0 + 20 +-11600.0 + 30 +0.0 + 11 +-1996.0 + 21 +-11600.0 + 31 +0.0 + 0 +INSERT + 5 +198 +330 +193 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-1900.0 + 20 +-17000.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +270.0 + 0 +INSERT + 5 +19A +330 +193 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-1900.0 + 20 +-11600.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +90.0 + 0 +LINE + 5 +19C +330 +193 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-1900.0 + 20 +-16872.0 + 30 +0.0 + 11 +-1900.0 + 21 +-11728.0 + 31 +0.0 + 0 +MTEXT + 5 +19D +330 +193 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-2028.0 + 20 +-14300.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +5400 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +19E +330 +193 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-1900.0 + 20 +-17000.0 + 30 +0.0 + 0 +POINT + 5 +19F +330 +193 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-17000.0 + 30 +0.0 + 0 +POINT + 5 +1A0 +330 +193 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-11600.0 + 30 +0.0 + 0 +ENDBLK + 5 +195 +330 +193 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1A3 +330 +1A2 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D8 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D8 + 1 + + 0 +LINE + 5 +1A5 +330 +1A2 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-128.0 + 20 +-11600.0 + 30 +0.0 + 11 +-1996.0 + 21 +-11600.0 + 31 +0.0 + 0 +LINE + 5 +1A6 +330 +1A2 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-128.0 + 20 +-8700.0 + 30 +0.0 + 11 +-1996.0 + 21 +-8700.0 + 31 +0.0 + 0 +INSERT + 5 +1A7 +330 +1A2 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-1900.0 + 20 +-11600.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +270.0 + 0 +INSERT + 5 +1A9 +330 +1A2 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-1900.0 + 20 +-8700.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +90.0 + 0 +LINE + 5 +1AB +330 +1A2 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-1900.0 + 20 +-11472.0 + 30 +0.0 + 11 +-1900.0 + 21 +-8828.0 + 31 +0.0 + 0 +MTEXT + 5 +1AC +330 +1A2 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-2028.0 + 20 +-10150.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +2900 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +1AD +330 +1A2 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-1900.0 + 20 +-11600.0 + 30 +0.0 + 0 +POINT + 5 +1AE +330 +1A2 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-11600.0 + 30 +0.0 + 0 +POINT + 5 +1AF +330 +1A2 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-8700.0 + 30 +0.0 + 0 +ENDBLK + 5 +1A4 +330 +1A2 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1B2 +330 +1B1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D9 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D9 + 1 + + 0 +LINE + 5 +1B4 +330 +1B1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-17128.0 + 30 +0.0 + 11 +0.0 + 21 +-18196.0 + 31 +0.0 + 0 +LINE + 5 +1B5 +330 +1B1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +10000.0 + 20 +-17128.0 + 30 +0.0 + 11 +10000.0 + 21 +-18196.0 + 31 +0.0 + 0 +INSERT + 5 +1B6 +330 +1B1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-18100.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +180.0 + 0 +INSERT + 5 +1B8 +330 +1B1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +10000.0 + 20 +-18100.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 0 +LINE + 5 +1BA +330 +1B1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +128.0 + 20 +-18100.0 + 30 +0.0 + 11 +9872.0 + 21 +-18100.0 + 31 +0.0 + 0 +MTEXT + 5 +1BB +330 +1B1 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +5000.0 + 20 +-17972.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +10000 + 7 +ARCH-TEXT + 0 +POINT + 5 +1BC +330 +1B1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-18100.0 + 30 +0.0 + 0 +POINT + 5 +1BD +330 +1B1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-17000.0 + 30 +0.0 + 0 +POINT + 5 +1BE +330 +1B1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +10000.0 + 20 +-17000.0 + 30 +0.0 + 0 +ENDBLK + 5 +1B3 +330 +1B1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1EA +330 +1E9 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D10 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D10 + 1 + + 0 +LINE + 5 +1EC +330 +1E9 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +15872.0 + 20 +-17000.0 + 30 +0.0 + 11 +14004.0 + 21 +-17000.0 + 31 +0.0 + 0 +LINE + 5 +1ED +330 +1E9 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +15872.0 + 20 +-11600.0 + 30 +0.0 + 11 +14004.0 + 21 +-11600.0 + 31 +0.0 + 0 +INSERT + 5 +1EE +330 +1E9 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14100.0 + 20 +-17000.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +270.0 + 0 +INSERT + 5 +1F0 +330 +1E9 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14100.0 + 20 +-11600.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +90.0 + 0 +LINE + 5 +1F2 +330 +1E9 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +14100.0 + 20 +-16872.0 + 30 +0.0 + 11 +14100.0 + 21 +-11728.0 + 31 +0.0 + 0 +MTEXT + 5 +1F3 +330 +1E9 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +13972.0 + 20 +-14300.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +5400 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +1F4 +330 +1E9 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14100.0 + 20 +-17000.0 + 30 +0.0 + 0 +POINT + 5 +1F5 +330 +1E9 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +-17000.0 + 30 +0.0 + 0 +POINT + 5 +1F6 +330 +1E9 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +-11600.0 + 30 +0.0 + 0 +ENDBLK + 5 +1EB +330 +1E9 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1F9 +330 +1F8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D11 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D11 + 1 + + 0 +LINE + 5 +1FB +330 +1F8 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +15872.0 + 20 +-11600.0 + 30 +0.0 + 11 +14004.0 + 21 +-11600.0 + 31 +0.0 + 0 +LINE + 5 +1FC +330 +1F8 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +15872.0 + 20 +-8700.0 + 30 +0.0 + 11 +14004.0 + 21 +-8700.0 + 31 +0.0 + 0 +INSERT + 5 +1FD +330 +1F8 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14100.0 + 20 +-11600.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +270.0 + 0 +INSERT + 5 +1FF +330 +1F8 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14100.0 + 20 +-8700.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +90.0 + 0 +LINE + 5 +201 +330 +1F8 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +14100.0 + 20 +-11472.0 + 30 +0.0 + 11 +14100.0 + 21 +-8828.0 + 31 +0.0 + 0 +MTEXT + 5 +202 +330 +1F8 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +13972.0 + 20 +-10150.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +2900 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +203 +330 +1F8 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14100.0 + 20 +-11600.0 + 30 +0.0 + 0 +POINT + 5 +204 +330 +1F8 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +-11600.0 + 30 +0.0 + 0 +POINT + 5 +205 +330 +1F8 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +-8700.0 + 30 +0.0 + 0 +ENDBLK + 5 +1FA +330 +1F8 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +208 +330 +207 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D12 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D12 + 1 + + 0 +LINE + 5 +20A +330 +207 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +16000.0 + 20 +-17128.0 + 30 +0.0 + 11 +16000.0 + 21 +-18196.0 + 31 +0.0 + 0 +LINE + 5 +20B +330 +207 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +24000.0 + 20 +-17128.0 + 30 +0.0 + 11 +24000.0 + 21 +-18196.0 + 31 +0.0 + 0 +INSERT + 5 +20C +330 +207 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +16000.0 + 20 +-18100.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 50 +180.0 + 0 +INSERT + 5 +20E +330 +207 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +24000.0 + 20 +-18100.0 + 30 +0.0 + 41 +128.0 + 42 +128.0 + 0 +LINE + 5 +210 +330 +207 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +16128.0 + 20 +-18100.0 + 30 +0.0 + 11 +23872.0 + 21 +-18100.0 + 31 +0.0 + 0 +MTEXT + 5 +211 +330 +207 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +20000.0 + 20 +-17972.0 + 30 +0.0 + 40 +160.0 + 71 +5 + 1 +8000 + 7 +ARCH-TEXT + 0 +POINT + 5 +212 +330 +207 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +-18100.0 + 30 +0.0 + 0 +POINT + 5 +213 +330 +207 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +16000.0 + 20 +-17000.0 + 30 +0.0 + 0 +POINT + 5 +214 +330 +207 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +24000.0 + 20 +-17000.0 + 30 +0.0 + 0 +ENDBLK + 5 +209 +330 +207 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LWPOLYLINE + 5 +9C +330 +17 +100 +AcDbEntity + 8 +E-GROUND +370 +70 +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +-1500.0 + 20 +0.0 + 10 +11500.0 + 20 +0.0 + 0 +HATCH + 5 +9D +330 +17 +100 +AcDbEntity + 8 +E-GROUND + 62 +3 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +EARTH + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +-1500.0 + 20 +-600.0 + 10 +11500.0 + 20 +-600.0 + 10 +11500.0 + 20 +0.0 + 10 +-1500.0 + 20 +0.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +6 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +0.0 + 43 +0.0 + 44 +142.875 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +0.0 + 43 +0.0 + 44 +285.75 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +47.625 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +190.5 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +333.375 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 98 +0 + 0 +LWPOLYLINE + 5 +9E +330 +17 +100 +AcDbEntity + 8 +E-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +10000.0 + 20 +0.0 + 10 +10000.0 + 20 +5400.0 + 10 +0.0 + 20 +5400.0 + 0 +HATCH + 5 +9F +330 +17 +100 +AcDbEntity + 8 +E-RENDER + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-SAND + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +0.0 + 10 +10000.0 + 20 +0.0 + 10 +10000.0 + 20 +5400.0 + 10 +0.0 + 20 +5400.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +15.0 + 77 +0 + 78 +4 + 53 +37.5 + 43 +0.0 + 44 +0.0 + 45 +-24.00046944 + 46 +734.1198559935 + 79 +6 + 49 +0.0 + 49 +-579.12 + 49 +0.0 + 49 +-647.7 + 49 +0.0 + 49 +-619.125 + 53 +7.5 + 43 +0.0 + 44 +0.0 + 45 +674.284924707 + 46 +1075.237650852 + 79 +6 + 49 +0.0 + 49 +-312.42 + 49 +0.0 + 49 +-521.97 + 49 +0.0 + 49 +-200.025 + 53 +-32.5 + 43 +-468.63 + 44 +0.0 + 45 +1186.4885553615 + 46 +2.1557770185 + 79 +6 + 49 +0.0 + 49 +-190.5 + 49 +0.0 + 49 +-685.8 + 49 +0.0 + 49 +-895.35 + 53 +-42.5 + 43 +-468.63 + 44 +0.0 + 45 +1145.334678708 + 46 +334.3939848855 + 79 +6 + 49 +0.0 + 49 +-95.25 + 49 +0.0 + 49 +-449.58 + 49 +0.0 + 49 +-514.35 + 98 +0 + 0 +LWPOLYLINE + 5 +A0 +330 +17 +100 +AcDbEntity + 8 +E-PLINTH +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +10000.0 + 20 +0.0 + 10 +10000.0 + 20 +450.0 + 10 +0.0 + 20 +450.0 + 0 +LWPOLYLINE + 5 +A1 +330 +17 +100 +AcDbEntity + 8 +E-ROOF +370 +50 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +-450.0 + 20 +5400.0 + 10 +5000.0 + 20 +8300.0 + 10 +10450.0 + 20 +5400.0 + 0 +HATCH + 5 +A2 +330 +17 +100 +AcDbEntity + 8 +E-ROOF + 62 +1 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-RROOF + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +3 + 10 +-450.0 + 20 +5400.0 + 10 +5000.0 + 20 +8300.0 + 10 +10450.0 + 20 +5400.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +3352.8 + 46 +1524.0 + 79 +4 + 49 +22860.0 + 49 +-3048.0 + 49 +7620.0 + 49 +-1524.0 + 53 +0.0 + 43 +2026.92 + 44 +762.0 + 45 +-1524.0 + 46 +2026.92 + 79 +4 + 49 +4572.0 + 49 +-502.92 + 49 +9144.0 + 49 +-1143.0 + 53 +0.0 + 43 +762.0 + 44 +1295.4 + 45 +7924.8 + 46 +1021.08 + 79 +4 + 49 +12192.0 + 49 +-2133.6 + 49 +6096.0 + 49 +-1524.0 + 98 +0 + 0 +LINE + 5 +A3 +330 +17 +100 +AcDbEntity + 8 +E-FLOOR + 6 +DASHDOT +100 +AcDbLine + 10 +0.0 + 20 +2700.0 + 30 +0.0 + 11 +10000.0 + 21 +2700.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +A4 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3600.0 + 20 +1100.0 + 10 +4500.0 + 20 +1100.0 + 10 +4500.0 + 20 +2400.0 + 10 +3600.0 + 20 +2400.0 + 0 +LWPOLYLINE + 5 +A5 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3660.0 + 20 +1160.0 + 10 +4440.0 + 20 +1160.0 + 10 +4440.0 + 20 +2340.0 + 10 +3660.0 + 20 +2340.0 + 0 +LINE + 5 +A6 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +4050.0 + 20 +1160.0 + 30 +0.0 + 11 +4050.0 + 21 +2340.0 + 31 +0.0 + 0 +HATCH + 5 +A7 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3660.0 + 20 +1160.0 + 10 +4440.0 + 20 +1160.0 + 10 +4440.0 + 20 +2340.0 + 10 +3660.0 + 20 +2340.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +A8 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3520.0 + 20 +1010.0 + 10 +4580.0 + 20 +1010.0 + 10 +4580.0 + 20 +1100.0 + 10 +3520.0 + 20 +1100.0 + 0 +LWPOLYLINE + 5 +A9 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6200.0 + 20 +1100.0 + 10 +7100.0 + 20 +1100.0 + 10 +7100.0 + 20 +2400.0 + 10 +6200.0 + 20 +2400.0 + 0 +LWPOLYLINE + 5 +AA +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6260.0 + 20 +1160.0 + 10 +7040.0 + 20 +1160.0 + 10 +7040.0 + 20 +2340.0 + 10 +6260.0 + 20 +2340.0 + 0 +LINE + 5 +AB +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +6650.0 + 20 +1160.0 + 30 +0.0 + 11 +6650.0 + 21 +2340.0 + 31 +0.0 + 0 +HATCH + 5 +AC +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6260.0 + 20 +1160.0 + 10 +7040.0 + 20 +1160.0 + 10 +7040.0 + 20 +2340.0 + 10 +6260.0 + 20 +2340.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +AD +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6120.0 + 20 +1010.0 + 10 +7180.0 + 20 +1010.0 + 10 +7180.0 + 20 +1100.0 + 10 +6120.0 + 20 +1100.0 + 0 +LWPOLYLINE + 5 +AE +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +8200.0 + 20 +1100.0 + 10 +9400.0 + 20 +1100.0 + 10 +9400.0 + 20 +2400.0 + 10 +8200.0 + 20 +2400.0 + 0 +LWPOLYLINE + 5 +AF +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +8260.0 + 20 +1160.0 + 10 +9340.0 + 20 +1160.0 + 10 +9340.0 + 20 +2340.0 + 10 +8260.0 + 20 +2340.0 + 0 +LINE + 5 +B0 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +8800.0 + 20 +1160.0 + 30 +0.0 + 11 +8800.0 + 21 +2340.0 + 31 +0.0 + 0 +HATCH + 5 +B1 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +8260.0 + 20 +1160.0 + 10 +9340.0 + 20 +1160.0 + 10 +9340.0 + 20 +2340.0 + 10 +8260.0 + 20 +2340.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +B2 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +8120.0 + 20 +1010.0 + 10 +9480.0 + 20 +1010.0 + 10 +9480.0 + 20 +1100.0 + 10 +8120.0 + 20 +1100.0 + 0 +LWPOLYLINE + 5 +B3 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +800.0 + 20 +3400.0 + 10 +1800.0 + 20 +3400.0 + 10 +1800.0 + 20 +4800.0 + 10 +800.0 + 20 +4800.0 + 0 +LWPOLYLINE + 5 +B4 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +860.0 + 20 +3460.0 + 10 +1740.0 + 20 +3460.0 + 10 +1740.0 + 20 +4740.0 + 10 +860.0 + 20 +4740.0 + 0 +LINE + 5 +B5 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +1300.0 + 20 +3460.0 + 30 +0.0 + 11 +1300.0 + 21 +4740.0 + 31 +0.0 + 0 +HATCH + 5 +B6 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +860.0 + 20 +3460.0 + 10 +1740.0 + 20 +3460.0 + 10 +1740.0 + 20 +4740.0 + 10 +860.0 + 20 +4740.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +B7 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +720.0 + 20 +3310.0 + 10 +1880.0 + 20 +3310.0 + 10 +1880.0 + 20 +3400.0 + 10 +720.0 + 20 +3400.0 + 0 +LWPOLYLINE + 5 +B8 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3600.0 + 20 +3400.0 + 10 +4500.0 + 20 +3400.0 + 10 +4500.0 + 20 +4800.0 + 10 +3600.0 + 20 +4800.0 + 0 +LWPOLYLINE + 5 +B9 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3660.0 + 20 +3460.0 + 10 +4440.0 + 20 +3460.0 + 10 +4440.0 + 20 +4740.0 + 10 +3660.0 + 20 +4740.0 + 0 +LINE + 5 +BA +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +4050.0 + 20 +3460.0 + 30 +0.0 + 11 +4050.0 + 21 +4740.0 + 31 +0.0 + 0 +HATCH + 5 +BB +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3660.0 + 20 +3460.0 + 10 +4440.0 + 20 +3460.0 + 10 +4440.0 + 20 +4740.0 + 10 +3660.0 + 20 +4740.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +BC +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3520.0 + 20 +3310.0 + 10 +4580.0 + 20 +3310.0 + 10 +4580.0 + 20 +3400.0 + 10 +3520.0 + 20 +3400.0 + 0 +LWPOLYLINE + 5 +BD +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6200.0 + 20 +3400.0 + 10 +7100.0 + 20 +3400.0 + 10 +7100.0 + 20 +4800.0 + 10 +6200.0 + 20 +4800.0 + 0 +LWPOLYLINE + 5 +BE +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6260.0 + 20 +3460.0 + 10 +7040.0 + 20 +3460.0 + 10 +7040.0 + 20 +4740.0 + 10 +6260.0 + 20 +4740.0 + 0 +LINE + 5 +BF +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +6650.0 + 20 +3460.0 + 30 +0.0 + 11 +6650.0 + 21 +4740.0 + 31 +0.0 + 0 +HATCH + 5 +C0 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6260.0 + 20 +3460.0 + 10 +7040.0 + 20 +3460.0 + 10 +7040.0 + 20 +4740.0 + 10 +6260.0 + 20 +4740.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +C1 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6120.0 + 20 +3310.0 + 10 +7180.0 + 20 +3310.0 + 10 +7180.0 + 20 +3400.0 + 10 +6120.0 + 20 +3400.0 + 0 +LWPOLYLINE + 5 +C2 +330 +17 +100 +AcDbEntity + 8 +E-DOOR +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1400.0 + 20 +0.0 + 10 +2900.0 + 20 +0.0 + 10 +2900.0 + 20 +2300.0 + 10 +1400.0 + 20 +2300.0 + 0 +LWPOLYLINE + 5 +C3 +330 +17 +100 +AcDbEntity + 8 +E-DOOR +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +100.0 + 10 +2800.0 + 20 +100.0 + 10 +2800.0 + 20 +2200.0 + 10 +1500.0 + 20 +2200.0 + 0 +CIRCLE + 5 +C4 +330 +17 +100 +AcDbEntity + 8 +E-DOOR +100 +AcDbCircle + 10 +2700.0 + 20 +1000.0 + 30 +0.0 + 40 +40.0 + 0 +LWPOLYLINE + 5 +C5 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1250.0 + 20 +300.0 + 10 +3050.0 + 20 +300.0 + 10 +3050.0 + 20 +450.0 + 10 +1250.0 + 20 +450.0 + 0 +LWPOLYLINE + 5 +C6 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1130.0 + 20 +150.0 + 10 +3170.0 + 20 +150.0 + 10 +3170.0 + 20 +300.0 + 10 +1130.0 + 20 +300.0 + 0 +LWPOLYLINE + 5 +C7 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1010.0 + 20 +0.0 + 10 +3290.0 + 20 +0.0 + 10 +3290.0 + 20 +150.0 + 10 +1010.0 + 20 +150.0 + 0 +INSERT + 5 +C8 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +11900.0 + 20 +0.0 + 30 +0.0 + 0 +ATTRIB + 5 +CA +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +12220.0 + 20 +290.0 + 30 +0.0 + 40 +120.0 + 1 ++0.00 + 11 +12220.0 + 21 +290.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +12220.0 + 21 +290.0 + 31 +0.0 + 0 +SEQEND + 5 +C9 +330 +C8 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +CB +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +10000.0 + 20 +0.0 + 30 +0.0 + 11 +11900.0 + 21 +0.0 + 31 +0.0 + 0 +INSERT + 5 +CC +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +11900.0 + 20 +2700.0 + 30 +0.0 + 0 +ATTRIB + 5 +CE +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +12220.0 + 20 +2990.0 + 30 +0.0 + 40 +120.0 + 1 ++2.70 + 11 +12220.0 + 21 +2990.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +12220.0 + 21 +2990.0 + 31 +0.0 + 0 +SEQEND + 5 +CD +330 +CC +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +CF +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +10000.0 + 20 +2700.0 + 30 +0.0 + 11 +11900.0 + 21 +2700.0 + 31 +0.0 + 0 +INSERT + 5 +D0 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +11900.0 + 20 +5400.0 + 30 +0.0 + 0 +ATTRIB + 5 +D2 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +12220.0 + 20 +5690.0 + 30 +0.0 + 40 +120.0 + 1 ++5.40 + 11 +12220.0 + 21 +5690.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +12220.0 + 21 +5690.0 + 31 +0.0 + 0 +SEQEND + 5 +D1 +330 +D0 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +D3 +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +10000.0 + 20 +5400.0 + 30 +0.0 + 11 +11900.0 + 21 +5400.0 + 31 +0.0 + 0 +INSERT + 5 +D4 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +11900.0 + 20 +8300.0 + 30 +0.0 + 0 +ATTRIB + 5 +D6 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +12220.0 + 20 +8590.0 + 30 +0.0 + 40 +120.0 + 1 ++8.30 + 11 +12220.0 + 21 +8590.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +12220.0 + 21 +8590.0 + 31 +0.0 + 0 +SEQEND + 5 +D5 +330 +D4 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +D7 +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +10000.0 + 20 +8300.0 + 30 +0.0 + 11 +11900.0 + 21 +8300.0 + 31 +0.0 + 0 +DIMENSION + 5 +D8 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D1 + 3 +ELEV50 + 10 +-1900.0 + 20 +0.0 + 30 +0.0 + 11 +-2028.0 + 21 +2700.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +0.0 + 24 +5400.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +EB +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D2 + 3 +ELEV50 + 10 +-1900.0 + 20 +5400.0 + 30 +0.0 + 11 +-2028.0 + 21 +6850.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +5400.0 + 33 +0.0 + 14 +0.0 + 24 +8300.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +FA +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D3 + 3 +ELEV50 + 10 +0.0 + 20 +-1100.0 + 30 +0.0 + 11 +5000.0 + 21 +-972.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +10000.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +TEXT + 5 +109 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +-2100.0 + 30 +0.0 + 40 +300.0 + 1 +SOUTH ELEVATION 1:50 + 7 +ARCH-TITLE + 11 +0.0 + 21 +-2100.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +10A +330 +17 +100 +AcDbEntity + 8 +A-ANNO +370 +70 +100 +AcDbLine + 10 +0.0 + 20 +-2250.0 + 30 +0.0 + 11 +3600.0 + 21 +-2250.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +10B +330 +17 +100 +AcDbEntity + 8 +E-GROUND +370 +70 +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +14500.0 + 20 +0.0 + 10 +25500.0 + 20 +0.0 + 0 +HATCH + 5 +10C +330 +17 +100 +AcDbEntity + 8 +E-GROUND + 62 +3 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +EARTH + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +14500.0 + 20 +-600.0 + 10 +25500.0 + 20 +-600.0 + 10 +25500.0 + 20 +0.0 + 10 +14500.0 + 20 +0.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +6 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +0.0 + 43 +0.0 + 44 +142.875 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +0.0 + 43 +0.0 + 44 +285.75 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +47.625 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +190.5 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +333.375 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 98 +0 + 0 +LWPOLYLINE + 5 +10D +330 +17 +100 +AcDbEntity + 8 +E-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +16000.0 + 20 +0.0 + 10 +24000.0 + 20 +0.0 + 10 +24000.0 + 20 +5400.0 + 10 +16000.0 + 20 +5400.0 + 0 +HATCH + 5 +10E +330 +17 +100 +AcDbEntity + 8 +E-RENDER + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-SAND + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +16000.0 + 20 +0.0 + 10 +24000.0 + 20 +0.0 + 10 +24000.0 + 20 +5400.0 + 10 +16000.0 + 20 +5400.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +15.0 + 77 +0 + 78 +4 + 53 +37.5 + 43 +0.0 + 44 +0.0 + 45 +-24.00046944 + 46 +734.1198559935 + 79 +6 + 49 +0.0 + 49 +-579.12 + 49 +0.0 + 49 +-647.7 + 49 +0.0 + 49 +-619.125 + 53 +7.5 + 43 +0.0 + 44 +0.0 + 45 +674.284924707 + 46 +1075.237650852 + 79 +6 + 49 +0.0 + 49 +-312.42 + 49 +0.0 + 49 +-521.97 + 49 +0.0 + 49 +-200.025 + 53 +-32.5 + 43 +-468.63 + 44 +0.0 + 45 +1186.4885553615 + 46 +2.1557770185 + 79 +6 + 49 +0.0 + 49 +-190.5 + 49 +0.0 + 49 +-685.8 + 49 +0.0 + 49 +-895.35 + 53 +-42.5 + 43 +-468.63 + 44 +0.0 + 45 +1145.334678708 + 46 +334.3939848855 + 79 +6 + 49 +0.0 + 49 +-95.25 + 49 +0.0 + 49 +-449.58 + 49 +0.0 + 49 +-514.35 + 98 +0 + 0 +LWPOLYLINE + 5 +10F +330 +17 +100 +AcDbEntity + 8 +E-PLINTH +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +16000.0 + 20 +0.0 + 10 +24000.0 + 20 +0.0 + 10 +24000.0 + 20 +450.0 + 10 +16000.0 + 20 +450.0 + 0 +LWPOLYLINE + 5 +110 +330 +17 +100 +AcDbEntity + 8 +E-ROOF +370 +50 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +15550.0 + 20 +5400.0 + 10 +20000.0 + 20 +8300.0 + 10 +24450.0 + 20 +5400.0 + 0 +HATCH + 5 +111 +330 +17 +100 +AcDbEntity + 8 +E-ROOF + 62 +1 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-RROOF + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +3 + 10 +15550.0 + 20 +5400.0 + 10 +20000.0 + 20 +8300.0 + 10 +24450.0 + 20 +5400.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +3352.8 + 46 +1524.0 + 79 +4 + 49 +22860.0 + 49 +-3048.0 + 49 +7620.0 + 49 +-1524.0 + 53 +0.0 + 43 +2026.92 + 44 +762.0 + 45 +-1524.0 + 46 +2026.92 + 79 +4 + 49 +4572.0 + 49 +-502.92 + 49 +9144.0 + 49 +-1143.0 + 53 +0.0 + 43 +762.0 + 44 +1295.4 + 45 +7924.8 + 46 +1021.08 + 79 +4 + 49 +12192.0 + 49 +-2133.6 + 49 +6096.0 + 49 +-1524.0 + 98 +0 + 0 +LINE + 5 +112 +330 +17 +100 +AcDbEntity + 8 +E-FLOOR + 6 +DASHDOT +100 +AcDbLine + 10 +16000.0 + 20 +2700.0 + 30 +0.0 + 11 +24000.0 + 21 +2700.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +113 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +17500.0 + 20 +1100.0 + 10 +18800.0 + 20 +1100.0 + 10 +18800.0 + 20 +2400.0 + 10 +17500.0 + 20 +2400.0 + 0 +LWPOLYLINE + 5 +114 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +17560.0 + 20 +1160.0 + 10 +18740.0 + 20 +1160.0 + 10 +18740.0 + 20 +2340.0 + 10 +17560.0 + 20 +2340.0 + 0 +LINE + 5 +115 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +18150.0 + 20 +1160.0 + 30 +0.0 + 11 +18150.0 + 21 +2340.0 + 31 +0.0 + 0 +HATCH + 5 +116 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +17560.0 + 20 +1160.0 + 10 +18740.0 + 20 +1160.0 + 10 +18740.0 + 20 +2340.0 + 10 +17560.0 + 20 +2340.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +117 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +17420.0 + 20 +1010.0 + 10 +18880.0 + 20 +1010.0 + 10 +18880.0 + 20 +1100.0 + 10 +17420.0 + 20 +1100.0 + 0 +LWPOLYLINE + 5 +118 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20800.0 + 20 +1100.0 + 10 +22100.0 + 20 +1100.0 + 10 +22100.0 + 20 +2400.0 + 10 +20800.0 + 20 +2400.0 + 0 +LWPOLYLINE + 5 +119 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20860.0 + 20 +1160.0 + 10 +22040.0 + 20 +1160.0 + 10 +22040.0 + 20 +2340.0 + 10 +20860.0 + 20 +2340.0 + 0 +LINE + 5 +11A +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +21450.0 + 20 +1160.0 + 30 +0.0 + 11 +21450.0 + 21 +2340.0 + 31 +0.0 + 0 +HATCH + 5 +11B +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +20860.0 + 20 +1160.0 + 10 +22040.0 + 20 +1160.0 + 10 +22040.0 + 20 +2340.0 + 10 +20860.0 + 20 +2340.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +11C +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20720.0 + 20 +1010.0 + 10 +22180.0 + 20 +1010.0 + 10 +22180.0 + 20 +1100.0 + 10 +20720.0 + 20 +1100.0 + 0 +LWPOLYLINE + 5 +11D +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +17500.0 + 20 +3400.0 + 10 +18800.0 + 20 +3400.0 + 10 +18800.0 + 20 +4800.0 + 10 +17500.0 + 20 +4800.0 + 0 +LWPOLYLINE + 5 +11E +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +17560.0 + 20 +3460.0 + 10 +18740.0 + 20 +3460.0 + 10 +18740.0 + 20 +4740.0 + 10 +17560.0 + 20 +4740.0 + 0 +LINE + 5 +11F +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +18150.0 + 20 +3460.0 + 30 +0.0 + 11 +18150.0 + 21 +4740.0 + 31 +0.0 + 0 +HATCH + 5 +120 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +17560.0 + 20 +3460.0 + 10 +18740.0 + 20 +3460.0 + 10 +18740.0 + 20 +4740.0 + 10 +17560.0 + 20 +4740.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +121 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +17420.0 + 20 +3310.0 + 10 +18880.0 + 20 +3310.0 + 10 +18880.0 + 20 +3400.0 + 10 +17420.0 + 20 +3400.0 + 0 +LWPOLYLINE + 5 +122 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20800.0 + 20 +3400.0 + 10 +22100.0 + 20 +3400.0 + 10 +22100.0 + 20 +4800.0 + 10 +20800.0 + 20 +4800.0 + 0 +LWPOLYLINE + 5 +123 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20860.0 + 20 +3460.0 + 10 +22040.0 + 20 +3460.0 + 10 +22040.0 + 20 +4740.0 + 10 +20860.0 + 20 +4740.0 + 0 +LINE + 5 +124 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +21450.0 + 20 +3460.0 + 30 +0.0 + 11 +21450.0 + 21 +4740.0 + 31 +0.0 + 0 +HATCH + 5 +125 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +20860.0 + 20 +3460.0 + 10 +22040.0 + 20 +3460.0 + 10 +22040.0 + 20 +4740.0 + 10 +20860.0 + 20 +4740.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +126 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20720.0 + 20 +3310.0 + 10 +22180.0 + 20 +3310.0 + 10 +22180.0 + 20 +3400.0 + 10 +20720.0 + 20 +3400.0 + 0 +INSERT + 5 +127 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +25900.0 + 20 +0.0 + 30 +0.0 + 0 +ATTRIB + 5 +129 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +26220.0 + 20 +290.0 + 30 +0.0 + 40 +120.0 + 1 ++0.00 + 11 +26220.0 + 21 +290.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +26220.0 + 21 +290.0 + 31 +0.0 + 0 +SEQEND + 5 +128 +330 +127 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +12A +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +24000.0 + 20 +0.0 + 30 +0.0 + 11 +25900.0 + 21 +0.0 + 31 +0.0 + 0 +INSERT + 5 +12B +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +25900.0 + 20 +2700.0 + 30 +0.0 + 0 +ATTRIB + 5 +12D +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +26220.0 + 20 +2990.0 + 30 +0.0 + 40 +120.0 + 1 ++2.70 + 11 +26220.0 + 21 +2990.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +26220.0 + 21 +2990.0 + 31 +0.0 + 0 +SEQEND + 5 +12C +330 +12B +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +12E +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +24000.0 + 20 +2700.0 + 30 +0.0 + 11 +25900.0 + 21 +2700.0 + 31 +0.0 + 0 +INSERT + 5 +12F +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +25900.0 + 20 +5400.0 + 30 +0.0 + 0 +ATTRIB + 5 +131 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +26220.0 + 20 +5690.0 + 30 +0.0 + 40 +120.0 + 1 ++5.40 + 11 +26220.0 + 21 +5690.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +26220.0 + 21 +5690.0 + 31 +0.0 + 0 +SEQEND + 5 +130 +330 +12F +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +132 +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +24000.0 + 20 +5400.0 + 30 +0.0 + 11 +25900.0 + 21 +5400.0 + 31 +0.0 + 0 +INSERT + 5 +133 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +25900.0 + 20 +8300.0 + 30 +0.0 + 0 +ATTRIB + 5 +135 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +26220.0 + 20 +8590.0 + 30 +0.0 + 40 +120.0 + 1 ++8.30 + 11 +26220.0 + 21 +8590.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +26220.0 + 21 +8590.0 + 31 +0.0 + 0 +SEQEND + 5 +134 +330 +133 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +136 +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +24000.0 + 20 +8300.0 + 30 +0.0 + 11 +25900.0 + 21 +8300.0 + 31 +0.0 + 0 +DIMENSION + 5 +137 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D4 + 3 +ELEV50 + 10 +14100.0 + 20 +0.0 + 30 +0.0 + 11 +13972.0 + 21 +2700.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +16000.0 + 23 +0.0 + 33 +0.0 + 14 +16000.0 + 24 +5400.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +146 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D5 + 3 +ELEV50 + 10 +14100.0 + 20 +5400.0 + 30 +0.0 + 11 +13972.0 + 21 +6850.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +16000.0 + 23 +5400.0 + 33 +0.0 + 14 +16000.0 + 24 +8300.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +155 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D6 + 3 +ELEV50 + 10 +16000.0 + 20 +-1100.0 + 30 +0.0 + 11 +20000.0 + 21 +-972.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +16000.0 + 23 +0.0 + 33 +0.0 + 14 +24000.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +TEXT + 5 +164 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +16000.0 + 20 +-2100.0 + 30 +0.0 + 40 +300.0 + 1 +EAST ELEVATION 1:50 + 7 +ARCH-TITLE + 11 +16000.0 + 21 +-2100.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +165 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +370 +70 +100 +AcDbLine + 10 +16000.0 + 20 +-2250.0 + 30 +0.0 + 11 +19600.0 + 21 +-2250.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +166 +330 +17 +100 +AcDbEntity + 8 +E-GROUND +370 +70 +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +-1500.0 + 20 +-17000.0 + 10 +11500.0 + 20 +-17000.0 + 0 +HATCH + 5 +167 +330 +17 +100 +AcDbEntity + 8 +E-GROUND + 62 +3 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +EARTH + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +-1500.0 + 20 +-17600.0 + 10 +11500.0 + 20 +-17600.0 + 10 +11500.0 + 20 +-17000.0 + 10 +-1500.0 + 20 +-17000.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +6 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +0.0 + 43 +0.0 + 44 +142.875 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +0.0 + 43 +0.0 + 44 +285.75 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +47.625 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +190.5 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +333.375 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 98 +0 + 0 +LWPOLYLINE + 5 +168 +330 +17 +100 +AcDbEntity + 8 +E-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-17000.0 + 10 +10000.0 + 20 +-17000.0 + 10 +10000.0 + 20 +-11600.0 + 10 +0.0 + 20 +-11600.0 + 0 +HATCH + 5 +169 +330 +17 +100 +AcDbEntity + 8 +E-RENDER + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-SAND + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-17000.0 + 10 +10000.0 + 20 +-17000.0 + 10 +10000.0 + 20 +-11600.0 + 10 +0.0 + 20 +-11600.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +15.0 + 77 +0 + 78 +4 + 53 +37.5 + 43 +0.0 + 44 +0.0 + 45 +-24.00046944 + 46 +734.1198559935 + 79 +6 + 49 +0.0 + 49 +-579.12 + 49 +0.0 + 49 +-647.7 + 49 +0.0 + 49 +-619.125 + 53 +7.5 + 43 +0.0 + 44 +0.0 + 45 +674.284924707 + 46 +1075.237650852 + 79 +6 + 49 +0.0 + 49 +-312.42 + 49 +0.0 + 49 +-521.97 + 49 +0.0 + 49 +-200.025 + 53 +-32.5 + 43 +-468.63 + 44 +0.0 + 45 +1186.4885553615 + 46 +2.1557770185 + 79 +6 + 49 +0.0 + 49 +-190.5 + 49 +0.0 + 49 +-685.8 + 49 +0.0 + 49 +-895.35 + 53 +-42.5 + 43 +-468.63 + 44 +0.0 + 45 +1145.334678708 + 46 +334.3939848855 + 79 +6 + 49 +0.0 + 49 +-95.25 + 49 +0.0 + 49 +-449.58 + 49 +0.0 + 49 +-514.35 + 98 +0 + 0 +LWPOLYLINE + 5 +16A +330 +17 +100 +AcDbEntity + 8 +E-PLINTH +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-17000.0 + 10 +10000.0 + 20 +-17000.0 + 10 +10000.0 + 20 +-16550.0 + 10 +0.0 + 20 +-16550.0 + 0 +LWPOLYLINE + 5 +16B +330 +17 +100 +AcDbEntity + 8 +E-ROOF +370 +50 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +-450.0 + 20 +-11600.0 + 10 +5000.0 + 20 +-8700.0 + 10 +10450.0 + 20 +-11600.0 + 0 +HATCH + 5 +16C +330 +17 +100 +AcDbEntity + 8 +E-ROOF + 62 +1 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-RROOF + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +3 + 10 +-450.0 + 20 +-11600.0 + 10 +5000.0 + 20 +-8700.0 + 10 +10450.0 + 20 +-11600.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +3352.8 + 46 +1524.0 + 79 +4 + 49 +22860.0 + 49 +-3048.0 + 49 +7620.0 + 49 +-1524.0 + 53 +0.0 + 43 +2026.92 + 44 +762.0 + 45 +-1524.0 + 46 +2026.92 + 79 +4 + 49 +4572.0 + 49 +-502.92 + 49 +9144.0 + 49 +-1143.0 + 53 +0.0 + 43 +762.0 + 44 +1295.4 + 45 +7924.8 + 46 +1021.08 + 79 +4 + 49 +12192.0 + 49 +-2133.6 + 49 +6096.0 + 49 +-1524.0 + 98 +0 + 0 +LINE + 5 +16D +330 +17 +100 +AcDbEntity + 8 +E-FLOOR + 6 +DASHDOT +100 +AcDbLine + 10 +0.0 + 20 +-14300.0 + 30 +0.0 + 11 +10000.0 + 21 +-14300.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +16E +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1200.0 + 20 +-15900.0 + 10 +2600.0 + 20 +-15900.0 + 10 +2600.0 + 20 +-14600.0 + 10 +1200.0 + 20 +-14600.0 + 0 +LWPOLYLINE + 5 +16F +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1260.0 + 20 +-15840.0 + 10 +2540.0 + 20 +-15840.0 + 10 +2540.0 + 20 +-14660.0 + 10 +1260.0 + 20 +-14660.0 + 0 +LINE + 5 +170 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +1900.0 + 20 +-15840.0 + 30 +0.0 + 11 +1900.0 + 21 +-14660.0 + 31 +0.0 + 0 +HATCH + 5 +171 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +1260.0 + 20 +-15840.0 + 10 +2540.0 + 20 +-15840.0 + 10 +2540.0 + 20 +-14660.0 + 10 +1260.0 + 20 +-14660.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +172 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1120.0 + 20 +-15990.0 + 10 +2680.0 + 20 +-15990.0 + 10 +2680.0 + 20 +-15900.0 + 10 +1120.0 + 20 +-15900.0 + 0 +LWPOLYLINE + 5 +173 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3600.0 + 20 +-15900.0 + 10 +5200.0 + 20 +-15900.0 + 10 +5200.0 + 20 +-14600.0 + 10 +3600.0 + 20 +-14600.0 + 0 +LWPOLYLINE + 5 +174 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3660.0 + 20 +-15840.0 + 10 +5140.0 + 20 +-15840.0 + 10 +5140.0 + 20 +-14660.0 + 10 +3660.0 + 20 +-14660.0 + 0 +LINE + 5 +175 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +4400.0 + 20 +-15840.0 + 30 +0.0 + 11 +4400.0 + 21 +-14660.0 + 31 +0.0 + 0 +HATCH + 5 +176 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3660.0 + 20 +-15840.0 + 10 +5140.0 + 20 +-15840.0 + 10 +5140.0 + 20 +-14660.0 + 10 +3660.0 + 20 +-14660.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +177 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3520.0 + 20 +-15990.0 + 10 +5280.0 + 20 +-15990.0 + 10 +5280.0 + 20 +-15900.0 + 10 +3520.0 + 20 +-15900.0 + 0 +LWPOLYLINE + 5 +178 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6600.0 + 20 +-15900.0 + 10 +7800.0 + 20 +-15900.0 + 10 +7800.0 + 20 +-14600.0 + 10 +6600.0 + 20 +-14600.0 + 0 +LWPOLYLINE + 5 +179 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6660.0 + 20 +-15840.0 + 10 +7740.0 + 20 +-15840.0 + 10 +7740.0 + 20 +-14660.0 + 10 +6660.0 + 20 +-14660.0 + 0 +LINE + 5 +17A +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +7200.0 + 20 +-15840.0 + 30 +0.0 + 11 +7200.0 + 21 +-14660.0 + 31 +0.0 + 0 +HATCH + 5 +17B +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6660.0 + 20 +-15840.0 + 10 +7740.0 + 20 +-15840.0 + 10 +7740.0 + 20 +-14660.0 + 10 +6660.0 + 20 +-14660.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +17C +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6520.0 + 20 +-15990.0 + 10 +7880.0 + 20 +-15990.0 + 10 +7880.0 + 20 +-15900.0 + 10 +6520.0 + 20 +-15900.0 + 0 +LWPOLYLINE + 5 +17D +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3600.0 + 20 +-13600.0 + 10 +5200.0 + 20 +-13600.0 + 10 +5200.0 + 20 +-12200.0 + 10 +3600.0 + 20 +-12200.0 + 0 +LWPOLYLINE + 5 +17E +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3660.0 + 20 +-13540.0 + 10 +5140.0 + 20 +-13540.0 + 10 +5140.0 + 20 +-12260.0 + 10 +3660.0 + 20 +-12260.0 + 0 +LINE + 5 +17F +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +4400.0 + 20 +-13540.0 + 30 +0.0 + 11 +4400.0 + 21 +-12260.0 + 31 +0.0 + 0 +HATCH + 5 +180 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3660.0 + 20 +-13540.0 + 10 +5140.0 + 20 +-13540.0 + 10 +5140.0 + 20 +-12260.0 + 10 +3660.0 + 20 +-12260.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +181 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3520.0 + 20 +-13690.0 + 10 +5280.0 + 20 +-13690.0 + 10 +5280.0 + 20 +-13600.0 + 10 +3520.0 + 20 +-13600.0 + 0 +INSERT + 5 +182 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +11900.0 + 20 +-17000.0 + 30 +0.0 + 0 +ATTRIB + 5 +184 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +12220.0 + 20 +-16710.0 + 30 +0.0 + 40 +120.0 + 1 ++0.00 + 11 +12220.0 + 21 +-16710.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +12220.0 + 21 +-16710.0 + 31 +0.0 + 0 +SEQEND + 5 +183 +330 +182 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +185 +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +10000.0 + 20 +-17000.0 + 30 +0.0 + 11 +11900.0 + 21 +-17000.0 + 31 +0.0 + 0 +INSERT + 5 +186 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +11900.0 + 20 +-14300.0 + 30 +0.0 + 0 +ATTRIB + 5 +188 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +12220.0 + 20 +-14010.0 + 30 +0.0 + 40 +120.0 + 1 ++2.70 + 11 +12220.0 + 21 +-14010.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +12220.0 + 21 +-14010.0 + 31 +0.0 + 0 +SEQEND + 5 +187 +330 +186 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +189 +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +10000.0 + 20 +-14300.0 + 30 +0.0 + 11 +11900.0 + 21 +-14300.0 + 31 +0.0 + 0 +INSERT + 5 +18A +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +11900.0 + 20 +-11600.0 + 30 +0.0 + 0 +ATTRIB + 5 +18C +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +12220.0 + 20 +-11310.0 + 30 +0.0 + 40 +120.0 + 1 ++5.40 + 11 +12220.0 + 21 +-11310.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +12220.0 + 21 +-11310.0 + 31 +0.0 + 0 +SEQEND + 5 +18B +330 +18A +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +18D +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +10000.0 + 20 +-11600.0 + 30 +0.0 + 11 +11900.0 + 21 +-11600.0 + 31 +0.0 + 0 +INSERT + 5 +18E +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +11900.0 + 20 +-8700.0 + 30 +0.0 + 0 +ATTRIB + 5 +190 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +12220.0 + 20 +-8410.0 + 30 +0.0 + 40 +120.0 + 1 ++8.30 + 11 +12220.0 + 21 +-8410.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +12220.0 + 21 +-8410.0 + 31 +0.0 + 0 +SEQEND + 5 +18F +330 +18E +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +191 +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +10000.0 + 20 +-8700.0 + 30 +0.0 + 11 +11900.0 + 21 +-8700.0 + 31 +0.0 + 0 +DIMENSION + 5 +192 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D7 + 3 +ELEV50 + 10 +-1900.0 + 20 +-17000.0 + 30 +0.0 + 11 +-2028.0 + 21 +-14300.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +-17000.0 + 33 +0.0 + 14 +0.0 + 24 +-11600.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +1A1 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D8 + 3 +ELEV50 + 10 +-1900.0 + 20 +-11600.0 + 30 +0.0 + 11 +-2028.0 + 21 +-10150.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +-11600.0 + 33 +0.0 + 14 +0.0 + 24 +-8700.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +1B0 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D9 + 3 +ELEV50 + 10 +0.0 + 20 +-18100.0 + 30 +0.0 + 11 +5000.0 + 21 +-17972.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +-17000.0 + 33 +0.0 + 14 +10000.0 + 24 +-17000.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +TEXT + 5 +1BF +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +-19100.0 + 30 +0.0 + 40 +300.0 + 1 +NORTH ELEVATION 1:50 + 7 +ARCH-TITLE + 11 +0.0 + 21 +-19100.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +1C0 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +370 +70 +100 +AcDbLine + 10 +0.0 + 20 +-19250.0 + 30 +0.0 + 11 +3600.0 + 21 +-19250.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +1C1 +330 +17 +100 +AcDbEntity + 8 +E-GROUND +370 +70 +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +14500.0 + 20 +-17000.0 + 10 +25500.0 + 20 +-17000.0 + 0 +HATCH + 5 +1C2 +330 +17 +100 +AcDbEntity + 8 +E-GROUND + 62 +3 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +EARTH + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +14500.0 + 20 +-17600.0 + 10 +25500.0 + 20 +-17600.0 + 10 +25500.0 + 20 +-17000.0 + 10 +14500.0 + 20 +-17000.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +6 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +0.0 + 43 +0.0 + 44 +142.875 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +0.0 + 43 +0.0 + 44 +285.75 + 45 +381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +47.625 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +190.5 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 53 +90.0 + 43 +333.375 + 44 +333.375 + 45 +-381.0 + 46 +381.0 + 79 +2 + 49 +381.0 + 49 +-381.0 + 98 +0 + 0 +LWPOLYLINE + 5 +1C3 +330 +17 +100 +AcDbEntity + 8 +E-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +16000.0 + 20 +-17000.0 + 10 +24000.0 + 20 +-17000.0 + 10 +24000.0 + 20 +-11600.0 + 10 +16000.0 + 20 +-11600.0 + 0 +HATCH + 5 +1C4 +330 +17 +100 +AcDbEntity + 8 +E-RENDER + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-SAND + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +16000.0 + 20 +-17000.0 + 10 +24000.0 + 20 +-17000.0 + 10 +24000.0 + 20 +-11600.0 + 10 +16000.0 + 20 +-11600.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +15.0 + 77 +0 + 78 +4 + 53 +37.5 + 43 +0.0 + 44 +0.0 + 45 +-24.00046944 + 46 +734.1198559935 + 79 +6 + 49 +0.0 + 49 +-579.12 + 49 +0.0 + 49 +-647.7 + 49 +0.0 + 49 +-619.125 + 53 +7.5 + 43 +0.0 + 44 +0.0 + 45 +674.284924707 + 46 +1075.237650852 + 79 +6 + 49 +0.0 + 49 +-312.42 + 49 +0.0 + 49 +-521.97 + 49 +0.0 + 49 +-200.025 + 53 +-32.5 + 43 +-468.63 + 44 +0.0 + 45 +1186.4885553615 + 46 +2.1557770185 + 79 +6 + 49 +0.0 + 49 +-190.5 + 49 +0.0 + 49 +-685.8 + 49 +0.0 + 49 +-895.35 + 53 +-42.5 + 43 +-468.63 + 44 +0.0 + 45 +1145.334678708 + 46 +334.3939848855 + 79 +6 + 49 +0.0 + 49 +-95.25 + 49 +0.0 + 49 +-449.58 + 49 +0.0 + 49 +-514.35 + 98 +0 + 0 +LWPOLYLINE + 5 +1C5 +330 +17 +100 +AcDbEntity + 8 +E-PLINTH +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +16000.0 + 20 +-17000.0 + 10 +24000.0 + 20 +-17000.0 + 10 +24000.0 + 20 +-16550.0 + 10 +16000.0 + 20 +-16550.0 + 0 +LWPOLYLINE + 5 +1C6 +330 +17 +100 +AcDbEntity + 8 +E-ROOF +370 +50 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +15550.0 + 20 +-11600.0 + 10 +20000.0 + 20 +-8700.0 + 10 +24450.0 + 20 +-11600.0 + 0 +HATCH + 5 +1C7 +330 +17 +100 +AcDbEntity + 8 +E-ROOF + 62 +1 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +AR-RROOF + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +3 + 10 +15550.0 + 20 +-11600.0 + 10 +20000.0 + 20 +-8700.0 + 10 +24450.0 + 20 +-11600.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +3 + 53 +0.0 + 43 +0.0 + 44 +0.0 + 45 +3352.8 + 46 +1524.0 + 79 +4 + 49 +22860.0 + 49 +-3048.0 + 49 +7620.0 + 49 +-1524.0 + 53 +0.0 + 43 +2026.92 + 44 +762.0 + 45 +-1524.0 + 46 +2026.92 + 79 +4 + 49 +4572.0 + 49 +-502.92 + 49 +9144.0 + 49 +-1143.0 + 53 +0.0 + 43 +762.0 + 44 +1295.4 + 45 +7924.8 + 46 +1021.08 + 79 +4 + 49 +12192.0 + 49 +-2133.6 + 49 +6096.0 + 49 +-1524.0 + 98 +0 + 0 +LINE + 5 +1C8 +330 +17 +100 +AcDbEntity + 8 +E-FLOOR + 6 +DASHDOT +100 +AcDbLine + 10 +16000.0 + 20 +-14300.0 + 30 +0.0 + 11 +24000.0 + 21 +-14300.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +1C9 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +17600.0 + 20 +-15900.0 + 10 +19000.0 + 20 +-15900.0 + 10 +19000.0 + 20 +-14600.0 + 10 +17600.0 + 20 +-14600.0 + 0 +LWPOLYLINE + 5 +1CA +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +17660.0 + 20 +-15840.0 + 10 +18940.0 + 20 +-15840.0 + 10 +18940.0 + 20 +-14660.0 + 10 +17660.0 + 20 +-14660.0 + 0 +LINE + 5 +1CB +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +18300.0 + 20 +-15840.0 + 30 +0.0 + 11 +18300.0 + 21 +-14660.0 + 31 +0.0 + 0 +HATCH + 5 +1CC +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +17660.0 + 20 +-15840.0 + 10 +18940.0 + 20 +-15840.0 + 10 +18940.0 + 20 +-14660.0 + 10 +17660.0 + 20 +-14660.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +1CD +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +17520.0 + 20 +-15990.0 + 10 +19080.0 + 20 +-15990.0 + 10 +19080.0 + 20 +-15900.0 + 10 +17520.0 + 20 +-15900.0 + 0 +LWPOLYLINE + 5 +1CE +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +21000.0 + 20 +-15900.0 + 10 +22400.0 + 20 +-15900.0 + 10 +22400.0 + 20 +-14600.0 + 10 +21000.0 + 20 +-14600.0 + 0 +LWPOLYLINE + 5 +1CF +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +21060.0 + 20 +-15840.0 + 10 +22340.0 + 20 +-15840.0 + 10 +22340.0 + 20 +-14660.0 + 10 +21060.0 + 20 +-14660.0 + 0 +LINE + 5 +1D0 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +21700.0 + 20 +-15840.0 + 30 +0.0 + 11 +21700.0 + 21 +-14660.0 + 31 +0.0 + 0 +HATCH + 5 +1D1 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +21060.0 + 20 +-15840.0 + 10 +22340.0 + 20 +-15840.0 + 10 +22340.0 + 20 +-14660.0 + 10 +21060.0 + 20 +-14660.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +1D2 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20920.0 + 20 +-15990.0 + 10 +22480.0 + 20 +-15990.0 + 10 +22480.0 + 20 +-15900.0 + 10 +20920.0 + 20 +-15900.0 + 0 +LWPOLYLINE + 5 +1D3 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +19000.0 + 20 +-13600.0 + 10 +20200.0 + 20 +-13600.0 + 10 +20200.0 + 20 +-12200.0 + 10 +19000.0 + 20 +-12200.0 + 0 +LWPOLYLINE + 5 +1D4 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +19060.0 + 20 +-13540.0 + 10 +20140.0 + 20 +-13540.0 + 10 +20140.0 + 20 +-12260.0 + 10 +19060.0 + 20 +-12260.0 + 0 +LINE + 5 +1D5 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +19600.0 + 20 +-13540.0 + 30 +0.0 + 11 +19600.0 + 21 +-12260.0 + 31 +0.0 + 0 +HATCH + 5 +1D6 +330 +17 +100 +AcDbEntity + 8 +A-WIND + 62 +5 +440 +33554534 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +19060.0 + 20 +-13540.0 + 10 +20140.0 + 20 +-13540.0 + 10 +20140.0 + 20 +-12260.0 + 10 +19060.0 + 20 +-12260.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +1D7 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +18920.0 + 20 +-13690.0 + 10 +20280.0 + 20 +-13690.0 + 10 +20280.0 + 20 +-13600.0 + 10 +18920.0 + 20 +-13600.0 + 0 +INSERT + 5 +1D8 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +25900.0 + 20 +-17000.0 + 30 +0.0 + 0 +ATTRIB + 5 +1DA +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +26220.0 + 20 +-16710.0 + 30 +0.0 + 40 +120.0 + 1 ++0.00 + 11 +26220.0 + 21 +-16710.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +26220.0 + 21 +-16710.0 + 31 +0.0 + 0 +SEQEND + 5 +1D9 +330 +1D8 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +1DB +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +24000.0 + 20 +-17000.0 + 30 +0.0 + 11 +25900.0 + 21 +-17000.0 + 31 +0.0 + 0 +INSERT + 5 +1DC +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +25900.0 + 20 +-14300.0 + 30 +0.0 + 0 +ATTRIB + 5 +1DE +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +26220.0 + 20 +-14010.0 + 30 +0.0 + 40 +120.0 + 1 ++2.70 + 11 +26220.0 + 21 +-14010.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +26220.0 + 21 +-14010.0 + 31 +0.0 + 0 +SEQEND + 5 +1DD +330 +1DC +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +1DF +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +24000.0 + 20 +-14300.0 + 30 +0.0 + 11 +25900.0 + 21 +-14300.0 + 31 +0.0 + 0 +INSERT + 5 +1E0 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +25900.0 + 20 +-11600.0 + 30 +0.0 + 0 +ATTRIB + 5 +1E2 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +26220.0 + 20 +-11310.0 + 30 +0.0 + 40 +120.0 + 1 ++5.40 + 11 +26220.0 + 21 +-11310.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +26220.0 + 21 +-11310.0 + 31 +0.0 + 0 +SEQEND + 5 +1E1 +330 +1E0 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +1E3 +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +24000.0 + 20 +-11600.0 + 30 +0.0 + 11 +25900.0 + 21 +-11600.0 + 31 +0.0 + 0 +INSERT + 5 +1E4 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +25900.0 + 20 +-8700.0 + 30 +0.0 + 0 +ATTRIB + 5 +1E6 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +26220.0 + 20 +-8410.0 + 30 +0.0 + 40 +120.0 + 1 ++8.30 + 11 +26220.0 + 21 +-8410.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +26220.0 + 21 +-8410.0 + 31 +0.0 + 0 +SEQEND + 5 +1E5 +330 +1E4 +100 +AcDbEntity + 8 +A-ANNO + 0 +LINE + 5 +1E7 +330 +17 +100 +AcDbEntity + 8 +A-ANNO + 6 +DASHED +100 +AcDbLine + 10 +24000.0 + 20 +-8700.0 + 30 +0.0 + 11 +25900.0 + 21 +-8700.0 + 31 +0.0 + 0 +DIMENSION + 5 +1E8 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D10 + 3 +ELEV50 + 10 +14100.0 + 20 +-17000.0 + 30 +0.0 + 11 +13972.0 + 21 +-14300.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +16000.0 + 23 +-17000.0 + 33 +0.0 + 14 +16000.0 + 24 +-11600.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +1F7 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D11 + 3 +ELEV50 + 10 +14100.0 + 20 +-11600.0 + 30 +0.0 + 11 +13972.0 + 21 +-10150.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +16000.0 + 23 +-11600.0 + 33 +0.0 + 14 +16000.0 + 24 +-8700.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +206 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D12 + 3 +ELEV50 + 10 +16000.0 + 20 +-18100.0 + 30 +0.0 + 11 +20000.0 + 21 +-17972.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +16000.0 + 23 +-17000.0 + 33 +0.0 + 14 +24000.0 + 24 +-17000.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +TEXT + 5 +215 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +16000.0 + 20 +-19100.0 + 30 +0.0 + 40 +300.0 + 1 +WEST ELEVATION 1:50 + 7 +ARCH-TITLE + 11 +16000.0 + 21 +-19100.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +216 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +370 +70 +100 +AcDbLine + 10 +16000.0 + 20 +-19250.0 + 30 +0.0 + 11 +19600.0 + 21 +-19250.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +217 +330 +17 +100 +AcDbEntity + 8 +E-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +32000.0 + 20 +0.0 + 10 +32300.0 + 20 +0.0 + 10 +32300.0 + 20 +5400.0 + 10 +32000.0 + 20 +5400.0 + 0 +HATCH + 5 +218 +330 +17 +100 +AcDbEntity + 8 +E-WALL + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +32000.0 + 20 +0.0 + 10 +32300.0 + 20 +0.0 + 10 +32300.0 + 20 +5400.0 + 10 +32000.0 + 20 +5400.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-134.703841818 + 46 +134.703841818 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +219 +330 +17 +100 +AcDbEntity + 8 +E-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +41700.0 + 20 +0.0 + 10 +42000.0 + 20 +0.0 + 10 +42000.0 + 20 +5400.0 + 10 +41700.0 + 20 +5400.0 + 0 +HATCH + 5 +21A +330 +17 +100 +AcDbEntity + 8 +E-WALL + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +41700.0 + 20 +0.0 + 10 +42000.0 + 20 +0.0 + 10 +42000.0 + 20 +5400.0 + 10 +41700.0 + 20 +5400.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +60.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-134.703841818 + 46 +134.703841818 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +21B +330 +17 +100 +AcDbEntity + 8 +E-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +32000.0 + 20 +-300.0 + 10 +42000.0 + 20 +-300.0 + 10 +42000.0 + 20 +0.0 + 10 +32000.0 + 20 +0.0 + 0 +LWPOLYLINE + 5 +21C +330 +17 +100 +AcDbEntity + 8 +E-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +32000.0 + 20 +2600.0 + 10 +42000.0 + 20 +2600.0 + 10 +42000.0 + 20 +2900.0 + 10 +32000.0 + 20 +2900.0 + 0 +LWPOLYLINE + 5 +21D +330 +17 +100 +AcDbEntity + 8 +E-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +32000.0 + 20 +5000.0 + 10 +42000.0 + 20 +5000.0 + 10 +42000.0 + 20 +5300.0 + 10 +32000.0 + 20 +5300.0 + 0 +LWPOLYLINE + 5 +21E +330 +17 +100 +AcDbEntity + 8 +E-ROOF +370 +50 +100 +AcDbPolyline + 90 +6 + 70 +1 + 10 +31550.0 + 20 +5400.0 + 10 +37000.0 + 20 +8300.0 + 10 +42450.0 + 20 +5400.0 + 10 +42450.0 + 20 +5250.0 + 10 +37000.0 + 20 +8150.0 + 10 +31550.0 + 20 +5250.0 + 0 +HATCH + 5 +21F +330 +17 +100 +AcDbEntity + 8 +E-ROOF + 62 +1 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +6 + 10 +31550.0 + 20 +5400.0 + 10 +37000.0 + 20 +8300.0 + 10 +42450.0 + 20 +5400.0 + 10 +42450.0 + 20 +5250.0 + 10 +37000.0 + 20 +8150.0 + 10 +31550.0 + 20 +5250.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +220 +330 +17 +100 +AcDbEntity + 8 +E-GROUND +370 +70 +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +30500.0 + 20 +-300.0 + 10 +43500.0 + 20 +-300.0 + 0 +LINE + 5 +221 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +38300.0 + 20 +0.0 + 30 +0.0 + 11 +38600.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +222 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +38600.0 + 20 +0.0 + 30 +0.0 + 11 +38600.0 + 21 +270.0 + 31 +0.0 + 0 +LINE + 5 +223 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +38600.0 + 20 +270.0 + 30 +0.0 + 11 +38900.0 + 21 +270.0 + 31 +0.0 + 0 +LINE + 5 +224 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +38900.0 + 20 +270.0 + 30 +0.0 + 11 +38900.0 + 21 +540.0 + 31 +0.0 + 0 +LINE + 5 +225 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +38900.0 + 20 +540.0 + 30 +0.0 + 11 +39200.0 + 21 +540.0 + 31 +0.0 + 0 +LINE + 5 +226 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +39200.0 + 20 +540.0 + 30 +0.0 + 11 +39200.0 + 21 +810.0 + 31 +0.0 + 0 +LINE + 5 +227 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +39200.0 + 20 +810.0 + 30 +0.0 + 11 +39500.0 + 21 +810.0 + 31 +0.0 + 0 +LINE + 5 +228 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +39500.0 + 20 +810.0 + 30 +0.0 + 11 +39500.0 + 21 +1080.0 + 31 +0.0 + 0 +LINE + 5 +229 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +39500.0 + 20 +1080.0 + 30 +0.0 + 11 +39800.0 + 21 +1080.0 + 31 +0.0 + 0 +LINE + 5 +22A +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +39800.0 + 20 +1080.0 + 30 +0.0 + 11 +39800.0 + 21 +1350.0 + 31 +0.0 + 0 +LINE + 5 +22B +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +39800.0 + 20 +1350.0 + 30 +0.0 + 11 +40100.0 + 21 +1350.0 + 31 +0.0 + 0 +LINE + 5 +22C +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +40100.0 + 20 +1350.0 + 30 +0.0 + 11 +40100.0 + 21 +1620.0 + 31 +0.0 + 0 +LINE + 5 +22D +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +40100.0 + 20 +1620.0 + 30 +0.0 + 11 +40400.0 + 21 +1620.0 + 31 +0.0 + 0 +LINE + 5 +22E +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +40400.0 + 20 +1620.0 + 30 +0.0 + 11 +40400.0 + 21 +1890.0 + 31 +0.0 + 0 +LINE + 5 +22F +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +40400.0 + 20 +1890.0 + 30 +0.0 + 11 +40700.0 + 21 +1890.0 + 31 +0.0 + 0 +LINE + 5 +230 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +40700.0 + 20 +1890.0 + 30 +0.0 + 11 +40700.0 + 21 +2160.0 + 31 +0.0 + 0 +LINE + 5 +231 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +40700.0 + 20 +2160.0 + 30 +0.0 + 11 +41000.0 + 21 +2160.0 + 31 +0.0 + 0 +LINE + 5 +232 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +41000.0 + 20 +2160.0 + 30 +0.0 + 11 +41000.0 + 21 +2430.0 + 31 +0.0 + 0 +LINE + 5 +233 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +41000.0 + 20 +2430.0 + 30 +0.0 + 11 +41300.0 + 21 +2430.0 + 31 +0.0 + 0 +LINE + 5 +234 +330 +17 +100 +AcDbEntity + 8 +E-STEP +100 +AcDbLine + 10 +41300.0 + 20 +2430.0 + 30 +0.0 + 11 +41300.0 + 21 +2700.0 + 31 +0.0 + 0 +TEXT + 5 +235 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +32000.0 + 20 +-2100.0 + 30 +0.0 + 40 +300.0 + 1 +SECTION A-A 1:50 + 7 +ARCH-TITLE + 11 +32000.0 + 21 +-2100.0 + 31 +0.0 +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +239 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +DICTIONARYVAR + 5 +239 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/house-plan-r2000.dxf b/crates/exav-viewer/demo/examples/files/cad/house-plan-r2000.dxf new file mode 100644 index 00000000..281a7303 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/house-plan-r2000.dxf @@ -0,0 +1,16378 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1015 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +218 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +214 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +5835.463185876482 + 22 +2324.253181513731 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +15878.827193569996 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +17 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-WALL + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-WALL-PAT + 70 +0 + 62 +8 + 6 +Continuous +370 +9 +390 +13 + 0 +LAYER + 5 +43 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-DOOR + 70 +0 + 62 +3 + 6 +Continuous +370 +25 +390 +13 + 0 +LAYER + 5 +44 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-WIND + 70 +0 + 62 +5 + 6 +Continuous +370 +25 +390 +13 + 0 +LAYER + 5 +45 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-FURN + 70 +0 + 62 +9 + 6 +Continuous +370 +13 +390 +13 + 0 +LAYER + 5 +46 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-FIXT + 70 +0 + 62 +4 + 6 +Continuous +370 +18 +390 +13 + 0 +LAYER + 5 +47 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-GRID + 70 +0 + 62 +1 + 6 +CENTER +370 +9 +390 +13 + 0 +LAYER + 5 +48 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-ANNO + 70 +0 + 62 +2 + 6 +Continuous +370 +18 +390 +13 + 0 +LAYER + 5 +49 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-DIMS + 70 +0 + 62 +3 + 6 +Continuous +370 +13 +390 +13 + 0 +LAYER + 5 +4A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-AREA + 70 +0 + 62 +6 + 6 +Continuous +370 +9 +390 +13 + 0 +LAYER + 5 +4B +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-FLOR + 70 +0 + 62 +252 + 6 +Continuous +370 +9 +390 +13 + 0 +LAYER + 5 +4C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-SLAB + 70 +0 + 62 +254 + 6 +Continuous +370 +9 +390 +13 + 0 +LAYER + 5 +4D +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-STAIR + 70 +0 + 62 +30 + 6 +Continuous +370 +18 +390 +13 + 0 +LAYER + 5 +4E +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-SHEET + 70 +0 + 62 +7 + 6 +Continuous +370 +35 +390 +13 + 0 +LAYER + 5 +4F +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-VPORT + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +9 +390 +13 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +4 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +50 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +ARCH-TEXT + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +STYLE + 5 +51 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +ARCH-TITLE + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +times.ttf + 4 + + 0 +STYLE + 5 +52 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +ARCH-NOTE + 70 +0 + 40 +0.0 + 41 +0.9 + 50 +15.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +215 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +216 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +2 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 3 + + 4 + + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +53 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +PLAN50 + 70 +0 + 3 + + 4 + + 40 +1.0 + 41 +176.0 + 42 +176.0 + 43 +3.75 + 44 +132.0 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 +140 +220.0 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +66.0 +148 +0.0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +3 +177 +3 +178 +2 +179 +2 +271 +0 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +340 +50 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +25 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 0 +BLOCK_RECORD + 5 +54 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +DOOR +340 +0 + 0 +BLOCK_RECORD + 5 +5B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +WINDOW +340 +0 + 0 +BLOCK_RECORD + 5 +64 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +BED +340 +0 + 0 +BLOCK_RECORD + 5 +6B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +TABLE +340 +0 + 0 +BLOCK_RECORD + 5 +77 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SOFA +340 +0 + 0 +BLOCK_RECORD + 5 +7F +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +WC +340 +0 + 0 +BLOCK_RECORD + 5 +84 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SINK +340 +0 + 0 +BLOCK_RECORD + 5 +8A +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +BATH +340 +0 + 0 +BLOCK_RECORD + 5 +8F +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +AXIS +340 +0 + 0 +BLOCK_RECORD + 5 +94 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +NORTH +340 +0 + 0 +BLOCK_RECORD + 5 +9A +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +LEVEL +340 +0 + 0 +BLOCK_RECORD + 5 +163 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D1 +340 +0 + 0 +BLOCK_RECORD + 5 +168 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDFILLED +340 +0 + 0 +BLOCK_RECORD + 5 +176 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D2 +340 +0 + 0 +BLOCK_RECORD + 5 +185 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D3 +340 +0 + 0 +BLOCK_RECORD + 5 +194 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D4 +340 +0 + 0 +BLOCK_RECORD + 5 +1A3 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D5 +340 +0 + 0 +BLOCK_RECORD + 5 +1B2 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D6 +340 +0 + 0 +BLOCK_RECORD + 5 +1C1 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D7 +340 +0 + 0 +BLOCK_RECORD + 5 +1D1 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D8 +340 +0 + 0 +BLOCK_RECORD + 5 +1E1 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D9 +340 +0 + 0 +BLOCK_RECORD + 5 +1F4 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D10 +340 +0 + 0 +BLOCK_RECORD + 5 +206 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space0 +340 +209 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +55 +330 +54 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +DOOR + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +DOOR + 1 + + 0 +LINE + 5 +57 +330 +54 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +900.0 + 31 +0.0 + 0 +ARC + 5 +58 +330 +54 +100 +AcDbEntity + 8 +A-DOOR + 6 +DASHED +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +900.0 +100 +AcDbArc + 50 +0.0 + 51 +90.0 + 0 +LWPOLYLINE + 5 +59 +330 +54 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +900.0 + 20 +0.0 + 10 +900.0 + 20 +40.0 + 10 +0.0 + 20 +40.0 + 0 +ATTDEF + 5 +5A +330 +54 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +350.0 + 20 +-330.0 + 30 +0.0 + 40 +110.0 + 1 +D-000 +100 +AcDbAttributeDefinition + 3 + + 2 +TAG + 70 +0 + 0 +ENDBLK + 5 +56 +330 +54 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +5C +330 +5B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +WINDOW + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +WINDOW + 1 + + 0 +LINE + 5 +5E +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +1.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +5F +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.35 + 30 +0.0 + 11 +1.0 + 21 +0.35 + 31 +0.0 + 0 +LINE + 5 +60 +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.65 + 30 +0.0 + 11 +1.0 + 21 +0.65 + 31 +0.0 + 0 +LINE + 5 +61 +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +1.0 + 30 +0.0 + 11 +1.0 + 21 +1.0 + 31 +0.0 + 0 +LINE + 5 +62 +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +1.0 + 31 +0.0 + 0 +LINE + 5 +63 +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +1.0 + 20 +0.0 + 30 +0.0 + 11 +1.0 + 21 +1.0 + 31 +0.0 + 0 +ENDBLK + 5 +5D +330 +5B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +65 +330 +64 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +BED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +BED + 1 + + 0 +LWPOLYLINE + 5 +67 +330 +64 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1600.0 + 20 +0.0 + 10 +1600.0 + 20 +2000.0 + 10 +0.0 + 20 +2000.0 + 0 +LWPOLYLINE + 5 +68 +330 +64 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +80.0 + 20 +1300.0 + 10 +760.0 + 20 +1300.0 + 10 +760.0 + 20 +1920.0 + 10 +80.0 + 20 +1920.0 + 0 +LWPOLYLINE + 5 +69 +330 +64 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +1300.0 + 10 +1520.0 + 20 +1300.0 + 10 +1520.0 + 20 +1920.0 + 10 +840.0 + 20 +1920.0 + 0 +LINE + 5 +6A +330 +64 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbLine + 10 +0.0 + 20 +1200.0 + 30 +0.0 + 11 +1600.0 + 21 +1200.0 + 31 +0.0 + 0 +ENDBLK + 5 +66 +330 +64 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +6C +330 +6B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +TABLE + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +TABLE + 1 + + 0 +LWPOLYLINE + 5 +6E +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1800.0 + 20 +0.0 + 10 +1800.0 + 20 +900.0 + 10 +0.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +6F +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +-450.0 + 10 +550.0 + 20 +-450.0 + 10 +550.0 + 20 +-50.0 + 10 +150.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +70 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +950.0 + 10 +550.0 + 20 +950.0 + 10 +550.0 + 20 +1350.0 + 10 +150.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +71 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +600.0 + 20 +-450.0 + 10 +1000.0 + 20 +-450.0 + 10 +1000.0 + 20 +-50.0 + 10 +600.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +72 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +600.0 + 20 +950.0 + 10 +1000.0 + 20 +950.0 + 10 +1000.0 + 20 +1350.0 + 10 +600.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +73 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1050.0 + 20 +-450.0 + 10 +1450.0 + 20 +-450.0 + 10 +1450.0 + 20 +-50.0 + 10 +1050.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +74 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1050.0 + 20 +950.0 + 10 +1450.0 + 20 +950.0 + 10 +1450.0 + 20 +1350.0 + 10 +1050.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +75 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +-450.0 + 10 +1900.0 + 20 +-450.0 + 10 +1900.0 + 20 +-50.0 + 10 +1500.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +76 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +950.0 + 10 +1900.0 + 20 +950.0 + 10 +1900.0 + 20 +1350.0 + 10 +1500.0 + 20 +1350.0 + 0 +ENDBLK + 5 +6D +330 +6B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +78 +330 +77 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SOFA + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SOFA + 1 + + 0 +LWPOLYLINE + 5 +7A +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +2200.0 + 20 +0.0 + 10 +2200.0 + 20 +950.0 + 10 +0.0 + 20 +950.0 + 0 +LWPOLYLINE + 5 +7B +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +2200.0 + 20 +0.0 + 10 +2200.0 + 20 +250.0 + 10 +0.0 + 20 +250.0 + 0 +LWPOLYLINE + 5 +7C +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +120.0 + 20 +300.0 + 10 +720.0 + 20 +300.0 + 10 +720.0 + 20 +900.0 + 10 +120.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +7D +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +770.0 + 20 +300.0 + 10 +1370.0 + 20 +300.0 + 10 +1370.0 + 20 +900.0 + 10 +770.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +7E +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1420.0 + 20 +300.0 + 10 +2020.0 + 20 +300.0 + 10 +2020.0 + 20 +900.0 + 10 +1420.0 + 20 +900.0 + 0 +ENDBLK + 5 +79 +330 +77 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +80 +330 +7F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +WC + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +WC + 1 + + 0 +LWPOLYLINE + 5 +82 +330 +7F +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +400.0 + 20 +0.0 + 10 +400.0 + 20 +200.0 + 10 +0.0 + 20 +200.0 + 0 +ELLIPSE + 5 +83 +330 +7F +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbEllipse + 10 +200.0 + 20 +-250.0 + 30 +0.0 + 11 +0.0 + 21 +230.0 + 31 +0.0 + 40 +0.8 + 41 +0.0 + 42 +6.283185307179586 + 0 +ENDBLK + 5 +81 +330 +7F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +85 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SINK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SINK + 1 + + 0 +LWPOLYLINE + 5 +87 +330 +84 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +600.0 + 20 +0.0 + 10 +600.0 + 20 +450.0 + 10 +0.0 + 20 +450.0 + 0 +CIRCLE + 5 +88 +330 +84 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbCircle + 10 +300.0 + 20 +225.0 + 30 +0.0 + 40 +160.0 + 0 +CIRCLE + 5 +89 +330 +84 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbCircle + 10 +300.0 + 20 +225.0 + 30 +0.0 + 40 +25.0 + 0 +ENDBLK + 5 +86 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +8B +330 +8A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +BATH + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +BATH + 1 + + 0 +LWPOLYLINE + 5 +8D +330 +8A +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1700.0 + 20 +0.0 + 10 +1700.0 + 20 +750.0 + 10 +0.0 + 20 +750.0 + 0 +LWPOLYLINE + 5 +8E +330 +8A +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +5 + 70 +1 + 10 +100.0 + 20 +100.0 + 10 +1500.0 + 20 +100.0 + 10 +1600.0 + 20 +375.0 + 10 +1500.0 + 20 +650.0 + 10 +100.0 + 20 +650.0 + 0 +ENDBLK + 5 +8C +330 +8A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +90 +330 +8F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +AXIS + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +AXIS + 1 + + 0 +CIRCLE + 5 +92 +330 +8F +100 +AcDbEntity + 8 +A-GRID +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 0 +ATTDEF + 5 +93 +330 +8F +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 1 +A + 72 +4 + 11 +0.0 + 21 +0.0 + 31 +0.0 +100 +AcDbAttributeDefinition + 3 + + 2 +ID + 70 +0 + 0 +ENDBLK + 5 +91 +330 +8F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +95 +330 +94 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +NORTH + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +NORTH + 1 + + 0 +CIRCLE + 5 +97 +330 +94 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +500.0 + 0 +SOLID + 5 +98 +330 +94 +100 +AcDbEntity + 8 +A-ANNO + 62 +7 +100 +AcDbTrace + 10 +0.0 + 20 +650.0 + 30 +0.0 + 11 +-170.0 + 21 +-400.0 + 31 +0.0 + 12 +0.0 + 22 +-200.0 + 32 +0.0 + 13 +170.0 + 23 +-400.0 + 33 +0.0 + 0 +TEXT + 5 +99 +330 +94 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +800.0 + 30 +0.0 + 40 +300.0 + 1 +N + 72 +1 + 11 +0.0 + 21 +800.0 + 31 +0.0 +100 +AcDbText + 73 +1 + 0 +ENDBLK + 5 +96 +330 +94 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +9B +330 +9A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +LEVEL + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +LEVEL + 1 + + 0 +LWPOLYLINE + 5 +9D +330 +9A +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +-250.0 + 20 +250.0 + 10 +0.0 + 20 +0.0 + 10 +250.0 + 20 +250.0 + 0 +LINE + 5 +9E +330 +9A +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbLine + 10 +-250.0 + 20 +250.0 + 30 +0.0 + 11 +900.0 + 21 +250.0 + 31 +0.0 + 0 +ATTDEF + 5 +9F +330 +9A +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +320.0 + 20 +290.0 + 30 +0.0 + 40 +120.0 + 1 ++0.00 +100 +AcDbAttributeDefinition + 3 + + 2 +H + 70 +0 + 0 +ENDBLK + 5 +9C +330 +9A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +164 +330 +163 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D1 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D1 + 1 + + 0 +LINE + 5 +166 +330 +163 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-176.0 + 30 +0.0 + 11 +0.0 + 21 +-3232.0 + 31 +0.0 + 0 +LINE + 5 +167 +330 +163 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +10000.0 + 20 +-176.0 + 30 +0.0 + 11 +10000.0 + 21 +-3232.0 + 31 +0.0 + 0 +INSERT + 5 +16C +330 +163 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-3100.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +180.0 + 0 +INSERT + 5 +16E +330 +163 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +10000.0 + 20 +-3100.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 0 +LINE + 5 +170 +330 +163 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +176.0 + 20 +-3100.0 + 30 +0.0 + 11 +9824.0 + 21 +-3100.0 + 31 +0.0 + 0 +MTEXT + 5 +171 +330 +163 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +5000.0 + 20 +-2924.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +10000 + 7 +ARCH-TEXT + 0 +POINT + 5 +172 +330 +163 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-3100.0 + 30 +0.0 + 0 +POINT + 5 +173 +330 +163 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +174 +330 +163 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +10000.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +165 +330 +163 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +169 +330 +168 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDFILLED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDFILLED + 1 + + 0 +SOLID + 5 +16B +330 +168 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbTrace + 10 +-1.0 + 20 +0.16439898372402537 + 30 +0.0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 12 +-1.0 + 22 +-0.16439898372402537 + 32 +0.0 + 13 +-1.0 + 23 +-0.16439898372402537 + 33 +0.0 + 0 +ENDBLK + 5 +16A +330 +168 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +177 +330 +176 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D2 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D2 + 1 + + 0 +LINE + 5 +179 +330 +176 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-176.0 + 30 +0.0 + 11 +0.0 + 21 +-2732.0 + 31 +0.0 + 0 +LINE + 5 +17A +330 +176 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +3500.0 + 20 +-176.0 + 30 +0.0 + 11 +3500.0 + 21 +-2732.0 + 31 +0.0 + 0 +INSERT + 5 +17B +330 +176 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +180.0 + 0 +INSERT + 5 +17D +330 +176 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +3500.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 0 +LINE + 5 +17F +330 +176 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +176.0 + 20 +-2600.0 + 30 +0.0 + 11 +3324.0 + 21 +-2600.0 + 31 +0.0 + 0 +MTEXT + 5 +180 +330 +176 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +1750.0 + 20 +-2424.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +3500 + 7 +ARCH-TEXT + 0 +POINT + 5 +181 +330 +176 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-2600.0 + 30 +0.0 + 0 +POINT + 5 +182 +330 +176 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +183 +330 +176 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +3500.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +178 +330 +176 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +186 +330 +185 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D3 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D3 + 1 + + 0 +LINE + 5 +188 +330 +185 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +3500.0 + 20 +-176.0 + 30 +0.0 + 11 +3500.0 + 21 +-2732.0 + 31 +0.0 + 0 +LINE + 5 +189 +330 +185 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +6200.0 + 20 +-176.0 + 30 +0.0 + 11 +6200.0 + 21 +-2732.0 + 31 +0.0 + 0 +INSERT + 5 +18A +330 +185 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +3500.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +180.0 + 0 +INSERT + 5 +18C +330 +185 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +6200.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 0 +LINE + 5 +18E +330 +185 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +3676.0 + 20 +-2600.0 + 30 +0.0 + 11 +6024.0 + 21 +-2600.0 + 31 +0.0 + 0 +MTEXT + 5 +18F +330 +185 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +4850.0 + 20 +-2424.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +2700 + 7 +ARCH-TEXT + 0 +POINT + 5 +190 +330 +185 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +3500.0 + 20 +-2600.0 + 30 +0.0 + 0 +POINT + 5 +191 +330 +185 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +3500.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +192 +330 +185 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6200.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +187 +330 +185 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +195 +330 +194 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D4 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D4 + 1 + + 0 +LINE + 5 +197 +330 +194 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +6200.0 + 20 +-176.0 + 30 +0.0 + 11 +6200.0 + 21 +-2732.0 + 31 +0.0 + 0 +LINE + 5 +198 +330 +194 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +10000.0 + 20 +-176.0 + 30 +0.0 + 11 +10000.0 + 21 +-2732.0 + 31 +0.0 + 0 +INSERT + 5 +199 +330 +194 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +6200.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +180.0 + 0 +INSERT + 5 +19B +330 +194 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +10000.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 0 +LINE + 5 +19D +330 +194 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +6376.0 + 20 +-2600.0 + 30 +0.0 + 11 +9824.0 + 21 +-2600.0 + 31 +0.0 + 0 +MTEXT + 5 +19E +330 +194 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +8100.0 + 20 +-2424.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +3800 + 7 +ARCH-TEXT + 0 +POINT + 5 +19F +330 +194 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6200.0 + 20 +-2600.0 + 30 +0.0 + 0 +POINT + 5 +1A0 +330 +194 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6200.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1A1 +330 +194 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +10000.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +196 +330 +194 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1A4 +330 +1A3 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D5 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D5 + 1 + + 0 +LINE + 5 +1A6 +330 +1A3 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +0.0 + 30 +0.0 + 11 +-3232.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +1A7 +330 +1A3 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +8000.0 + 30 +0.0 + 11 +-3232.0 + 21 +8000.0 + 31 +0.0 + 0 +INSERT + 5 +1A8 +330 +1A3 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-3100.0 + 20 +0.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +270.0 + 0 +INSERT + 5 +1AA +330 +1A3 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-3100.0 + 20 +8000.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +90.0 + 0 +LINE + 5 +1AC +330 +1A3 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-3100.0 + 20 +176.0 + 30 +0.0 + 11 +-3100.0 + 21 +7824.0 + 31 +0.0 + 0 +MTEXT + 5 +1AD +330 +1A3 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-3276.0 + 20 +4000.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +8000 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +1AE +330 +1A3 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-3100.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1AF +330 +1A3 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1B0 +330 +1A3 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +8000.0 + 30 +0.0 + 0 +ENDBLK + 5 +1A5 +330 +1A3 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1B3 +330 +1B2 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D6 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D6 + 1 + + 0 +LINE + 5 +1B5 +330 +1B2 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +0.0 + 30 +0.0 + 11 +-2732.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +1B6 +330 +1B2 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +4200.0 + 30 +0.0 + 11 +-2732.0 + 21 +4200.0 + 31 +0.0 + 0 +INSERT + 5 +1B7 +330 +1B2 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-2600.0 + 20 +0.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +270.0 + 0 +INSERT + 5 +1B9 +330 +1B2 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-2600.0 + 20 +4200.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +90.0 + 0 +LINE + 5 +1BB +330 +1B2 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-2600.0 + 20 +176.0 + 30 +0.0 + 11 +-2600.0 + 21 +4024.0 + 31 +0.0 + 0 +MTEXT + 5 +1BC +330 +1B2 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-2776.0 + 20 +2100.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +4200 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +1BD +330 +1B2 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-2600.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1BE +330 +1B2 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1BF +330 +1B2 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +4200.0 + 30 +0.0 + 0 +ENDBLK + 5 +1B4 +330 +1B2 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C2 +330 +1C1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D7 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D7 + 1 + + 0 +LINE + 5 +1C4 +330 +1C1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +4200.0 + 30 +0.0 + 11 +-2732.0 + 21 +4200.0 + 31 +0.0 + 0 +LINE + 5 +1C5 +330 +1C1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +8000.0 + 30 +0.0 + 11 +-2732.0 + 21 +8000.0 + 31 +0.0 + 0 +INSERT + 5 +1C6 +330 +1C1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-2600.0 + 20 +4200.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +270.0 + 0 +INSERT + 5 +1C8 +330 +1C1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-2600.0 + 20 +8000.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +90.0 + 0 +LINE + 5 +1CA +330 +1C1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-2600.0 + 20 +4376.0 + 30 +0.0 + 11 +-2600.0 + 21 +7824.0 + 31 +0.0 + 0 +MTEXT + 5 +1CB +330 +1C1 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-2776.0 + 20 +6100.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +3800 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +1CC +330 +1C1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-2600.0 + 20 +4200.0 + 30 +0.0 + 0 +POINT + 5 +1CD +330 +1C1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +4200.0 + 30 +0.0 + 0 +POINT + 5 +1CE +330 +1C1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +8000.0 + 30 +0.0 + 0 +ENDBLK + 5 +1C3 +330 +1C1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1D2 +330 +1D1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D8 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D8 + 1 + + 0 +LINE + 5 +1D4 +330 +1D1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +11624.450793488832 + 20 +375.54920651116765 + 30 +0.0 + 11 +12088.312841947207 + 21 +-88.31284194720743 + 31 +0.0 + 0 +LINE + 5 +1D5 +330 +1D1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +14624.450793488832 + 20 +3375.5492065111675 + 30 +0.0 + 11 +15088.312841947207 + 21 +2911.6871580527927 + 31 +0.0 + 0 +INSERT + 5 +1D6 +330 +1D1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +11994.974746830583 + 20 +5.025253169416828 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +225.0 + 0 +INSERT + 5 +1D8 +330 +1D1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14994.974746830583 + 20 +3005.025253169417 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +45.0 + 0 +LINE + 5 +1DA +330 +1D1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +12119.425540319415 + 20 +129.47604665824917 + 30 +0.0 + 11 +14870.523953341752 + 21 +2880.5744596805844 + 31 +0.0 + 0 +MTEXT + 5 +1DB +330 +1D1 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +13370.523953341752 + 20 +1629.4760466582493 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +4243 + 7 +ARCH-TEXT + 50 +45.0 + 0 +POINT + 5 +1DC +330 +1D1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +11994.974746830583 + 20 +5.025253169416828 + 30 +0.0 + 0 +POINT + 5 +1DD +330 +1D1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 0 +POINT + 5 +1DE +330 +1D1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14500.0 + 20 +3500.0 + 30 +0.0 + 0 +ENDBLK + 5 +1D3 +330 +1D1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1E2 +330 +1E1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D9 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D9 + 1 + + 0 +LINE + 5 +1E4 +330 +1E1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +11676.0 + 20 +500.0 + 30 +0.0 + 11 +12456.621125123533 + 21 +500.0 + 31 +0.0 + 0 +LINE + 5 +1E5 +330 +1E1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +11624.450793488832 + 20 +624.4507934888323 + 30 +0.0 + 11 +12176.433284601155 + 21 +1176.4332846011544 + 31 +0.0 + 0 +INSERT + 5 +1E6 +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +12324.621125123533 + 20 +500.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +443.88564221369427 + 0 +INSERT + 5 +1E8 +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +12083.09518948453 + 20 +1083.0951894845302 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +321.11435778630573 + 0 +MTEXT + 5 +1EA +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +12546.405475591537 + 20 +933.4353397314832 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +45° + 7 +ARCH-TEXT + 50 +-67.5 + 0 +ARC + 5 +1EB +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbCircle + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 40 +824.6211251235321 +100 +AcDbArc + 50 +0.0 + 51 +45.0 + 0 +ARC + 5 +1EC +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbCircle + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 40 +824.6211251235321 +100 +AcDbArc + 50 +-24.45743114522284 + 51 +-12.22871557261142 + 0 +ARC + 5 +1ED +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbCircle + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 40 +824.6211251235321 +100 +AcDbArc + 50 +57.22871557261142 + 51 +69.45743114522284 + 0 +POINT + 5 +1EE +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14500.0 + 20 +500.0 + 30 +0.0 + 0 +POINT + 5 +1EF +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 0 +POINT + 5 +1F0 +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14500.0 + 20 +3500.0 + 30 +0.0 + 0 +POINT + 5 +1F1 +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 0 +POINT + 5 +1F2 +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +12300.0 + 20 +700.0 + 30 +0.0 + 0 +ENDBLK + 5 +1E3 +330 +1E1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1F5 +330 +1F4 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D10 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D10 + 1 + + 0 +INSERT + 5 +1F7 +330 +1F4 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +6911.396103067893 + 20 +2336.396103067893 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +225.0 + 0 +LINE + 5 +1F9 +330 +1F4 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +6275.0 + 20 +1700.0 + 30 +0.0 + 11 +6911.396103067893 + 21 +2336.396103067893 + 31 +0.0 + 0 +LINE + 5 +1FA +330 +1F4 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +7035.846896556725 + 20 +2460.8468965567254 + 30 +0.0 + 11 +7704.7699115591995 + 21 +3129.7699115591995 + 31 +0.0 + 0 +MTEXT + 5 +1FB +330 +1F4 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +7269.192134348285 + 20 +2943.093721325951 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +R900 + 7 +ARCH-TEXT + 50 +45.0 + 0 +POINT + 5 +1FC +330 +1F4 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6275.0 + 20 +1700.0 + 30 +0.0 + 0 +POINT + 5 +1FD +330 +1F4 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6911.396103067893 + 20 +2336.396103067893 + 30 +0.0 + 0 +ENDBLK + 5 +1F6 +330 +1F4 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +207 +330 +206 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space0 + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space0 + 1 + + 0 +VIEWPORT + 5 +20A +330 +206 +100 +AcDbEntity + 67 +1 + 8 +VIEWPORTS +100 +AcDbViewport + 10 +200.0 + 20 +138.5 + 30 +0.0 + 40 +462.00000000000006 + 41 +326.70000000000005 + 68 +1 + 69 +1 + 12 +200.0 + 22 +138.5 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +326.70000000000005 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +557088 + 1 + +281 +0 + 71 +0 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +LWPOLYLINE + 5 +20B +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +10.0 + 20 +10.0 + 10 +410.0 + 20 +10.0 + 10 +410.0 + 20 +287.0 + 10 +10.0 + 20 +287.0 + 0 +LWPOLYLINE + 5 +20C +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +5.0 + 20 +5.0 + 10 +415.0 + 20 +5.0 + 10 +415.0 + 20 +292.0 + 10 +5.0 + 20 +292.0 + 0 +LWPOLYLINE + 5 +20D +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +260.0 + 20 +10.0 + 10 +410.0 + 20 +10.0 + 10 +410.0 + 20 +60.0 + 10 +260.0 + 20 +60.0 + 0 +LINE + 5 +20E +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbLine + 10 +260.0 + 20 +35.0 + 30 +0.0 + 11 +410.0 + 21 +35.0 + 31 +0.0 + 0 +LINE + 5 +20F +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbLine + 10 +335.0 + 20 +10.0 + 30 +0.0 + 11 +335.0 + 21 +35.0 + 31 +0.0 + 0 +TEXT + 5 +210 +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbText + 10 +265.0 + 20 +45.0 + 30 +0.0 + 40 +7.0 + 1 +HOUSE, GROUND FLOOR + 7 +ARCH-TITLE + 11 +265.0 + 21 +45.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +211 +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbText + 10 +265.0 + 20 +18.0 + 30 +0.0 + 40 +4.0 + 1 +Scale 1:50 + 7 +ARCH-TEXT + 11 +265.0 + 21 +18.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +212 +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbText + 10 +340.0 + 20 +18.0 + 30 +0.0 + 40 +4.0 + 1 +Sheet A-101 + 7 +ARCH-TEXT + 11 +340.0 + 21 +18.0 + 31 +0.0 +100 +AcDbText + 0 +VIEWPORT + 5 +213 +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-VPORT +100 +AcDbViewport + 10 +135.0 + 20 +160.0 + 30 +0.0 + 40 +250.0 + 41 +200.0 + 68 +1 + 69 +3 + 12 +5000.0 + 22 +3400.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +10000.0 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +0 + 1 + +281 +0 + 71 +0 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDBLK + 5 +208 +330 +206 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +HATCH + 5 +A0 +330 +17 +100 +AcDbEntity + 8 +A-SLAB + 62 +254 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +0.0 + 10 +10000.0 + 20 +0.0 + 10 +10000.0 + 20 +8000.0 + 10 +0.0 + 20 +8000.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +A1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +150.0 + 10 +1400.0 + 20 +150.0 + 10 +1400.0 + 20 +-150.0 + 10 +0.0 + 20 +-150.0 + 0 +HATCH + 5 +A2 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +150.0 + 10 +1400.0 + 20 +150.0 + 10 +1400.0 + 20 +-150.0 + 10 +0.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +A3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +2900.0 + 20 +150.0 + 10 +3600.0 + 20 +150.0 + 10 +3600.0 + 20 +-150.0 + 10 +2900.0 + 20 +-150.0 + 0 +HATCH + 5 +A4 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +2900.0 + 20 +150.0 + 10 +3600.0 + 20 +150.0 + 10 +3600.0 + 20 +-150.0 + 10 +2900.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +A5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +4500.0 + 20 +150.0 + 10 +6200.0 + 20 +150.0 + 10 +6200.0 + 20 +-150.0 + 10 +4500.0 + 20 +-150.0 + 0 +HATCH + 5 +A6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +4500.0 + 20 +150.0 + 10 +6200.0 + 20 +150.0 + 10 +6200.0 + 20 +-150.0 + 10 +4500.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +A7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +7100.0 + 20 +150.0 + 10 +8200.0 + 20 +150.0 + 10 +8200.0 + 20 +-150.0 + 10 +7100.0 + 20 +-150.0 + 0 +HATCH + 5 +A8 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +7100.0 + 20 +150.0 + 10 +8200.0 + 20 +150.0 + 10 +8200.0 + 20 +-150.0 + 10 +7100.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +A9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +9400.0 + 20 +150.0 + 10 +10000.0 + 20 +150.0 + 10 +10000.0 + 20 +-150.0 + 10 +9400.0 + 20 +-150.0 + 0 +HATCH + 5 +AA +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +9400.0 + 20 +150.0 + 10 +10000.0 + 20 +150.0 + 10 +10000.0 + 20 +-150.0 + 10 +9400.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +AB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +1400.0 + 20 +150.0 + 30 +0.0 + 11 +1400.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +AC +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +2900.0 + 20 +150.0 + 30 +0.0 + 11 +2900.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +AD +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3600.0 + 20 +150.0 + 30 +0.0 + 11 +3600.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +AE +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +4500.0 + 20 +150.0 + 30 +0.0 + 11 +4500.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +AF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6200.0 + 20 +150.0 + 30 +0.0 + 11 +6200.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +B0 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +7100.0 + 20 +150.0 + 30 +0.0 + 11 +7100.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +B1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +8200.0 + 20 +150.0 + 30 +0.0 + 11 +8200.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +B2 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9400.0 + 20 +150.0 + 30 +0.0 + 11 +9400.0 + 21 +-150.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +B3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +9850.0 + 20 +0.0 + 10 +9850.0 + 20 +1500.0 + 10 +10150.0 + 20 +1500.0 + 10 +10150.0 + 20 +0.0 + 0 +HATCH + 5 +B4 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +9850.0 + 20 +0.0 + 10 +9850.0 + 20 +1500.0 + 10 +10150.0 + 20 +1500.0 + 10 +10150.0 + 20 +0.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +B5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +9850.0 + 20 +2800.0 + 10 +9850.0 + 20 +4800.0 + 10 +10150.0 + 20 +4800.0 + 10 +10150.0 + 20 +2800.0 + 0 +HATCH + 5 +B6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +9850.0 + 20 +2800.0 + 10 +9850.0 + 20 +4800.0 + 10 +10150.0 + 20 +4800.0 + 10 +10150.0 + 20 +2800.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +B7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +9850.0 + 20 +6100.0 + 10 +9850.0 + 20 +8000.0 + 10 +10150.0 + 20 +8000.0 + 10 +10150.0 + 20 +6100.0 + 0 +HATCH + 5 +B8 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +9850.0 + 20 +6100.0 + 10 +9850.0 + 20 +8000.0 + 10 +10150.0 + 20 +8000.0 + 10 +10150.0 + 20 +6100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +B9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9850.0 + 20 +1500.0 + 30 +0.0 + 11 +10150.0 + 21 +1500.0 + 31 +0.0 + 0 +LINE + 5 +BA +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9850.0 + 20 +2800.0 + 30 +0.0 + 11 +10150.0 + 21 +2800.0 + 31 +0.0 + 0 +LINE + 5 +BB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9850.0 + 20 +4800.0 + 30 +0.0 + 11 +10150.0 + 21 +4800.0 + 31 +0.0 + 0 +LINE + 5 +BC +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9850.0 + 20 +6100.0 + 30 +0.0 + 11 +10150.0 + 21 +6100.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +BD +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +10000.0 + 20 +7850.0 + 10 +8800.0 + 20 +7850.0 + 10 +8800.0 + 20 +8150.0 + 10 +10000.0 + 20 +8150.0 + 0 +HATCH + 5 +BE +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +10000.0 + 20 +7850.0 + 10 +8800.0 + 20 +7850.0 + 10 +8800.0 + 20 +8150.0 + 10 +10000.0 + 20 +8150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +BF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +7400.0 + 20 +7850.0 + 10 +6400.0 + 20 +7850.0 + 10 +6400.0 + 20 +8150.0 + 10 +7400.0 + 20 +8150.0 + 0 +HATCH + 5 +C0 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +7400.0 + 20 +7850.0 + 10 +6400.0 + 20 +7850.0 + 10 +6400.0 + 20 +8150.0 + 10 +7400.0 + 20 +8150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +C1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +4800.0 + 20 +7850.0 + 10 +3400.0 + 20 +7850.0 + 10 +3400.0 + 20 +8150.0 + 10 +4800.0 + 20 +8150.0 + 0 +HATCH + 5 +C2 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +4800.0 + 20 +7850.0 + 10 +3400.0 + 20 +7850.0 + 10 +3400.0 + 20 +8150.0 + 10 +4800.0 + 20 +8150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +C3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +2200.0 + 20 +7850.0 + 10 +0.0 + 20 +7850.0 + 10 +0.0 + 20 +8150.0 + 10 +2200.0 + 20 +8150.0 + 0 +HATCH + 5 +C4 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +2200.0 + 20 +7850.0 + 10 +0.0 + 20 +7850.0 + 10 +0.0 + 20 +8150.0 + 10 +2200.0 + 20 +8150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +C5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +8800.0 + 20 +7850.0 + 30 +0.0 + 11 +8800.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +C6 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +7400.0 + 20 +7850.0 + 30 +0.0 + 11 +7400.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +C7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6400.0 + 20 +7850.0 + 30 +0.0 + 11 +6400.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +C8 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +4800.0 + 20 +7850.0 + 30 +0.0 + 11 +4800.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +C9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3400.0 + 20 +7850.0 + 30 +0.0 + 11 +3400.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +CA +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +2200.0 + 20 +7850.0 + 30 +0.0 + 11 +2200.0 + 21 +8150.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +CB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +8000.0 + 10 +150.0 + 20 +6400.0 + 10 +-150.0 + 20 +6400.0 + 10 +-150.0 + 20 +8000.0 + 0 +HATCH + 5 +CC +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +150.0 + 20 +8000.0 + 10 +150.0 + 20 +6400.0 + 10 +-150.0 + 20 +6400.0 + 10 +-150.0 + 20 +8000.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +CD +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +5000.0 + 10 +150.0 + 20 +3000.0 + 10 +-150.0 + 20 +3000.0 + 10 +-150.0 + 20 +5000.0 + 0 +HATCH + 5 +CE +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +150.0 + 20 +5000.0 + 10 +150.0 + 20 +3000.0 + 10 +-150.0 + 20 +3000.0 + 10 +-150.0 + 20 +5000.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +CF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +1600.0 + 10 +150.0 + 20 +0.0 + 10 +-150.0 + 20 +0.0 + 10 +-150.0 + 20 +1600.0 + 0 +HATCH + 5 +D0 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +150.0 + 20 +1600.0 + 10 +150.0 + 20 +0.0 + 10 +-150.0 + 20 +0.0 + 10 +-150.0 + 20 +1600.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +D1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +150.0 + 20 +6400.0 + 30 +0.0 + 11 +-150.0 + 21 +6400.0 + 31 +0.0 + 0 +LINE + 5 +D2 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +150.0 + 20 +5000.0 + 30 +0.0 + 11 +-150.0 + 21 +5000.0 + 31 +0.0 + 0 +LINE + 5 +D3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +150.0 + 20 +3000.0 + 30 +0.0 + 11 +-150.0 + 21 +3000.0 + 31 +0.0 + 0 +LINE + 5 +D4 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +150.0 + 20 +1600.0 + 30 +0.0 + 11 +-150.0 + 21 +1600.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +D5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +4275.0 + 10 +600.0 + 20 +4275.0 + 10 +600.0 + 20 +4125.0 + 10 +0.0 + 20 +4125.0 + 0 +HATCH + 5 +D6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +4275.0 + 10 +600.0 + 20 +4275.0 + 10 +600.0 + 20 +4125.0 + 10 +0.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +D7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +4275.0 + 10 +2700.0 + 20 +4275.0 + 10 +2700.0 + 20 +4125.0 + 10 +1500.0 + 20 +4125.0 + 0 +HATCH + 5 +D8 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +1500.0 + 20 +4275.0 + 10 +2700.0 + 20 +4275.0 + 10 +2700.0 + 20 +4125.0 + 10 +1500.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +D9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3600.0 + 20 +4275.0 + 10 +4800.0 + 20 +4275.0 + 10 +4800.0 + 20 +4125.0 + 10 +3600.0 + 20 +4125.0 + 0 +HATCH + 5 +DA +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3600.0 + 20 +4275.0 + 10 +4800.0 + 20 +4275.0 + 10 +4800.0 + 20 +4125.0 + 10 +3600.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +DB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +5700.0 + 20 +4275.0 + 10 +6200.0 + 20 +4275.0 + 10 +6200.0 + 20 +4125.0 + 10 +5700.0 + 20 +4125.0 + 0 +HATCH + 5 +DC +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +5700.0 + 20 +4275.0 + 10 +6200.0 + 20 +4275.0 + 10 +6200.0 + 20 +4125.0 + 10 +5700.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +DD +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +600.0 + 20 +4275.0 + 30 +0.0 + 11 +600.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +DE +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +1500.0 + 20 +4275.0 + 30 +0.0 + 11 +1500.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +DF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +2700.0 + 20 +4275.0 + 30 +0.0 + 11 +2700.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +E0 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3600.0 + 20 +4275.0 + 30 +0.0 + 11 +3600.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +E1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +4800.0 + 20 +4275.0 + 30 +0.0 + 11 +4800.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +E2 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +5700.0 + 20 +4275.0 + 30 +0.0 + 11 +5700.0 + 21 +4125.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +E3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6125.0 + 20 +0.0 + 10 +6125.0 + 20 +1700.0 + 10 +6275.0 + 20 +1700.0 + 10 +6275.0 + 20 +0.0 + 0 +HATCH + 5 +E4 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6125.0 + 20 +0.0 + 10 +6125.0 + 20 +1700.0 + 10 +6275.0 + 20 +1700.0 + 10 +6275.0 + 20 +0.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +E5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6125.0 + 20 +2600.0 + 10 +6125.0 + 20 +4200.0 + 10 +6275.0 + 20 +4200.0 + 10 +6275.0 + 20 +2600.0 + 0 +HATCH + 5 +E6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6125.0 + 20 +2600.0 + 10 +6125.0 + 20 +4200.0 + 10 +6275.0 + 20 +4200.0 + 10 +6275.0 + 20 +2600.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +E7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6125.0 + 20 +1700.0 + 30 +0.0 + 11 +6275.0 + 21 +1700.0 + 31 +0.0 + 0 +LINE + 5 +E8 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6125.0 + 20 +2600.0 + 30 +0.0 + 11 +6275.0 + 21 +2600.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +E9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6125.0 + 20 +4200.0 + 10 +6125.0 + 20 +6300.0 + 10 +6275.0 + 20 +6300.0 + 10 +6275.0 + 20 +4200.0 + 0 +HATCH + 5 +EA +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6125.0 + 20 +4200.0 + 10 +6125.0 + 20 +6300.0 + 10 +6275.0 + 20 +6300.0 + 10 +6275.0 + 20 +4200.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +EB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6125.0 + 20 +7200.0 + 10 +6125.0 + 20 +8000.0 + 10 +6275.0 + 20 +8000.0 + 10 +6275.0 + 20 +7200.0 + 0 +HATCH + 5 +EC +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6125.0 + 20 +7200.0 + 10 +6125.0 + 20 +8000.0 + 10 +6275.0 + 20 +8000.0 + 10 +6275.0 + 20 +7200.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +ED +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6125.0 + 20 +6300.0 + 30 +0.0 + 11 +6275.0 + 21 +6300.0 + 31 +0.0 + 0 +LINE + 5 +EE +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6125.0 + 20 +7200.0 + 30 +0.0 + 11 +6275.0 + 21 +7200.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +EF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3425.0 + 20 +0.0 + 10 +3425.0 + 20 +2400.0 + 10 +3575.0 + 20 +2400.0 + 10 +3575.0 + 20 +0.0 + 0 +HATCH + 5 +F0 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3425.0 + 20 +0.0 + 10 +3425.0 + 20 +2400.0 + 10 +3575.0 + 20 +2400.0 + 10 +3575.0 + 20 +0.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +F1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3425.0 + 20 +3300.0 + 10 +3425.0 + 20 +4200.0 + 10 +3575.0 + 20 +4200.0 + 10 +3575.0 + 20 +3300.0 + 0 +HATCH + 5 +F2 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3425.0 + 20 +3300.0 + 10 +3425.0 + 20 +4200.0 + 10 +3575.0 + 20 +4200.0 + 10 +3575.0 + 20 +3300.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +F3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3425.0 + 20 +2400.0 + 30 +0.0 + 11 +3575.0 + 21 +2400.0 + 31 +0.0 + 0 +LINE + 5 +F4 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3425.0 + 20 +3300.0 + 30 +0.0 + 11 +3575.0 + 21 +3300.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +F5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6200.0 + 20 +4275.0 + 10 +10000.0 + 20 +4275.0 + 10 +10000.0 + 20 +4125.0 + 10 +6200.0 + 20 +4125.0 + 0 +HATCH + 5 +F6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6200.0 + 20 +4275.0 + 10 +10000.0 + 20 +4275.0 + 10 +10000.0 + 20 +4125.0 + 10 +6200.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +F7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +8125.0 + 20 +4200.0 + 10 +8125.0 + 20 +5800.0 + 10 +8275.0 + 20 +5800.0 + 10 +8275.0 + 20 +4200.0 + 0 +HATCH + 5 +F8 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +8125.0 + 20 +4200.0 + 10 +8125.0 + 20 +5800.0 + 10 +8275.0 + 20 +5800.0 + 10 +8275.0 + 20 +4200.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +F9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +8125.0 + 20 +6700.0 + 10 +8125.0 + 20 +8000.0 + 10 +8275.0 + 20 +8000.0 + 10 +8275.0 + 20 +6700.0 + 0 +HATCH + 5 +FA +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +8125.0 + 20 +6700.0 + 10 +8125.0 + 20 +8000.0 + 10 +8275.0 + 20 +8000.0 + 10 +8275.0 + 20 +6700.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +FB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +8125.0 + 20 +5800.0 + 30 +0.0 + 11 +8275.0 + 21 +5800.0 + 31 +0.0 + 0 +LINE + 5 +FC +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +8125.0 + 20 +6700.0 + 30 +0.0 + 11 +8275.0 + 21 +6700.0 + 31 +0.0 + 0 +INSERT + 5 +FD +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +1700.0 + 20 +150.0 + 30 +0.0 + 0 +ATTRIB + 5 +FF +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +2050.0 + 20 +-180.0 + 30 +0.0 + 40 +110.0 + 1 +D-101 + 11 +2050.0 + 21 +-180.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 0 +SEQEND + 5 +FE +330 +FD +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +100 +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +6275.0 + 20 +1700.0 + 30 +0.0 + 50 +90.0 + 0 +ATTRIB + 5 +102 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +6605.0 + 20 +2050.0 + 30 +0.0 + 40 +110.0 + 1 +D-102 + 50 +90.0 + 11 +6605.0 + 21 +2050.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 0 +SEQEND + 5 +101 +330 +100 +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +103 +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +3575.0 + 20 +2400.0 + 30 +0.0 + 50 +90.0 + 0 +ATTRIB + 5 +105 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +3905.0 + 20 +2750.0 + 30 +0.0 + 40 +110.0 + 1 +D-103 + 50 +90.0 + 11 +3905.0 + 21 +2750.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 0 +SEQEND + 5 +104 +330 +103 +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +106 +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +700.0 + 20 +4275.0 + 30 +0.0 + 42 +-1.0 + 0 +ATTRIB + 5 +108 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +-1050.0 + 20 +4605.0 + 30 +0.0 + 40 +110.0 + 1 +D-104 + 50 +180.0 + 11 +-1050.0 + 21 +4605.0 + 31 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 0 +SEQEND + 5 +107 +330 +106 +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +109 +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +2800.0 + 20 +4275.0 + 30 +0.0 + 0 +ATTRIB + 5 +10B +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +3150.0 + 20 +3945.0 + 30 +0.0 + 40 +110.0 + 1 +D-105 + 11 +3150.0 + 21 +3945.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 0 +SEQEND + 5 +10A +330 +109 +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +10C +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +4900.0 + 20 +4275.0 + 30 +0.0 + 42 +-1.0 + 0 +ATTRIB + 5 +10E +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +-5250.0 + 20 +4605.0 + 30 +0.0 + 40 +110.0 + 1 +D-106 + 50 +180.0 + 11 +-5250.0 + 21 +4605.0 + 31 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 0 +SEQEND + 5 +10D +330 +10C +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +10F +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +8275.0 + 20 +5800.0 + 30 +0.0 + 50 +90.0 + 0 +ATTRIB + 5 +111 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +8605.0 + 20 +6150.0 + 30 +0.0 + 40 +110.0 + 1 +D-107 + 50 +90.0 + 11 +8605.0 + 21 +6150.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 0 +SEQEND + 5 +110 +330 +10F +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +112 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +3600.0 + 20 +-150.0 + 30 +0.0 + 41 +900.0 + 42 +300.0 + 0 +INSERT + 5 +114 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +6200.0 + 20 +-150.0 + 30 +0.0 + 41 +900.0 + 42 +300.0 + 0 +INSERT + 5 +116 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +8200.0 + 20 +-150.0 + 30 +0.0 + 41 +1200.0 + 42 +300.0 + 0 +INSERT + 5 +118 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +10150.0 + 20 +4800.0 + 30 +0.0 + 41 +1300.0 + 42 +300.0 + 50 +90.0 + 0 +INSERT + 5 +11A +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +1200.0 + 20 +7850.0 + 30 +0.0 + 41 +1400.0 + 42 +300.0 + 0 +INSERT + 5 +11C +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +3600.0 + 20 +7850.0 + 30 +0.0 + 41 +1600.0 + 42 +300.0 + 0 +INSERT + 5 +11E +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +6600.0 + 20 +7850.0 + 30 +0.0 + 41 +1200.0 + 42 +300.0 + 0 +INSERT + 5 +120 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +150.0 + 20 +5000.0 + 30 +0.0 + 41 +1400.0 + 42 +300.0 + 50 +90.0 + 0 +INSERT + 5 +122 +330 +17 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbBlockReference + 2 +SOFA + 10 +1200.0 + 20 +6900.0 + 30 +0.0 + 0 +INSERT + 5 +124 +330 +17 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbBlockReference + 2 +TABLE + 10 +3900.0 + 20 +5300.0 + 30 +0.0 + 0 +INSERT + 5 +126 +330 +17 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbBlockReference + 2 +BED + 10 +4300.0 + 20 +400.0 + 30 +0.0 + 0 +INSERT + 5 +128 +330 +17 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbBlockReference + 2 +BED + 10 +500.0 + 20 +600.0 + 30 +0.0 + 41 +0.8 + 42 +0.9 + 0 +INSERT + 5 +12A +330 +17 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbBlockReference + 2 +BATH + 10 +6800.0 + 20 +300.0 + 30 +0.0 + 0 +INSERT + 5 +12C +330 +17 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbBlockReference + 2 +WC + 10 +8300.0 + 20 +3200.0 + 30 +0.0 + 50 +90.0 + 0 +INSERT + 5 +12E +330 +17 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbBlockReference + 2 +SINK + 10 +6500.0 + 20 +3350.0 + 30 +0.0 + 0 +LINE + 5 +130 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +4400.0 + 30 +0.0 + 11 +7400.0 + 21 +4400.0 + 31 +0.0 + 0 +LINE + 5 +131 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +4680.0 + 30 +0.0 + 11 +7400.0 + 21 +4680.0 + 31 +0.0 + 0 +LINE + 5 +132 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +4960.0 + 30 +0.0 + 11 +7400.0 + 21 +4960.0 + 31 +0.0 + 0 +LINE + 5 +133 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +5240.0 + 30 +0.0 + 11 +7400.0 + 21 +5240.0 + 31 +0.0 + 0 +LINE + 5 +134 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +5520.0 + 30 +0.0 + 11 +7400.0 + 21 +5520.0 + 31 +0.0 + 0 +LINE + 5 +135 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +5800.0 + 30 +0.0 + 11 +7400.0 + 21 +5800.0 + 31 +0.0 + 0 +LINE + 5 +136 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +6080.0 + 30 +0.0 + 11 +7400.0 + 21 +6080.0 + 31 +0.0 + 0 +LINE + 5 +137 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +6360.0 + 30 +0.0 + 11 +7400.0 + 21 +6360.0 + 31 +0.0 + 0 +LINE + 5 +138 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +6640.0 + 30 +0.0 + 11 +7400.0 + 21 +6640.0 + 31 +0.0 + 0 +LINE + 5 +139 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +6920.0 + 30 +0.0 + 11 +7400.0 + 21 +6920.0 + 31 +0.0 + 0 +LINE + 5 +13A +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +7200.0 + 30 +0.0 + 11 +7400.0 + 21 +7200.0 + 31 +0.0 + 0 +LINE + 5 +13B +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +7480.0 + 30 +0.0 + 11 +7400.0 + 21 +7480.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +13C +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6400.0 + 20 +4400.0 + 10 +7400.0 + 20 +4400.0 + 10 +7400.0 + 20 +7760.0 + 10 +6400.0 + 20 +7760.0 + 0 +LWPOLYLINE + 5 +13D +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +6900.0 + 20 +4500.0 + 10 +6900.0 + 20 +7700.0 + 0 +LEADER + 5 +13E +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLeader + 3 +Standard + 73 +3 + 76 +3 + 10 +6900.0 + 20 +7700.0 + 30 +0.0 + 10 +6820.0 + 20 +7400.0 + 30 +0.0 + 10 +6980.0 + 20 +7400.0 + 30 +0.0 + 0 +TEXT + 5 +13F +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbText + 10 +6950.0 + 20 +4600.0 + 30 +0.0 + 40 +150.0 + 1 +UP + 7 +ARCH-TEXT + 11 +6950.0 + 21 +4600.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +140 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +0.0 + 20 +-1700.0 + 30 +0.0 + 11 +0.0 + 21 +9200.0 + 31 +0.0 + 0 +INSERT + 5 +141 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +0.0 + 20 +-2000.0 + 30 +0.0 + 0 +ATTRIB + 5 +143 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +0.0 + 20 +-2000.0 + 30 +0.0 + 40 +300.0 + 1 +1 + 72 +4 + 11 +0.0 + 21 +-2000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 0 +SEQEND + 5 +142 +330 +141 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +144 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +3500.0 + 20 +-1700.0 + 30 +0.0 + 11 +3500.0 + 21 +9200.0 + 31 +0.0 + 0 +INSERT + 5 +145 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +3500.0 + 20 +-2000.0 + 30 +0.0 + 0 +ATTRIB + 5 +147 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +3500.0 + 20 +-2000.0 + 30 +0.0 + 40 +300.0 + 1 +2 + 72 +4 + 11 +3500.0 + 21 +-2000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 0 +SEQEND + 5 +146 +330 +145 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +148 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +6200.0 + 20 +-1700.0 + 30 +0.0 + 11 +6200.0 + 21 +9200.0 + 31 +0.0 + 0 +INSERT + 5 +149 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +6200.0 + 20 +-2000.0 + 30 +0.0 + 0 +ATTRIB + 5 +14B +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +6200.0 + 20 +-2000.0 + 30 +0.0 + 40 +300.0 + 1 +3 + 72 +4 + 11 +6200.0 + 21 +-2000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 0 +SEQEND + 5 +14A +330 +149 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +14C +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +10000.0 + 20 +-1700.0 + 30 +0.0 + 11 +10000.0 + 21 +9200.0 + 31 +0.0 + 0 +INSERT + 5 +14D +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +10000.0 + 20 +-2000.0 + 30 +0.0 + 0 +ATTRIB + 5 +14F +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +10000.0 + 20 +-2000.0 + 30 +0.0 + 40 +300.0 + 1 +4 + 72 +4 + 11 +10000.0 + 21 +-2000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 0 +SEQEND + 5 +14E +330 +14D +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +150 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +-1700.0 + 20 +0.0 + 30 +0.0 + 11 +11200.0 + 21 +0.0 + 31 +0.0 + 0 +INSERT + 5 +151 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +-2000.0 + 20 +0.0 + 30 +0.0 + 0 +ATTRIB + 5 +153 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +-2000.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 1 +A + 72 +4 + 11 +-2000.0 + 21 +0.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 0 +SEQEND + 5 +152 +330 +151 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +154 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +-1700.0 + 20 +4200.0 + 30 +0.0 + 11 +11200.0 + 21 +4200.0 + 31 +0.0 + 0 +INSERT + 5 +155 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +-2000.0 + 20 +4200.0 + 30 +0.0 + 0 +ATTRIB + 5 +157 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +-2000.0 + 20 +4200.0 + 30 +0.0 + 40 +300.0 + 1 +B + 72 +4 + 11 +-2000.0 + 21 +4200.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 0 +SEQEND + 5 +156 +330 +155 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +158 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +-1700.0 + 20 +8000.0 + 30 +0.0 + 11 +11200.0 + 21 +8000.0 + 31 +0.0 + 0 +INSERT + 5 +159 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +-2000.0 + 20 +8000.0 + 30 +0.0 + 0 +ATTRIB + 5 +15B +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +-2000.0 + 20 +8000.0 + 30 +0.0 + 40 +300.0 + 1 +C + 72 +4 + 11 +-2000.0 + 21 +8000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 0 +SEQEND + 5 +15A +330 +159 +100 +AcDbEntity + 8 +A-GRID + 0 +MTEXT + 5 +15C +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +1750.0 + 20 +2100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;BEDROOM 1\P\fArial|b0;14.7 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +15D +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +4850.0 + 20 +2100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;BEDROOM 2\P\fArial|b0;11.3 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +15E +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +7500.0 + 20 +2100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;BATH\P\fArial|b0;16.0 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +15F +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +3100.0 + 20 +6100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;LIVING ROOM\P\fArial|b0;23.6 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +160 +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +7150.0 + 20 +6100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;KITCHEN\P\fArial|b0;14.4 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +161 +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +1500.0 + 20 +4650.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;HALL + 7 +ARCH-TEXT + 0 +DIMENSION + 5 +162 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D1 + 3 +PLAN50 + 10 +0.0 + 20 +-3100.0 + 30 +0.0 + 11 +5000.0 + 21 +-2924.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +10000.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +175 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D2 + 3 +PLAN50 + 10 +0.0 + 20 +-2600.0 + 30 +0.0 + 11 +1750.0 + 21 +-2424.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +3500.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +184 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D3 + 3 +PLAN50 + 10 +3500.0 + 20 +-2600.0 + 30 +0.0 + 11 +4850.0 + 21 +-2424.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +3500.0 + 23 +0.0 + 33 +0.0 + 14 +6200.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +193 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D4 + 3 +PLAN50 + 10 +6200.0 + 20 +-2600.0 + 30 +0.0 + 11 +8100.0 + 21 +-2424.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +6200.0 + 23 +0.0 + 33 +0.0 + 14 +10000.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +1A2 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D5 + 3 +PLAN50 + 10 +-3100.0 + 20 +0.0 + 30 +0.0 + 11 +-3276.0 + 21 +4000.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +0.0 + 24 +8000.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +1B1 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D6 + 3 +PLAN50 + 10 +-2600.0 + 20 +0.0 + 30 +0.0 + 11 +-2776.0 + 21 +2100.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +0.0 + 24 +4200.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +1C0 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D7 + 3 +PLAN50 + 10 +-2600.0 + 20 +4200.0 + 30 +0.0 + 11 +-2776.0 + 21 +6100.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +4200.0 + 33 +0.0 + 14 +0.0 + 24 +8000.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +LWPOLYLINE + 5 +1CF +330 +17 +100 +AcDbEntity + 8 +A-WALL + 6 +DASHED +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +11500.0 + 20 +500.0 + 10 +14500.0 + 20 +3500.0 + 0 +DIMENSION + 5 +1D0 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D8 + 3 +PLAN50 + 10 +11994.974746830583 + 20 +5.025253169416828 + 30 +0.0 + 11 +13370.523953341752 + 21 +1629.4760466582493 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +11500.0 + 23 +500.0 + 33 +0.0 + 14 +14500.0 + 24 +3500.0 + 34 +0.0 + 50 +45.0 +100 +AcDbRotatedDimension + 0 +LINE + 5 +1DF +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 11 +14500.0 + 21 +500.0 + 31 +0.0 + 0 +DIMENSION + 5 +1E0 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D9 + 3 +PLAN50 + 10 +14500.0 + 20 +500.0 + 30 +0.0 + 11 +12546.405475591537 + 21 +933.4353397314832 + 31 +0.0 + 70 +34 + 71 +5 + 1 +<> +100 +AcDb2LineAngularDimension + 13 +11500.0 + 23 +500.0 + 33 +0.0 + 14 +14500.0 + 24 +3500.0 + 34 +0.0 + 15 +11500.0 + 25 +500.0 + 35 +0.0 + 16 +12300.0 + 26 +700.0 + 36 +0.0 + 0 +DIMENSION + 5 +1F3 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension + 2 +*D10 + 3 +PLAN50 + 10 +6275.0 + 20 +1700.0 + 30 +0.0 + 11 +7269.192134348285 + 21 +2943.093721325951 + 31 +0.0 + 70 +36 + 71 +5 + 1 +<> +100 +AcDbRadialDimension + 15 +6911.396103067893 + 25 +2336.396103067893 + 35 +0.0 + 0 +INSERT + 5 +1FE +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 2 +NORTH + 10 +12200.0 + 20 +8700.0 + 30 +0.0 + 0 +INSERT + 5 +200 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +5000.0 + 20 +3400.0 + 30 +0.0 + 0 +ATTRIB + 5 +202 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +5320.0 + 20 +3690.0 + 30 +0.0 + 40 +120.0 + 1 +±0.00 + 11 +5320.0 + 21 +3690.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 0 +SEQEND + 5 +201 +330 +200 +100 +AcDbEntity + 8 +A-ANNO + 0 +TEXT + 5 +203 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +-4700.0 + 30 +0.0 + 40 +330.0 + 1 +GROUND FLOOR PLAN 1:50 + 7 +ARCH-TITLE + 11 +0.0 + 21 +-4700.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +204 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +370 +70 +100 +AcDbLine + 10 +0.0 + 20 +-4900.0 + 30 +0.0 + 11 +4500.0 + 21 +-4900.0 + 31 +0.0 + 0 +TEXT + 5 +205 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +-5200.0 + 30 +0.0 + 40 +130.0 + 1 +Drawn for the exav demo. Dimensions in millimetres. + 7 +ARCH-NOTE + 11 +0.0 + 21 +-5200.0 + 31 +0.0 +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 3 +A3 Plan +350 +209 + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +217 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +LAYOUT + 5 +209 +330 +D +100 +AcDbPlotSettings + 1 + + 2 +DWG to PDF.pc3 + 4 +ezdxf_(420.00_x_297.00_MM) + 6 + + 40 +10.0 + 41 +10.0 + 42 +10.0 + 43 +10.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +672 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +A3 Plan + 70 +1 + 71 +3 + 10 +-10.0 + 20 +-10.0 + 11 +410.0 + 21 +287.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +206 +331 +20A + 0 +DICTIONARYVAR + 5 +217 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/house-plan.dxf b/crates/exav-viewer/demo/examples/files/cad/house-plan.dxf new file mode 100644 index 00000000..6cdcec39 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/house-plan.dxf @@ -0,0 +1,17000 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1032 + 9 +$ACADMAINTVER + 90 +4 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$REQUIREDVERSIONS +160 +0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +218 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +214 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +5835.463185876482 + 22 +2324.253181513731 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +15878.827193569996 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +17 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-WALL + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-WALL-PAT + 70 +0 + 62 +8 + 6 +Continuous +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +43 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-DOOR + 70 +0 + 62 +3 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +44 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-WIND + 70 +0 + 62 +5 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +45 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-FURN + 70 +0 + 62 +9 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +46 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-FIXT + 70 +0 + 62 +4 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +47 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-GRID + 70 +0 + 62 +1 + 6 +CENTER +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +48 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-ANNO + 70 +0 + 62 +2 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +49 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-DIMS + 70 +0 + 62 +3 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +4A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-AREA + 70 +0 + 62 +6 + 6 +Continuous +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +4B +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-FLOR + 70 +0 + 62 +252 + 6 +Continuous +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +4C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-SLAB + 70 +0 + 62 +254 + 6 +Continuous +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +4D +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-STAIR + 70 +0 + 62 +30 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +4E +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-SHEET + 70 +0 + 62 +7 + 6 +Continuous +370 +35 +390 +13 +347 +21 + 0 +LAYER + 5 +4F +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +A-VPORT + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +9 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +4 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +50 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +ARCH-TEXT + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +STYLE + 5 +51 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +ARCH-TITLE + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +times.ttf + 4 + + 0 +STYLE + 5 +52 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +ARCH-NOTE + 70 +0 + 40 +0.0 + 41 +0.9 + 50 +15.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +215 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +216 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +2 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +53 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +PLAN50 + 70 +0 + 40 +1.0 + 41 +176.0 + 42 +176.0 + 43 +3.75 + 44 +132.0 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +220.0 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +66.0 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +3 +177 +3 +178 +2 +179 +2 +271 +0 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +340 +50 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +25 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +54 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +DOOR +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +5B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +WINDOW +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +64 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +BED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +6B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +TABLE +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +77 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SOFA +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +7F +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +WC +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +84 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SINK +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8A +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +BATH +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8F +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +AXIS +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +94 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +NORTH +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +9A +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +LEVEL +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +163 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D1 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +168 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDFILLED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +176 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D2 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +185 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D3 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +194 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D4 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1A3 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D5 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B2 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D6 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1C1 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D7 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1D1 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D8 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1E1 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D9 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1F4 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D10 +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +206 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space0 +340 +209 + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +55 +330 +54 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +DOOR + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +DOOR + 1 + + 0 +LINE + 5 +57 +330 +54 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +900.0 + 31 +0.0 + 0 +ARC + 5 +58 +330 +54 +100 +AcDbEntity + 8 +A-DOOR + 6 +DASHED +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +900.0 +100 +AcDbArc + 50 +0.0 + 51 +90.0 + 0 +LWPOLYLINE + 5 +59 +330 +54 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +900.0 + 20 +0.0 + 10 +900.0 + 20 +40.0 + 10 +0.0 + 20 +40.0 + 0 +ATTDEF + 5 +5A +330 +54 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +350.0 + 20 +-330.0 + 30 +0.0 + 40 +110.0 + 1 +D-000 +100 +AcDbAttributeDefinition + 3 + + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 0 +ENDBLK + 5 +56 +330 +54 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +5C +330 +5B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +WINDOW + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +WINDOW + 1 + + 0 +LINE + 5 +5E +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +1.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +5F +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.35 + 30 +0.0 + 11 +1.0 + 21 +0.35 + 31 +0.0 + 0 +LINE + 5 +60 +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.65 + 30 +0.0 + 11 +1.0 + 21 +0.65 + 31 +0.0 + 0 +LINE + 5 +61 +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +1.0 + 30 +0.0 + 11 +1.0 + 21 +1.0 + 31 +0.0 + 0 +LINE + 5 +62 +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +1.0 + 31 +0.0 + 0 +LINE + 5 +63 +330 +5B +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +1.0 + 20 +0.0 + 30 +0.0 + 11 +1.0 + 21 +1.0 + 31 +0.0 + 0 +ENDBLK + 5 +5D +330 +5B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +65 +330 +64 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +BED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +BED + 1 + + 0 +LWPOLYLINE + 5 +67 +330 +64 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1600.0 + 20 +0.0 + 10 +1600.0 + 20 +2000.0 + 10 +0.0 + 20 +2000.0 + 0 +LWPOLYLINE + 5 +68 +330 +64 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +80.0 + 20 +1300.0 + 10 +760.0 + 20 +1300.0 + 10 +760.0 + 20 +1920.0 + 10 +80.0 + 20 +1920.0 + 0 +LWPOLYLINE + 5 +69 +330 +64 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +1300.0 + 10 +1520.0 + 20 +1300.0 + 10 +1520.0 + 20 +1920.0 + 10 +840.0 + 20 +1920.0 + 0 +LINE + 5 +6A +330 +64 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbLine + 10 +0.0 + 20 +1200.0 + 30 +0.0 + 11 +1600.0 + 21 +1200.0 + 31 +0.0 + 0 +ENDBLK + 5 +66 +330 +64 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +6C +330 +6B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +TABLE + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +TABLE + 1 + + 0 +LWPOLYLINE + 5 +6E +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1800.0 + 20 +0.0 + 10 +1800.0 + 20 +900.0 + 10 +0.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +6F +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +-450.0 + 10 +550.0 + 20 +-450.0 + 10 +550.0 + 20 +-50.0 + 10 +150.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +70 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +950.0 + 10 +550.0 + 20 +950.0 + 10 +550.0 + 20 +1350.0 + 10 +150.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +71 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +600.0 + 20 +-450.0 + 10 +1000.0 + 20 +-450.0 + 10 +1000.0 + 20 +-50.0 + 10 +600.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +72 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +600.0 + 20 +950.0 + 10 +1000.0 + 20 +950.0 + 10 +1000.0 + 20 +1350.0 + 10 +600.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +73 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1050.0 + 20 +-450.0 + 10 +1450.0 + 20 +-450.0 + 10 +1450.0 + 20 +-50.0 + 10 +1050.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +74 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1050.0 + 20 +950.0 + 10 +1450.0 + 20 +950.0 + 10 +1450.0 + 20 +1350.0 + 10 +1050.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +75 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +-450.0 + 10 +1900.0 + 20 +-450.0 + 10 +1900.0 + 20 +-50.0 + 10 +1500.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +76 +330 +6B +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +950.0 + 10 +1900.0 + 20 +950.0 + 10 +1900.0 + 20 +1350.0 + 10 +1500.0 + 20 +1350.0 + 0 +ENDBLK + 5 +6D +330 +6B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +78 +330 +77 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SOFA + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SOFA + 1 + + 0 +LWPOLYLINE + 5 +7A +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +2200.0 + 20 +0.0 + 10 +2200.0 + 20 +950.0 + 10 +0.0 + 20 +950.0 + 0 +LWPOLYLINE + 5 +7B +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +2200.0 + 20 +0.0 + 10 +2200.0 + 20 +250.0 + 10 +0.0 + 20 +250.0 + 0 +LWPOLYLINE + 5 +7C +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +120.0 + 20 +300.0 + 10 +720.0 + 20 +300.0 + 10 +720.0 + 20 +900.0 + 10 +120.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +7D +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +770.0 + 20 +300.0 + 10 +1370.0 + 20 +300.0 + 10 +1370.0 + 20 +900.0 + 10 +770.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +7E +330 +77 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1420.0 + 20 +300.0 + 10 +2020.0 + 20 +300.0 + 10 +2020.0 + 20 +900.0 + 10 +1420.0 + 20 +900.0 + 0 +ENDBLK + 5 +79 +330 +77 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +80 +330 +7F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +WC + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +WC + 1 + + 0 +LWPOLYLINE + 5 +82 +330 +7F +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +400.0 + 20 +0.0 + 10 +400.0 + 20 +200.0 + 10 +0.0 + 20 +200.0 + 0 +ELLIPSE + 5 +83 +330 +7F +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbEllipse + 10 +200.0 + 20 +-250.0 + 30 +0.0 + 11 +0.0 + 21 +230.0 + 31 +0.0 + 40 +0.8 + 41 +0.0 + 42 +6.283185307179586 + 0 +ENDBLK + 5 +81 +330 +7F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +85 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SINK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SINK + 1 + + 0 +LWPOLYLINE + 5 +87 +330 +84 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +600.0 + 20 +0.0 + 10 +600.0 + 20 +450.0 + 10 +0.0 + 20 +450.0 + 0 +CIRCLE + 5 +88 +330 +84 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbCircle + 10 +300.0 + 20 +225.0 + 30 +0.0 + 40 +160.0 + 0 +CIRCLE + 5 +89 +330 +84 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbCircle + 10 +300.0 + 20 +225.0 + 30 +0.0 + 40 +25.0 + 0 +ENDBLK + 5 +86 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +8B +330 +8A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +BATH + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +BATH + 1 + + 0 +LWPOLYLINE + 5 +8D +330 +8A +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1700.0 + 20 +0.0 + 10 +1700.0 + 20 +750.0 + 10 +0.0 + 20 +750.0 + 0 +LWPOLYLINE + 5 +8E +330 +8A +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +5 + 70 +1 + 10 +100.0 + 20 +100.0 + 10 +1500.0 + 20 +100.0 + 10 +1600.0 + 20 +375.0 + 10 +1500.0 + 20 +650.0 + 10 +100.0 + 20 +650.0 + 0 +ENDBLK + 5 +8C +330 +8A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +90 +330 +8F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +AXIS + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +AXIS + 1 + + 0 +CIRCLE + 5 +92 +330 +8F +100 +AcDbEntity + 8 +A-GRID +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 0 +ATTDEF + 5 +93 +330 +8F +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 1 +A + 72 +4 + 11 +0.0 + 21 +0.0 + 31 +0.0 +100 +AcDbAttributeDefinition + 3 + + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 0 +ENDBLK + 5 +91 +330 +8F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +95 +330 +94 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +NORTH + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +NORTH + 1 + + 0 +CIRCLE + 5 +97 +330 +94 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +500.0 + 0 +SOLID + 5 +98 +330 +94 +100 +AcDbEntity + 8 +A-ANNO + 62 +7 +100 +AcDbTrace + 10 +0.0 + 20 +650.0 + 30 +0.0 + 11 +-170.0 + 21 +-400.0 + 31 +0.0 + 12 +0.0 + 22 +-200.0 + 32 +0.0 + 13 +170.0 + 23 +-400.0 + 33 +0.0 + 0 +TEXT + 5 +99 +330 +94 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +800.0 + 30 +0.0 + 40 +300.0 + 1 +N + 72 +1 + 11 +0.0 + 21 +800.0 + 31 +0.0 +100 +AcDbText + 73 +1 + 0 +ENDBLK + 5 +96 +330 +94 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +9B +330 +9A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +LEVEL + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +LEVEL + 1 + + 0 +LWPOLYLINE + 5 +9D +330 +9A +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +-250.0 + 20 +250.0 + 10 +0.0 + 20 +0.0 + 10 +250.0 + 20 +250.0 + 0 +LINE + 5 +9E +330 +9A +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbLine + 10 +-250.0 + 20 +250.0 + 30 +0.0 + 11 +900.0 + 21 +250.0 + 31 +0.0 + 0 +ATTDEF + 5 +9F +330 +9A +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +320.0 + 20 +290.0 + 30 +0.0 + 40 +120.0 + 1 ++0.00 +100 +AcDbAttributeDefinition + 3 + + 2 +H + 70 +0 + 71 +1 + 72 +0 + 0 +ENDBLK + 5 +9C +330 +9A +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +164 +330 +163 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D1 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D1 + 1 + + 0 +LINE + 5 +166 +330 +163 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-176.0 + 30 +0.0 + 11 +0.0 + 21 +-3232.0 + 31 +0.0 + 0 +LINE + 5 +167 +330 +163 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +10000.0 + 20 +-176.0 + 30 +0.0 + 11 +10000.0 + 21 +-3232.0 + 31 +0.0 + 0 +INSERT + 5 +16C +330 +163 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-3100.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +180.0 + 0 +INSERT + 5 +16E +330 +163 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +10000.0 + 20 +-3100.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 0 +LINE + 5 +170 +330 +163 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +176.0 + 20 +-3100.0 + 30 +0.0 + 11 +9824.0 + 21 +-3100.0 + 31 +0.0 + 0 +MTEXT + 5 +171 +330 +163 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +5000.0 + 20 +-2924.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +10000 + 7 +ARCH-TEXT + 0 +POINT + 5 +172 +330 +163 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-3100.0 + 30 +0.0 + 0 +POINT + 5 +173 +330 +163 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +174 +330 +163 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +10000.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +165 +330 +163 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +169 +330 +168 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDFILLED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDFILLED + 1 + + 0 +SOLID + 5 +16B +330 +168 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbTrace + 10 +-1.0 + 20 +0.16439898372402537 + 30 +0.0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 12 +-1.0 + 22 +-0.16439898372402537 + 32 +0.0 + 13 +-1.0 + 23 +-0.16439898372402537 + 33 +0.0 + 0 +ENDBLK + 5 +16A +330 +168 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +177 +330 +176 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D2 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D2 + 1 + + 0 +LINE + 5 +179 +330 +176 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +0.0 + 20 +-176.0 + 30 +0.0 + 11 +0.0 + 21 +-2732.0 + 31 +0.0 + 0 +LINE + 5 +17A +330 +176 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +3500.0 + 20 +-176.0 + 30 +0.0 + 11 +3500.0 + 21 +-2732.0 + 31 +0.0 + 0 +INSERT + 5 +17B +330 +176 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +0.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +180.0 + 0 +INSERT + 5 +17D +330 +176 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +3500.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 0 +LINE + 5 +17F +330 +176 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +176.0 + 20 +-2600.0 + 30 +0.0 + 11 +3324.0 + 21 +-2600.0 + 31 +0.0 + 0 +MTEXT + 5 +180 +330 +176 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +1750.0 + 20 +-2424.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +3500 + 7 +ARCH-TEXT + 0 +POINT + 5 +181 +330 +176 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +-2600.0 + 30 +0.0 + 0 +POINT + 5 +182 +330 +176 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +183 +330 +176 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +3500.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +178 +330 +176 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +186 +330 +185 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D3 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D3 + 1 + + 0 +LINE + 5 +188 +330 +185 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +3500.0 + 20 +-176.0 + 30 +0.0 + 11 +3500.0 + 21 +-2732.0 + 31 +0.0 + 0 +LINE + 5 +189 +330 +185 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +6200.0 + 20 +-176.0 + 30 +0.0 + 11 +6200.0 + 21 +-2732.0 + 31 +0.0 + 0 +INSERT + 5 +18A +330 +185 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +3500.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +180.0 + 0 +INSERT + 5 +18C +330 +185 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +6200.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 0 +LINE + 5 +18E +330 +185 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +3676.0 + 20 +-2600.0 + 30 +0.0 + 11 +6024.0 + 21 +-2600.0 + 31 +0.0 + 0 +MTEXT + 5 +18F +330 +185 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +4850.0 + 20 +-2424.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +2700 + 7 +ARCH-TEXT + 0 +POINT + 5 +190 +330 +185 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +3500.0 + 20 +-2600.0 + 30 +0.0 + 0 +POINT + 5 +191 +330 +185 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +3500.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +192 +330 +185 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6200.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +187 +330 +185 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +195 +330 +194 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D4 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D4 + 1 + + 0 +LINE + 5 +197 +330 +194 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +6200.0 + 20 +-176.0 + 30 +0.0 + 11 +6200.0 + 21 +-2732.0 + 31 +0.0 + 0 +LINE + 5 +198 +330 +194 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +10000.0 + 20 +-176.0 + 30 +0.0 + 11 +10000.0 + 21 +-2732.0 + 31 +0.0 + 0 +INSERT + 5 +199 +330 +194 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +6200.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +180.0 + 0 +INSERT + 5 +19B +330 +194 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +10000.0 + 20 +-2600.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 0 +LINE + 5 +19D +330 +194 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +6376.0 + 20 +-2600.0 + 30 +0.0 + 11 +9824.0 + 21 +-2600.0 + 31 +0.0 + 0 +MTEXT + 5 +19E +330 +194 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +8100.0 + 20 +-2424.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +3800 + 7 +ARCH-TEXT + 0 +POINT + 5 +19F +330 +194 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6200.0 + 20 +-2600.0 + 30 +0.0 + 0 +POINT + 5 +1A0 +330 +194 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6200.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1A1 +330 +194 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +10000.0 + 20 +0.0 + 30 +0.0 + 0 +ENDBLK + 5 +196 +330 +194 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1A4 +330 +1A3 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D5 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D5 + 1 + + 0 +LINE + 5 +1A6 +330 +1A3 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +0.0 + 30 +0.0 + 11 +-3232.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +1A7 +330 +1A3 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +8000.0 + 30 +0.0 + 11 +-3232.0 + 21 +8000.0 + 31 +0.0 + 0 +INSERT + 5 +1A8 +330 +1A3 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-3100.0 + 20 +0.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +270.0 + 0 +INSERT + 5 +1AA +330 +1A3 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-3100.0 + 20 +8000.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +90.0 + 0 +LINE + 5 +1AC +330 +1A3 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-3100.0 + 20 +176.0 + 30 +0.0 + 11 +-3100.0 + 21 +7824.0 + 31 +0.0 + 0 +MTEXT + 5 +1AD +330 +1A3 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-3276.0 + 20 +4000.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +8000 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +1AE +330 +1A3 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-3100.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1AF +330 +1A3 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1B0 +330 +1A3 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +8000.0 + 30 +0.0 + 0 +ENDBLK + 5 +1A5 +330 +1A3 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1B3 +330 +1B2 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D6 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D6 + 1 + + 0 +LINE + 5 +1B5 +330 +1B2 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +0.0 + 30 +0.0 + 11 +-2732.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +1B6 +330 +1B2 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +4200.0 + 30 +0.0 + 11 +-2732.0 + 21 +4200.0 + 31 +0.0 + 0 +INSERT + 5 +1B7 +330 +1B2 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-2600.0 + 20 +0.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +270.0 + 0 +INSERT + 5 +1B9 +330 +1B2 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-2600.0 + 20 +4200.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +90.0 + 0 +LINE + 5 +1BB +330 +1B2 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-2600.0 + 20 +176.0 + 30 +0.0 + 11 +-2600.0 + 21 +4024.0 + 31 +0.0 + 0 +MTEXT + 5 +1BC +330 +1B2 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-2776.0 + 20 +2100.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +4200 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +1BD +330 +1B2 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-2600.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1BE +330 +1B2 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +POINT + 5 +1BF +330 +1B2 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +4200.0 + 30 +0.0 + 0 +ENDBLK + 5 +1B4 +330 +1B2 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C2 +330 +1C1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D7 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D7 + 1 + + 0 +LINE + 5 +1C4 +330 +1C1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +4200.0 + 30 +0.0 + 11 +-2732.0 + 21 +4200.0 + 31 +0.0 + 0 +LINE + 5 +1C5 +330 +1C1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-176.0 + 20 +8000.0 + 30 +0.0 + 11 +-2732.0 + 21 +8000.0 + 31 +0.0 + 0 +INSERT + 5 +1C6 +330 +1C1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-2600.0 + 20 +4200.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +270.0 + 0 +INSERT + 5 +1C8 +330 +1C1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +-2600.0 + 20 +8000.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +90.0 + 0 +LINE + 5 +1CA +330 +1C1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +-2600.0 + 20 +4376.0 + 30 +0.0 + 11 +-2600.0 + 21 +7824.0 + 31 +0.0 + 0 +MTEXT + 5 +1CB +330 +1C1 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +-2776.0 + 20 +6100.0 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +3800 + 7 +ARCH-TEXT + 50 +90.0 + 0 +POINT + 5 +1CC +330 +1C1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +-2600.0 + 20 +4200.0 + 30 +0.0 + 0 +POINT + 5 +1CD +330 +1C1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +4200.0 + 30 +0.0 + 0 +POINT + 5 +1CE +330 +1C1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +0.0 + 20 +8000.0 + 30 +0.0 + 0 +ENDBLK + 5 +1C3 +330 +1C1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1D2 +330 +1D1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D8 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D8 + 1 + + 0 +LINE + 5 +1D4 +330 +1D1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +11624.450793488832 + 20 +375.54920651116765 + 30 +0.0 + 11 +12088.312841947207 + 21 +-88.31284194720743 + 31 +0.0 + 0 +LINE + 5 +1D5 +330 +1D1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +14624.450793488832 + 20 +3375.5492065111675 + 30 +0.0 + 11 +15088.312841947207 + 21 +2911.6871580527927 + 31 +0.0 + 0 +INSERT + 5 +1D6 +330 +1D1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +11994.974746830583 + 20 +5.025253169416828 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +225.0 + 0 +INSERT + 5 +1D8 +330 +1D1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +14994.974746830583 + 20 +3005.025253169417 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +45.0 + 0 +LINE + 5 +1DA +330 +1D1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +12119.425540319415 + 20 +129.47604665824917 + 30 +0.0 + 11 +14870.523953341752 + 21 +2880.5744596805844 + 31 +0.0 + 0 +MTEXT + 5 +1DB +330 +1D1 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +13370.523953341752 + 20 +1629.4760466582493 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +4243 + 7 +ARCH-TEXT + 50 +45.0 + 0 +POINT + 5 +1DC +330 +1D1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +11994.974746830583 + 20 +5.025253169416828 + 30 +0.0 + 0 +POINT + 5 +1DD +330 +1D1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 0 +POINT + 5 +1DE +330 +1D1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14500.0 + 20 +3500.0 + 30 +0.0 + 0 +ENDBLK + 5 +1D3 +330 +1D1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1E2 +330 +1E1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D9 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D9 + 1 + + 0 +LINE + 5 +1E4 +330 +1E1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +11676.0 + 20 +500.0 + 30 +0.0 + 11 +12456.621125123533 + 21 +500.0 + 31 +0.0 + 0 +LINE + 5 +1E5 +330 +1E1 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +11624.450793488832 + 20 +624.4507934888323 + 30 +0.0 + 11 +12176.433284601155 + 21 +1176.4332846011544 + 31 +0.0 + 0 +INSERT + 5 +1E6 +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +12324.621125123533 + 20 +500.0 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +443.88564221369427 + 0 +INSERT + 5 +1E8 +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +12083.09518948453 + 20 +1083.0951894845302 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +321.11435778630573 + 0 +MTEXT + 5 +1EA +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +12546.405475591537 + 20 +933.4353397314832 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +45° + 7 +ARCH-TEXT + 50 +-67.5 + 0 +ARC + 5 +1EB +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbCircle + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 40 +824.6211251235321 +100 +AcDbArc + 50 +0.0 + 51 +45.0 + 0 +ARC + 5 +1EC +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbCircle + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 40 +824.6211251235321 +100 +AcDbArc + 50 +-24.45743114522284 + 51 +-12.22871557261142 + 0 +ARC + 5 +1ED +330 +1E1 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbCircle + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 40 +824.6211251235321 +100 +AcDbArc + 50 +57.22871557261142 + 51 +69.45743114522284 + 0 +POINT + 5 +1EE +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14500.0 + 20 +500.0 + 30 +0.0 + 0 +POINT + 5 +1EF +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 0 +POINT + 5 +1F0 +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +14500.0 + 20 +3500.0 + 30 +0.0 + 0 +POINT + 5 +1F1 +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 0 +POINT + 5 +1F2 +330 +1E1 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +12300.0 + 20 +700.0 + 30 +0.0 + 0 +ENDBLK + 5 +1E3 +330 +1E1 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1F5 +330 +1F4 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D10 + 70 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D10 + 1 + + 0 +INSERT + 5 +1F7 +330 +1F4 +100 +AcDbEntity + 8 +A-DIMS + 62 +3 +100 +AcDbBlockReference + 2 +_CLOSEDFILLED + 10 +6911.396103067893 + 20 +2336.396103067893 + 30 +0.0 + 41 +176.0 + 42 +176.0 + 50 +225.0 + 0 +LINE + 5 +1F9 +330 +1F4 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +6275.0 + 20 +1700.0 + 30 +0.0 + 11 +6911.396103067893 + 21 +2336.396103067893 + 31 +0.0 + 0 +LINE + 5 +1FA +330 +1F4 +100 +AcDbEntity + 8 +0 + 62 +3 +100 +AcDbLine + 10 +7035.846896556725 + 20 +2460.8468965567254 + 30 +0.0 + 11 +7704.7699115591995 + 21 +3129.7699115591995 + 31 +0.0 + 0 +MTEXT + 5 +1FB +330 +1F4 +100 +AcDbEntity + 8 +A-DIMS + 62 +2 +100 +AcDbMText + 10 +7269.192134348285 + 20 +2943.093721325951 + 30 +0.0 + 40 +220.0 + 71 +5 + 1 +R900 + 7 +ARCH-TEXT + 50 +45.0 + 0 +POINT + 5 +1FC +330 +1F4 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6275.0 + 20 +1700.0 + 30 +0.0 + 0 +POINT + 5 +1FD +330 +1F4 +100 +AcDbEntity + 8 +Defpoints +100 +AcDbPoint + 10 +6911.396103067893 + 20 +2336.396103067893 + 30 +0.0 + 0 +ENDBLK + 5 +1F6 +330 +1F4 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +207 +330 +206 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space0 + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space0 + 1 + + 0 +VIEWPORT + 5 +20A +330 +206 +100 +AcDbEntity + 67 +1 + 8 +VIEWPORTS +100 +AcDbViewport + 10 +200.0 + 20 +138.5 + 30 +0.0 + 40 +462.00000000000006 + 41 +326.70000000000005 + 68 +1 + 69 +1 + 12 +200.0 + 22 +138.5 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +326.70000000000005 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +557088 + 1 + +281 +0 + 71 +0 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 +282 +0 + 0 +LWPOLYLINE + 5 +20B +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +10.0 + 20 +10.0 + 10 +410.0 + 20 +10.0 + 10 +410.0 + 20 +287.0 + 10 +10.0 + 20 +287.0 + 0 +LWPOLYLINE + 5 +20C +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +5.0 + 20 +5.0 + 10 +415.0 + 20 +5.0 + 10 +415.0 + 20 +292.0 + 10 +5.0 + 20 +292.0 + 0 +LWPOLYLINE + 5 +20D +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +260.0 + 20 +10.0 + 10 +410.0 + 20 +10.0 + 10 +410.0 + 20 +60.0 + 10 +260.0 + 20 +60.0 + 0 +LINE + 5 +20E +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbLine + 10 +260.0 + 20 +35.0 + 30 +0.0 + 11 +410.0 + 21 +35.0 + 31 +0.0 + 0 +LINE + 5 +20F +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbLine + 10 +335.0 + 20 +10.0 + 30 +0.0 + 11 +335.0 + 21 +35.0 + 31 +0.0 + 0 +TEXT + 5 +210 +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbText + 10 +265.0 + 20 +45.0 + 30 +0.0 + 40 +7.0 + 1 +HOUSE, GROUND FLOOR + 7 +ARCH-TITLE + 11 +265.0 + 21 +45.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +211 +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbText + 10 +265.0 + 20 +18.0 + 30 +0.0 + 40 +4.0 + 1 +Scale 1:50 + 7 +ARCH-TEXT + 11 +265.0 + 21 +18.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +212 +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-SHEET +100 +AcDbText + 10 +340.0 + 20 +18.0 + 30 +0.0 + 40 +4.0 + 1 +Sheet A-101 + 7 +ARCH-TEXT + 11 +340.0 + 21 +18.0 + 31 +0.0 +100 +AcDbText + 0 +VIEWPORT + 5 +213 +330 +206 +100 +AcDbEntity + 67 +1 + 8 +A-VPORT +100 +AcDbViewport + 10 +135.0 + 20 +160.0 + 30 +0.0 + 40 +250.0 + 41 +200.0 + 68 +1 + 69 +3 + 12 +5000.0 + 22 +3400.0 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +10000.0 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +0 + 1 + +281 +0 + 71 +0 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 +282 +0 + 0 +ENDBLK + 5 +208 +330 +206 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +HATCH + 5 +A0 +330 +17 +100 +AcDbEntity + 8 +A-SLAB + 62 +254 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +0.0 + 10 +10000.0 + 20 +0.0 + 10 +10000.0 + 20 +8000.0 + 10 +0.0 + 20 +8000.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +A1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +150.0 + 10 +1400.0 + 20 +150.0 + 10 +1400.0 + 20 +-150.0 + 10 +0.0 + 20 +-150.0 + 0 +HATCH + 5 +A2 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +150.0 + 10 +1400.0 + 20 +150.0 + 10 +1400.0 + 20 +-150.0 + 10 +0.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +A3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +2900.0 + 20 +150.0 + 10 +3600.0 + 20 +150.0 + 10 +3600.0 + 20 +-150.0 + 10 +2900.0 + 20 +-150.0 + 0 +HATCH + 5 +A4 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +2900.0 + 20 +150.0 + 10 +3600.0 + 20 +150.0 + 10 +3600.0 + 20 +-150.0 + 10 +2900.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +A5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +4500.0 + 20 +150.0 + 10 +6200.0 + 20 +150.0 + 10 +6200.0 + 20 +-150.0 + 10 +4500.0 + 20 +-150.0 + 0 +HATCH + 5 +A6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +4500.0 + 20 +150.0 + 10 +6200.0 + 20 +150.0 + 10 +6200.0 + 20 +-150.0 + 10 +4500.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +A7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +7100.0 + 20 +150.0 + 10 +8200.0 + 20 +150.0 + 10 +8200.0 + 20 +-150.0 + 10 +7100.0 + 20 +-150.0 + 0 +HATCH + 5 +A8 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +7100.0 + 20 +150.0 + 10 +8200.0 + 20 +150.0 + 10 +8200.0 + 20 +-150.0 + 10 +7100.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +A9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +9400.0 + 20 +150.0 + 10 +10000.0 + 20 +150.0 + 10 +10000.0 + 20 +-150.0 + 10 +9400.0 + 20 +-150.0 + 0 +HATCH + 5 +AA +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +9400.0 + 20 +150.0 + 10 +10000.0 + 20 +150.0 + 10 +10000.0 + 20 +-150.0 + 10 +9400.0 + 20 +-150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +AB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +1400.0 + 20 +150.0 + 30 +0.0 + 11 +1400.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +AC +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +2900.0 + 20 +150.0 + 30 +0.0 + 11 +2900.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +AD +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3600.0 + 20 +150.0 + 30 +0.0 + 11 +3600.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +AE +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +4500.0 + 20 +150.0 + 30 +0.0 + 11 +4500.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +AF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6200.0 + 20 +150.0 + 30 +0.0 + 11 +6200.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +B0 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +7100.0 + 20 +150.0 + 30 +0.0 + 11 +7100.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +B1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +8200.0 + 20 +150.0 + 30 +0.0 + 11 +8200.0 + 21 +-150.0 + 31 +0.0 + 0 +LINE + 5 +B2 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9400.0 + 20 +150.0 + 30 +0.0 + 11 +9400.0 + 21 +-150.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +B3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +9850.0 + 20 +0.0 + 10 +9850.0 + 20 +1500.0 + 10 +10150.0 + 20 +1500.0 + 10 +10150.0 + 20 +0.0 + 0 +HATCH + 5 +B4 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +9850.0 + 20 +0.0 + 10 +9850.0 + 20 +1500.0 + 10 +10150.0 + 20 +1500.0 + 10 +10150.0 + 20 +0.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +B5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +9850.0 + 20 +2800.0 + 10 +9850.0 + 20 +4800.0 + 10 +10150.0 + 20 +4800.0 + 10 +10150.0 + 20 +2800.0 + 0 +HATCH + 5 +B6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +9850.0 + 20 +2800.0 + 10 +9850.0 + 20 +4800.0 + 10 +10150.0 + 20 +4800.0 + 10 +10150.0 + 20 +2800.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +B7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +9850.0 + 20 +6100.0 + 10 +9850.0 + 20 +8000.0 + 10 +10150.0 + 20 +8000.0 + 10 +10150.0 + 20 +6100.0 + 0 +HATCH + 5 +B8 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +9850.0 + 20 +6100.0 + 10 +9850.0 + 20 +8000.0 + 10 +10150.0 + 20 +8000.0 + 10 +10150.0 + 20 +6100.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +B9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9850.0 + 20 +1500.0 + 30 +0.0 + 11 +10150.0 + 21 +1500.0 + 31 +0.0 + 0 +LINE + 5 +BA +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9850.0 + 20 +2800.0 + 30 +0.0 + 11 +10150.0 + 21 +2800.0 + 31 +0.0 + 0 +LINE + 5 +BB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9850.0 + 20 +4800.0 + 30 +0.0 + 11 +10150.0 + 21 +4800.0 + 31 +0.0 + 0 +LINE + 5 +BC +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +9850.0 + 20 +6100.0 + 30 +0.0 + 11 +10150.0 + 21 +6100.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +BD +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +10000.0 + 20 +7850.0 + 10 +8800.0 + 20 +7850.0 + 10 +8800.0 + 20 +8150.0 + 10 +10000.0 + 20 +8150.0 + 0 +HATCH + 5 +BE +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +10000.0 + 20 +7850.0 + 10 +8800.0 + 20 +7850.0 + 10 +8800.0 + 20 +8150.0 + 10 +10000.0 + 20 +8150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +BF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +7400.0 + 20 +7850.0 + 10 +6400.0 + 20 +7850.0 + 10 +6400.0 + 20 +8150.0 + 10 +7400.0 + 20 +8150.0 + 0 +HATCH + 5 +C0 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +7400.0 + 20 +7850.0 + 10 +6400.0 + 20 +7850.0 + 10 +6400.0 + 20 +8150.0 + 10 +7400.0 + 20 +8150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +C1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +4800.0 + 20 +7850.0 + 10 +3400.0 + 20 +7850.0 + 10 +3400.0 + 20 +8150.0 + 10 +4800.0 + 20 +8150.0 + 0 +HATCH + 5 +C2 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +4800.0 + 20 +7850.0 + 10 +3400.0 + 20 +7850.0 + 10 +3400.0 + 20 +8150.0 + 10 +4800.0 + 20 +8150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +C3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +2200.0 + 20 +7850.0 + 10 +0.0 + 20 +7850.0 + 10 +0.0 + 20 +8150.0 + 10 +2200.0 + 20 +8150.0 + 0 +HATCH + 5 +C4 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +2200.0 + 20 +7850.0 + 10 +0.0 + 20 +7850.0 + 10 +0.0 + 20 +8150.0 + 10 +2200.0 + 20 +8150.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +C5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +8800.0 + 20 +7850.0 + 30 +0.0 + 11 +8800.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +C6 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +7400.0 + 20 +7850.0 + 30 +0.0 + 11 +7400.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +C7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6400.0 + 20 +7850.0 + 30 +0.0 + 11 +6400.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +C8 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +4800.0 + 20 +7850.0 + 30 +0.0 + 11 +4800.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +C9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3400.0 + 20 +7850.0 + 30 +0.0 + 11 +3400.0 + 21 +8150.0 + 31 +0.0 + 0 +LINE + 5 +CA +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +2200.0 + 20 +7850.0 + 30 +0.0 + 11 +2200.0 + 21 +8150.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +CB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +8000.0 + 10 +150.0 + 20 +6400.0 + 10 +-150.0 + 20 +6400.0 + 10 +-150.0 + 20 +8000.0 + 0 +HATCH + 5 +CC +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +150.0 + 20 +8000.0 + 10 +150.0 + 20 +6400.0 + 10 +-150.0 + 20 +6400.0 + 10 +-150.0 + 20 +8000.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +CD +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +5000.0 + 10 +150.0 + 20 +3000.0 + 10 +-150.0 + 20 +3000.0 + 10 +-150.0 + 20 +5000.0 + 0 +HATCH + 5 +CE +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +150.0 + 20 +5000.0 + 10 +150.0 + 20 +3000.0 + 10 +-150.0 + 20 +3000.0 + 10 +-150.0 + 20 +5000.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +CF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +1600.0 + 10 +150.0 + 20 +0.0 + 10 +-150.0 + 20 +0.0 + 10 +-150.0 + 20 +1600.0 + 0 +HATCH + 5 +D0 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +150.0 + 20 +1600.0 + 10 +150.0 + 20 +0.0 + 10 +-150.0 + 20 +0.0 + 10 +-150.0 + 20 +1600.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +D1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +150.0 + 20 +6400.0 + 30 +0.0 + 11 +-150.0 + 21 +6400.0 + 31 +0.0 + 0 +LINE + 5 +D2 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +150.0 + 20 +5000.0 + 30 +0.0 + 11 +-150.0 + 21 +5000.0 + 31 +0.0 + 0 +LINE + 5 +D3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +150.0 + 20 +3000.0 + 30 +0.0 + 11 +-150.0 + 21 +3000.0 + 31 +0.0 + 0 +LINE + 5 +D4 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +150.0 + 20 +1600.0 + 30 +0.0 + 11 +-150.0 + 21 +1600.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +D5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +4275.0 + 10 +600.0 + 20 +4275.0 + 10 +600.0 + 20 +4125.0 + 10 +0.0 + 20 +4125.0 + 0 +HATCH + 5 +D6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +4275.0 + 10 +600.0 + 20 +4275.0 + 10 +600.0 + 20 +4125.0 + 10 +0.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +D7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +4275.0 + 10 +2700.0 + 20 +4275.0 + 10 +2700.0 + 20 +4125.0 + 10 +1500.0 + 20 +4125.0 + 0 +HATCH + 5 +D8 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +1500.0 + 20 +4275.0 + 10 +2700.0 + 20 +4275.0 + 10 +2700.0 + 20 +4125.0 + 10 +1500.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +D9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3600.0 + 20 +4275.0 + 10 +4800.0 + 20 +4275.0 + 10 +4800.0 + 20 +4125.0 + 10 +3600.0 + 20 +4125.0 + 0 +HATCH + 5 +DA +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3600.0 + 20 +4275.0 + 10 +4800.0 + 20 +4275.0 + 10 +4800.0 + 20 +4125.0 + 10 +3600.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +DB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +5700.0 + 20 +4275.0 + 10 +6200.0 + 20 +4275.0 + 10 +6200.0 + 20 +4125.0 + 10 +5700.0 + 20 +4125.0 + 0 +HATCH + 5 +DC +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +5700.0 + 20 +4275.0 + 10 +6200.0 + 20 +4275.0 + 10 +6200.0 + 20 +4125.0 + 10 +5700.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +DD +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +600.0 + 20 +4275.0 + 30 +0.0 + 11 +600.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +DE +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +1500.0 + 20 +4275.0 + 30 +0.0 + 11 +1500.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +DF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +2700.0 + 20 +4275.0 + 30 +0.0 + 11 +2700.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +E0 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3600.0 + 20 +4275.0 + 30 +0.0 + 11 +3600.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +E1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +4800.0 + 20 +4275.0 + 30 +0.0 + 11 +4800.0 + 21 +4125.0 + 31 +0.0 + 0 +LINE + 5 +E2 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +5700.0 + 20 +4275.0 + 30 +0.0 + 11 +5700.0 + 21 +4125.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +E3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6125.0 + 20 +0.0 + 10 +6125.0 + 20 +1700.0 + 10 +6275.0 + 20 +1700.0 + 10 +6275.0 + 20 +0.0 + 0 +HATCH + 5 +E4 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6125.0 + 20 +0.0 + 10 +6125.0 + 20 +1700.0 + 10 +6275.0 + 20 +1700.0 + 10 +6275.0 + 20 +0.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +E5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6125.0 + 20 +2600.0 + 10 +6125.0 + 20 +4200.0 + 10 +6275.0 + 20 +4200.0 + 10 +6275.0 + 20 +2600.0 + 0 +HATCH + 5 +E6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6125.0 + 20 +2600.0 + 10 +6125.0 + 20 +4200.0 + 10 +6275.0 + 20 +4200.0 + 10 +6275.0 + 20 +2600.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +E7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6125.0 + 20 +1700.0 + 30 +0.0 + 11 +6275.0 + 21 +1700.0 + 31 +0.0 + 0 +LINE + 5 +E8 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6125.0 + 20 +2600.0 + 30 +0.0 + 11 +6275.0 + 21 +2600.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +E9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6125.0 + 20 +4200.0 + 10 +6125.0 + 20 +6300.0 + 10 +6275.0 + 20 +6300.0 + 10 +6275.0 + 20 +4200.0 + 0 +HATCH + 5 +EA +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6125.0 + 20 +4200.0 + 10 +6125.0 + 20 +6300.0 + 10 +6275.0 + 20 +6300.0 + 10 +6275.0 + 20 +4200.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +EB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6125.0 + 20 +7200.0 + 10 +6125.0 + 20 +8000.0 + 10 +6275.0 + 20 +8000.0 + 10 +6275.0 + 20 +7200.0 + 0 +HATCH + 5 +EC +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6125.0 + 20 +7200.0 + 10 +6125.0 + 20 +8000.0 + 10 +6275.0 + 20 +8000.0 + 10 +6275.0 + 20 +7200.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +ED +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6125.0 + 20 +6300.0 + 30 +0.0 + 11 +6275.0 + 21 +6300.0 + 31 +0.0 + 0 +LINE + 5 +EE +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +6125.0 + 20 +7200.0 + 30 +0.0 + 11 +6275.0 + 21 +7200.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +EF +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3425.0 + 20 +0.0 + 10 +3425.0 + 20 +2400.0 + 10 +3575.0 + 20 +2400.0 + 10 +3575.0 + 20 +0.0 + 0 +HATCH + 5 +F0 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3425.0 + 20 +0.0 + 10 +3425.0 + 20 +2400.0 + 10 +3575.0 + 20 +2400.0 + 10 +3575.0 + 20 +0.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +F1 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +3425.0 + 20 +3300.0 + 10 +3425.0 + 20 +4200.0 + 10 +3575.0 + 20 +4200.0 + 10 +3575.0 + 20 +3300.0 + 0 +HATCH + 5 +F2 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +3425.0 + 20 +3300.0 + 10 +3425.0 + 20 +4200.0 + 10 +3575.0 + 20 +4200.0 + 10 +3575.0 + 20 +3300.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +F3 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3425.0 + 20 +2400.0 + 30 +0.0 + 11 +3575.0 + 21 +2400.0 + 31 +0.0 + 0 +LINE + 5 +F4 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +3425.0 + 20 +3300.0 + 30 +0.0 + 11 +3575.0 + 21 +3300.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +F5 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6200.0 + 20 +4275.0 + 10 +10000.0 + 20 +4275.0 + 10 +10000.0 + 20 +4125.0 + 10 +6200.0 + 20 +4125.0 + 0 +HATCH + 5 +F6 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +6200.0 + 20 +4275.0 + 10 +10000.0 + 20 +4275.0 + 10 +10000.0 + 20 +4125.0 + 10 +6200.0 + 20 +4125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +F7 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +8125.0 + 20 +4200.0 + 10 +8125.0 + 20 +5800.0 + 10 +8275.0 + 20 +5800.0 + 10 +8275.0 + 20 +4200.0 + 0 +HATCH + 5 +F8 +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +8125.0 + 20 +4200.0 + 10 +8125.0 + 20 +5800.0 + 10 +8275.0 + 20 +5800.0 + 10 +8275.0 + 20 +4200.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +F9 +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +8125.0 + 20 +6700.0 + 10 +8125.0 + 20 +8000.0 + 10 +8275.0 + 20 +8000.0 + 10 +8275.0 + 20 +6700.0 + 0 +HATCH + 5 +FA +330 +17 +100 +AcDbEntity + 8 +A-WALL-PAT + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +8125.0 + 20 +6700.0 + 10 +8125.0 + 20 +8000.0 + 10 +8275.0 + 20 +8000.0 + 10 +8275.0 + 20 +6700.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +40.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-89.802561212 + 46 +89.802561212 + 79 +0 + 98 +0 + 0 +LINE + 5 +FB +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +8125.0 + 20 +5800.0 + 30 +0.0 + 11 +8275.0 + 21 +5800.0 + 31 +0.0 + 0 +LINE + 5 +FC +330 +17 +100 +AcDbEntity + 8 +A-WALL +100 +AcDbLine + 10 +8125.0 + 20 +6700.0 + 30 +0.0 + 11 +8275.0 + 21 +6700.0 + 31 +0.0 + 0 +INSERT + 5 +FD +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +1700.0 + 20 +150.0 + 30 +0.0 + 0 +ATTRIB + 5 +FF +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +2050.0 + 20 +-180.0 + 30 +0.0 + 40 +110.0 + 1 +D-101 + 11 +2050.0 + 21 +-180.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 11 +2050.0 + 21 +-180.0 + 31 +0.0 + 0 +SEQEND + 5 +FE +330 +FD +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +100 +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +6275.0 + 20 +1700.0 + 30 +0.0 + 50 +90.0 + 0 +ATTRIB + 5 +102 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +6605.0 + 20 +2050.0 + 30 +0.0 + 40 +110.0 + 1 +D-102 + 50 +90.0 + 11 +6605.0 + 21 +2050.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 11 +6605.0 + 21 +2050.0 + 31 +0.0 + 0 +SEQEND + 5 +101 +330 +100 +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +103 +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +3575.0 + 20 +2400.0 + 30 +0.0 + 50 +90.0 + 0 +ATTRIB + 5 +105 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +3905.0 + 20 +2750.0 + 30 +0.0 + 40 +110.0 + 1 +D-103 + 50 +90.0 + 11 +3905.0 + 21 +2750.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 11 +3905.0 + 21 +2750.0 + 31 +0.0 + 0 +SEQEND + 5 +104 +330 +103 +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +106 +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +700.0 + 20 +4275.0 + 30 +0.0 + 42 +-1.0 + 0 +ATTRIB + 5 +108 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +-1050.0 + 20 +4605.0 + 30 +0.0 + 40 +110.0 + 1 +D-104 + 50 +180.0 + 11 +-1050.0 + 21 +4605.0 + 31 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 11 +-1050.0 + 21 +4605.0 + 31 +0.0 + 0 +SEQEND + 5 +107 +330 +106 +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +109 +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +2800.0 + 20 +4275.0 + 30 +0.0 + 0 +ATTRIB + 5 +10B +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +3150.0 + 20 +3945.0 + 30 +0.0 + 40 +110.0 + 1 +D-105 + 11 +3150.0 + 21 +3945.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 11 +3150.0 + 21 +3945.0 + 31 +0.0 + 0 +SEQEND + 5 +10A +330 +109 +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +10C +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +4900.0 + 20 +4275.0 + 30 +0.0 + 42 +-1.0 + 0 +ATTRIB + 5 +10E +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +-5250.0 + 20 +4605.0 + 30 +0.0 + 40 +110.0 + 1 +D-106 + 50 +180.0 + 11 +-5250.0 + 21 +4605.0 + 31 +0.0 +210 +0.0 +220 +0.0 +230 +-1.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 11 +-5250.0 + 21 +4605.0 + 31 +0.0 + 0 +SEQEND + 5 +10D +330 +10C +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +10F +330 +17 +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbBlockReference + 66 +1 + 2 +DOOR + 10 +8275.0 + 20 +5800.0 + 30 +0.0 + 50 +90.0 + 0 +ATTRIB + 5 +111 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +8605.0 + 20 +6150.0 + 30 +0.0 + 40 +110.0 + 1 +D-107 + 50 +90.0 + 11 +8605.0 + 21 +6150.0 + 31 +0.0 +100 +AcDbAttribute + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 11 +8605.0 + 21 +6150.0 + 31 +0.0 + 0 +SEQEND + 5 +110 +330 +10F +100 +AcDbEntity + 8 +A-DOOR + 0 +INSERT + 5 +112 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +3600.0 + 20 +-150.0 + 30 +0.0 + 41 +900.0 + 42 +300.0 + 0 +INSERT + 5 +114 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +6200.0 + 20 +-150.0 + 30 +0.0 + 41 +900.0 + 42 +300.0 + 0 +INSERT + 5 +116 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +8200.0 + 20 +-150.0 + 30 +0.0 + 41 +1200.0 + 42 +300.0 + 0 +INSERT + 5 +118 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +10150.0 + 20 +4800.0 + 30 +0.0 + 41 +1300.0 + 42 +300.0 + 50 +90.0 + 0 +INSERT + 5 +11A +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +1200.0 + 20 +7850.0 + 30 +0.0 + 41 +1400.0 + 42 +300.0 + 0 +INSERT + 5 +11C +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +3600.0 + 20 +7850.0 + 30 +0.0 + 41 +1600.0 + 42 +300.0 + 0 +INSERT + 5 +11E +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +6600.0 + 20 +7850.0 + 30 +0.0 + 41 +1200.0 + 42 +300.0 + 0 +INSERT + 5 +120 +330 +17 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbBlockReference + 2 +WINDOW + 10 +150.0 + 20 +5000.0 + 30 +0.0 + 41 +1400.0 + 42 +300.0 + 50 +90.0 + 0 +INSERT + 5 +122 +330 +17 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbBlockReference + 2 +SOFA + 10 +1200.0 + 20 +6900.0 + 30 +0.0 + 0 +INSERT + 5 +124 +330 +17 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbBlockReference + 2 +TABLE + 10 +3900.0 + 20 +5300.0 + 30 +0.0 + 0 +INSERT + 5 +126 +330 +17 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbBlockReference + 2 +BED + 10 +4300.0 + 20 +400.0 + 30 +0.0 + 0 +INSERT + 5 +128 +330 +17 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbBlockReference + 2 +BED + 10 +500.0 + 20 +600.0 + 30 +0.0 + 41 +0.8 + 42 +0.9 + 0 +INSERT + 5 +12A +330 +17 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbBlockReference + 2 +BATH + 10 +6800.0 + 20 +300.0 + 30 +0.0 + 0 +INSERT + 5 +12C +330 +17 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbBlockReference + 2 +WC + 10 +8300.0 + 20 +3200.0 + 30 +0.0 + 50 +90.0 + 0 +INSERT + 5 +12E +330 +17 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbBlockReference + 2 +SINK + 10 +6500.0 + 20 +3350.0 + 30 +0.0 + 0 +LINE + 5 +130 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +4400.0 + 30 +0.0 + 11 +7400.0 + 21 +4400.0 + 31 +0.0 + 0 +LINE + 5 +131 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +4680.0 + 30 +0.0 + 11 +7400.0 + 21 +4680.0 + 31 +0.0 + 0 +LINE + 5 +132 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +4960.0 + 30 +0.0 + 11 +7400.0 + 21 +4960.0 + 31 +0.0 + 0 +LINE + 5 +133 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +5240.0 + 30 +0.0 + 11 +7400.0 + 21 +5240.0 + 31 +0.0 + 0 +LINE + 5 +134 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +5520.0 + 30 +0.0 + 11 +7400.0 + 21 +5520.0 + 31 +0.0 + 0 +LINE + 5 +135 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +5800.0 + 30 +0.0 + 11 +7400.0 + 21 +5800.0 + 31 +0.0 + 0 +LINE + 5 +136 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +6080.0 + 30 +0.0 + 11 +7400.0 + 21 +6080.0 + 31 +0.0 + 0 +LINE + 5 +137 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +6360.0 + 30 +0.0 + 11 +7400.0 + 21 +6360.0 + 31 +0.0 + 0 +LINE + 5 +138 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +6640.0 + 30 +0.0 + 11 +7400.0 + 21 +6640.0 + 31 +0.0 + 0 +LINE + 5 +139 +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +6920.0 + 30 +0.0 + 11 +7400.0 + 21 +6920.0 + 31 +0.0 + 0 +LINE + 5 +13A +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +7200.0 + 30 +0.0 + 11 +7400.0 + 21 +7200.0 + 31 +0.0 + 0 +LINE + 5 +13B +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLine + 10 +6400.0 + 20 +7480.0 + 30 +0.0 + 11 +7400.0 + 21 +7480.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +13C +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +6400.0 + 20 +4400.0 + 10 +7400.0 + 20 +4400.0 + 10 +7400.0 + 20 +7760.0 + 10 +6400.0 + 20 +7760.0 + 0 +LWPOLYLINE + 5 +13D +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +6900.0 + 20 +4500.0 + 10 +6900.0 + 20 +7700.0 + 0 +LEADER + 5 +13E +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbLeader + 3 +Standard + 73 +3 + 76 +3 + 10 +6900.0 + 20 +7700.0 + 30 +0.0 + 10 +6820.0 + 20 +7400.0 + 30 +0.0 + 10 +6980.0 + 20 +7400.0 + 30 +0.0 + 0 +TEXT + 5 +13F +330 +17 +100 +AcDbEntity + 8 +A-STAIR +100 +AcDbText + 10 +6950.0 + 20 +4600.0 + 30 +0.0 + 40 +150.0 + 1 +UP + 7 +ARCH-TEXT + 11 +6950.0 + 21 +4600.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +140 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +0.0 + 20 +-1700.0 + 30 +0.0 + 11 +0.0 + 21 +9200.0 + 31 +0.0 + 0 +INSERT + 5 +141 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +0.0 + 20 +-2000.0 + 30 +0.0 + 0 +ATTRIB + 5 +143 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +0.0 + 20 +-2000.0 + 30 +0.0 + 40 +300.0 + 1 +1 + 72 +4 + 11 +0.0 + 21 +-2000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +0.0 + 21 +-2000.0 + 31 +0.0 + 0 +SEQEND + 5 +142 +330 +141 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +144 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +3500.0 + 20 +-1700.0 + 30 +0.0 + 11 +3500.0 + 21 +9200.0 + 31 +0.0 + 0 +INSERT + 5 +145 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +3500.0 + 20 +-2000.0 + 30 +0.0 + 0 +ATTRIB + 5 +147 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +3500.0 + 20 +-2000.0 + 30 +0.0 + 40 +300.0 + 1 +2 + 72 +4 + 11 +3500.0 + 21 +-2000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +3500.0 + 21 +-2000.0 + 31 +0.0 + 0 +SEQEND + 5 +146 +330 +145 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +148 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +6200.0 + 20 +-1700.0 + 30 +0.0 + 11 +6200.0 + 21 +9200.0 + 31 +0.0 + 0 +INSERT + 5 +149 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +6200.0 + 20 +-2000.0 + 30 +0.0 + 0 +ATTRIB + 5 +14B +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +6200.0 + 20 +-2000.0 + 30 +0.0 + 40 +300.0 + 1 +3 + 72 +4 + 11 +6200.0 + 21 +-2000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +6200.0 + 21 +-2000.0 + 31 +0.0 + 0 +SEQEND + 5 +14A +330 +149 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +14C +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +10000.0 + 20 +-1700.0 + 30 +0.0 + 11 +10000.0 + 21 +9200.0 + 31 +0.0 + 0 +INSERT + 5 +14D +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +10000.0 + 20 +-2000.0 + 30 +0.0 + 0 +ATTRIB + 5 +14F +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +10000.0 + 20 +-2000.0 + 30 +0.0 + 40 +300.0 + 1 +4 + 72 +4 + 11 +10000.0 + 21 +-2000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +10000.0 + 21 +-2000.0 + 31 +0.0 + 0 +SEQEND + 5 +14E +330 +14D +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +150 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +-1700.0 + 20 +0.0 + 30 +0.0 + 11 +11200.0 + 21 +0.0 + 31 +0.0 + 0 +INSERT + 5 +151 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +-2000.0 + 20 +0.0 + 30 +0.0 + 0 +ATTRIB + 5 +153 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +-2000.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 1 +A + 72 +4 + 11 +-2000.0 + 21 +0.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +-2000.0 + 21 +0.0 + 31 +0.0 + 0 +SEQEND + 5 +152 +330 +151 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +154 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +-1700.0 + 20 +4200.0 + 30 +0.0 + 11 +11200.0 + 21 +4200.0 + 31 +0.0 + 0 +INSERT + 5 +155 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +-2000.0 + 20 +4200.0 + 30 +0.0 + 0 +ATTRIB + 5 +157 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +-2000.0 + 20 +4200.0 + 30 +0.0 + 40 +300.0 + 1 +B + 72 +4 + 11 +-2000.0 + 21 +4200.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +-2000.0 + 21 +4200.0 + 31 +0.0 + 0 +SEQEND + 5 +156 +330 +155 +100 +AcDbEntity + 8 +A-GRID + 0 +LINE + 5 +158 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +-1700.0 + 20 +8000.0 + 30 +0.0 + 11 +11200.0 + 21 +8000.0 + 31 +0.0 + 0 +INSERT + 5 +159 +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbBlockReference + 66 +1 + 2 +AXIS + 10 +-2000.0 + 20 +8000.0 + 30 +0.0 + 0 +ATTRIB + 5 +15B +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +-2000.0 + 20 +8000.0 + 30 +0.0 + 40 +300.0 + 1 +C + 72 +4 + 11 +-2000.0 + 21 +8000.0 + 31 +0.0 +100 +AcDbAttribute + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +-2000.0 + 21 +8000.0 + 31 +0.0 + 0 +SEQEND + 5 +15A +330 +159 +100 +AcDbEntity + 8 +A-GRID + 0 +MTEXT + 5 +15C +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +1750.0 + 20 +2100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;BEDROOM 1\P\fArial|b0;14.7 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +15D +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +4850.0 + 20 +2100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;BEDROOM 2\P\fArial|b0;11.3 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +15E +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +7500.0 + 20 +2100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;BATH\P\fArial|b0;16.0 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +15F +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +3100.0 + 20 +6100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;LIVING ROOM\P\fArial|b0;23.6 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +160 +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +7150.0 + 20 +6100.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;KITCHEN\P\fArial|b0;14.4 m² + 7 +ARCH-TEXT + 0 +MTEXT + 5 +161 +330 +17 +100 +AcDbEntity + 8 +A-AREA +100 +AcDbMText + 10 +1500.0 + 20 +4650.0 + 30 +0.0 + 40 +260.0 + 41 +2600.0 + 71 +5 + 1 +\fArial|b1;HALL + 7 +ARCH-TEXT + 0 +DIMENSION + 5 +162 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D1 + 3 +PLAN50 + 10 +0.0 + 20 +-3100.0 + 30 +0.0 + 11 +5000.0 + 21 +-2924.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +10000.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +175 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D2 + 3 +PLAN50 + 10 +0.0 + 20 +-2600.0 + 30 +0.0 + 11 +1750.0 + 21 +-2424.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +3500.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +184 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D3 + 3 +PLAN50 + 10 +3500.0 + 20 +-2600.0 + 30 +0.0 + 11 +4850.0 + 21 +-2424.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +3500.0 + 23 +0.0 + 33 +0.0 + 14 +6200.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +193 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D4 + 3 +PLAN50 + 10 +6200.0 + 20 +-2600.0 + 30 +0.0 + 11 +8100.0 + 21 +-2424.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +6200.0 + 23 +0.0 + 33 +0.0 + 14 +10000.0 + 24 +0.0 + 34 +0.0 + 50 +0.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +1A2 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D5 + 3 +PLAN50 + 10 +-3100.0 + 20 +0.0 + 30 +0.0 + 11 +-3276.0 + 21 +4000.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +0.0 + 24 +8000.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +1B1 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D6 + 3 +PLAN50 + 10 +-2600.0 + 20 +0.0 + 30 +0.0 + 11 +-2776.0 + 21 +2100.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +0.0 + 33 +0.0 + 14 +0.0 + 24 +4200.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +DIMENSION + 5 +1C0 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D7 + 3 +PLAN50 + 10 +-2600.0 + 20 +4200.0 + 30 +0.0 + 11 +-2776.0 + 21 +6100.0 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +0.0 + 23 +4200.0 + 33 +0.0 + 14 +0.0 + 24 +8000.0 + 34 +0.0 + 50 +90.0 +100 +AcDbRotatedDimension + 0 +LWPOLYLINE + 5 +1CF +330 +17 +100 +AcDbEntity + 8 +A-WALL + 6 +DASHED +100 +AcDbPolyline + 90 +2 + 70 +0 + 10 +11500.0 + 20 +500.0 + 10 +14500.0 + 20 +3500.0 + 0 +DIMENSION + 5 +1D0 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D8 + 3 +PLAN50 + 10 +11994.974746830583 + 20 +5.025253169416828 + 30 +0.0 + 11 +13370.523953341752 + 21 +1629.4760466582493 + 31 +0.0 + 70 +32 + 71 +5 + 1 +<> +100 +AcDbAlignedDimension + 13 +11500.0 + 23 +500.0 + 33 +0.0 + 14 +14500.0 + 24 +3500.0 + 34 +0.0 + 50 +45.0 +100 +AcDbRotatedDimension + 0 +LINE + 5 +1DF +330 +17 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbLine + 10 +11500.0 + 20 +500.0 + 30 +0.0 + 11 +14500.0 + 21 +500.0 + 31 +0.0 + 0 +DIMENSION + 5 +1E0 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D9 + 3 +PLAN50 + 10 +14500.0 + 20 +500.0 + 30 +0.0 + 11 +12546.405475591537 + 21 +933.4353397314832 + 31 +0.0 + 70 +34 + 71 +5 + 1 +<> +100 +AcDb2LineAngularDimension + 13 +11500.0 + 23 +500.0 + 33 +0.0 + 14 +14500.0 + 24 +3500.0 + 34 +0.0 + 15 +11500.0 + 25 +500.0 + 35 +0.0 + 16 +12300.0 + 26 +700.0 + 36 +0.0 + 0 +DIMENSION + 5 +1F3 +330 +17 +100 +AcDbEntity + 8 +A-DIMS +100 +AcDbDimension +280 +0 + 2 +*D10 + 3 +PLAN50 + 10 +6275.0 + 20 +1700.0 + 30 +0.0 + 11 +7269.192134348285 + 21 +2943.093721325951 + 31 +0.0 + 70 +36 + 71 +5 + 1 +<> +100 +AcDbRadialDimension + 15 +6911.396103067893 + 25 +2336.396103067893 + 35 +0.0 + 0 +INSERT + 5 +1FE +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 2 +NORTH + 10 +12200.0 + 20 +8700.0 + 30 +0.0 + 0 +INSERT + 5 +200 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbBlockReference + 66 +1 + 2 +LEVEL + 10 +5000.0 + 20 +3400.0 + 30 +0.0 + 0 +ATTRIB + 5 +202 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +5320.0 + 20 +3690.0 + 30 +0.0 + 40 +120.0 + 1 +±0.00 + 11 +5320.0 + 21 +3690.0 + 31 +0.0 +100 +AcDbAttribute + 2 +H + 70 +0 + 71 +1 + 72 +0 + 11 +5320.0 + 21 +3690.0 + 31 +0.0 + 0 +SEQEND + 5 +201 +330 +200 +100 +AcDbEntity + 8 +A-ANNO + 0 +TEXT + 5 +203 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +-4700.0 + 30 +0.0 + 40 +330.0 + 1 +GROUND FLOOR PLAN 1:50 + 7 +ARCH-TITLE + 11 +0.0 + 21 +-4700.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +204 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +370 +70 +100 +AcDbLine + 10 +0.0 + 20 +-4900.0 + 30 +0.0 + 11 +4500.0 + 21 +-4900.0 + 31 +0.0 + 0 +TEXT + 5 +205 +330 +17 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +-5200.0 + 30 +0.0 + 40 +130.0 + 1 +Drawn for the exav demo. Dimensions in millimetres. + 7 +ARCH-NOTE + 11 +0.0 + 21 +-5200.0 + 31 +0.0 +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 3 +A3 Plan +350 +209 + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +217 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +LAYOUT + 5 +209 +330 +D +100 +AcDbPlotSettings + 1 + + 2 +DWG to PDF.pc3 + 4 +ezdxf_(420.00_x_297.00_MM) + 6 + + 40 +10.0 + 41 +10.0 + 42 +10.0 + 43 +10.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +672 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +A3 Plan + 70 +1 + 71 +3 + 10 +-10.0 + 20 +-10.0 + 11 +410.0 + 21 +287.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +206 +331 +20A + 0 +DICTIONARYVAR + 5 +217 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/legacy-r12.dxf b/crates/exav-viewer/demo/examples/files/cad/legacy-r12.dxf new file mode 100644 index 00000000..c7210ca2 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/legacy-r12.dxf @@ -0,0 +1,2602 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1009 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$DRAGMODE + 70 +2 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$OSMODE + 70 +20583 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$COORDS + 70 +1 + 9 +$ATTDIA + 70 +0 + 9 +$ATTREQ + 70 +1 + 9 +$HANDLING + 70 +1 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +A1 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 70 +1 + 0 +VPORT + 5 +9E + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +10.0 + 22 +-7.427997320830542 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +120.59879437374414 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 70 +3 + 0 +LTYPE + 5 +24 + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 70 +6 + 0 +LAYER + 5 +27 + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous + 0 +LAYER + 5 +28 + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous + 0 +LAYER + 5 +2D + 2 +GEAR + 70 +0 + 62 +7 + 6 +Continuous + 0 +LAYER + 5 +2E + 2 +AXES + 70 +0 + 62 +1 + 6 +Continuous + 0 +LAYER + 5 +2F + 2 +TEXT + 70 +0 + 62 +2 + 6 +Continuous + 0 +LAYER + 5 +30 + 2 +DIMS + 70 +0 + 62 +3 + 6 +Continuous + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 70 +1 + 0 +STYLE + 5 +29 + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 70 +3 + 0 +APPID + 5 +2A + 2 +ACAD + 70 +0 + 0 +APPID + 5 +9F + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +A0 + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 70 +1 + 0 +DIMSTYLE +105 +2B + 2 +Standard + 70 +0 + 3 + + 4 + + 5 + + 6 + + 7 + + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 + 8 +0 + 2 +$Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +$Model_Space + 1 + +1001 +EZDXF +1000 +CREATED_BY_EZDXF +1000 +0.0 @ 2000-01-01T00:00:00.000000+00:00 +1000 +WRITTEN_BY_EZDXF +1000 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDBLK + 5 +19 + 8 +0 + 0 +BLOCK + 5 +1C + 8 +0 + 2 +$Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +$Paper_Space + 1 + + 0 +ENDBLK + 5 +1D + 8 +0 + 0 +BLOCK + 5 +94 + 8 +0 + 2 +TAG + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +TAG + 1 + + 0 +CIRCLE + 5 +96 + 8 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +5.0 + 0 +TEXT + 5 +97 + 8 +0 + 10 +-1.5 + 20 +-2.0 + 30 +0.0 + 40 +5.0 + 1 +1 + 11 +-1.5 + 21 +-2.0 + 31 +0.0 + 0 +ENDBLK + 5 +95 + 8 +0 + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +POLYLINE + 5 +31 + 8 +GEAR + 66 +1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 70 +1 + 0 +VERTEX + 5 +33 + 8 +GEAR + 10 +33.0 + 20 +0.0 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +34 + 8 +GEAR + 10 +39.921069137130864 + 20 +2.511620781172535 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +35 + 8 +GEAR + 10 +39.404373046190955 + 20 +6.877164011176382 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +36 + 8 +GEAR + 10 +32.088207373123325 + 20 +7.703697007244878 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +37 + 8 +GEAR + 10 +31.384865037740067 + 20 +10.197560814373263 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +38 + 8 +GEAR + 10 +37.19105943553006 + 20 +14.724982107387117 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +39 + 8 +GEAR + 10 +35.350625203547736 + 20 +18.717192570422934 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +3A + 8 +GEAR + 10 +28.137125423685042 + 20 +17.24245263562631 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +3B + 8 +GEAR + 10 +26.697560814373265 + 20 +19.396913325651614 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +3C + 8 +GEAR + 10 +30.82052971103157 + 20 +25.496959589947586 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +3D + 8 +GEAR + 10 +27.83651186369257 + 20 +28.725051910527554 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +3E + 8 +GEAR + 10 +21.43178559489606 + 20 +25.093396864801022 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +3F + 8 +GEAR + 10 +19.396913325651614 + 20 +26.697560814373265 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +40 + 8 +GEAR + 10 +21.433071799159862 + 20 +33.773117020080605 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +41 + 8 +GEAR + 10 +17.59756679423661 + 20 +35.92110303042462 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +42 + 8 +GEAR + 10 +12.628553268047964 + 20 +30.48802457287246 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +43 + 8 +GEAR + 10 +10.197560814373265 + 20 +31.384865037740067 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +44 + 8 +GEAR + 10 +9.947595486594189 + 20 +38.74332644514524 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +45 + 8 +GEAR + 10 +5.636049277503313 + 20 +39.6009463086623 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +46 + 8 +GEAR + 10 +2.589150159018885 + 20 +32.89827201319322 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +47 + 8 +GEAR + 10 +2.0206672185931327e-15 + 20 +33.0 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +48 + 8 +GEAR + 10 +-2.511620781172536 + 20 +39.921069137130864 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +49 + 8 +GEAR + 10 +-6.877164011176376 + 20 +39.40437304619096 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +4A + 8 +GEAR + 10 +-7.703697007244876 + 20 +32.08820737312333 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +4B + 8 +GEAR + 10 +-10.197560814373261 + 20 +31.38486503774007 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +4C + 8 +GEAR + 10 +-14.724982107387119 + 20 +37.19105943553005 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +4D + 8 +GEAR + 10 +-18.71719257042293 + 20 +35.350625203547736 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +4E + 8 +GEAR + 10 +-17.24245263562631 + 20 +28.137125423685045 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +4F + 8 +GEAR + 10 +-19.39691332565161 + 20 +26.697560814373265 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +50 + 8 +GEAR + 10 +-25.49695958994759 + 20 +30.82052971103157 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +51 + 8 +GEAR + 10 +-28.725051910527547 + 20 +27.83651186369258 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +52 + 8 +GEAR + 10 +-25.093396864801022 + 20 +21.431785594896063 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +53 + 8 +GEAR + 10 +-26.69756081437326 + 20 +19.396913325651617 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +54 + 8 +GEAR + 10 +-33.773117020080605 + 20 +21.433071799159865 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +55 + 8 +GEAR + 10 +-35.92110303042462 + 20 +17.597566794236613 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +56 + 8 +GEAR + 10 +-30.48802457287246 + 20 +12.628553268047966 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +57 + 8 +GEAR + 10 +-31.384865037740067 + 20 +10.197560814373269 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +58 + 8 +GEAR + 10 +-38.74332644514524 + 20 +9.947595486594192 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +59 + 8 +GEAR + 10 +-39.6009463086623 + 20 +5.636049277503314 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +5A + 8 +GEAR + 10 +-32.89827201319322 + 20 +2.5891501590188875 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +5B + 8 +GEAR + 10 +-33.0 + 20 +4.0413344371862654e-15 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +5C + 8 +GEAR + 10 +-39.921069137130864 + 20 +-2.511620781172534 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +5D + 8 +GEAR + 10 +-39.40437304619096 + 20 +-6.877164011176373 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +5E + 8 +GEAR + 10 +-32.08820737312333 + 20 +-7.703697007244874 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +5F + 8 +GEAR + 10 +-31.384865037740074 + 20 +-10.197560814373247 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +60 + 8 +GEAR + 10 +-37.191059435530065 + 20 +-14.724982107387099 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +61 + 8 +GEAR + 10 +-35.35062520354775 + 20 +-18.71719257042291 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +62 + 8 +GEAR + 10 +-28.137125423685053 + 20 +-17.242452635626297 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +63 + 8 +GEAR + 10 +-26.69756081437327 + 20 +-19.39691332565161 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +64 + 8 +GEAR + 10 +-30.82052971103157 + 20 +-25.496959589947586 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +65 + 8 +GEAR + 10 +-27.836511863692582 + 20 +-28.725051910527547 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +66 + 8 +GEAR + 10 +-21.431785594896077 + 20 +-25.093396864801008 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +67 + 8 +GEAR + 10 +-19.396913325651617 + 20 +-26.69756081437326 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +68 + 8 +GEAR + 10 +-21.433071799159883 + 20 +-33.77311702008059 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +69 + 8 +GEAR + 10 +-17.597566794236613 + 20 +-35.92110303042462 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +6A + 8 +GEAR + 10 +-12.62855326804798 + 20 +-30.488024572872455 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +6B + 8 +GEAR + 10 +-10.19756081437327 + 20 +-31.384865037740067 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +6C + 8 +GEAR + 10 +-9.947595486594212 + 20 +-38.743326445145236 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +6D + 8 +GEAR + 10 +-5.636049277503316 + 20 +-39.6009463086623 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +6E + 8 +GEAR + 10 +-2.589150159018904 + 20 +-32.89827201319322 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +6F + 8 +GEAR + 10 +-6.062001655779398e-15 + 20 +-33.0 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +70 + 8 +GEAR + 10 +2.511620781172513 + 20 +-39.921069137130864 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +71 + 8 +GEAR + 10 +6.8771640111763706 + 20 +-39.40437304619096 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +72 + 8 +GEAR + 10 +7.703697007244858 + 20 +-32.08820737312333 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +73 + 8 +GEAR + 10 +10.197560814373258 + 20 +-31.38486503774007 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +74 + 8 +GEAR + 10 +14.724982107387097 + 20 +-37.191059435530065 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +75 + 8 +GEAR + 10 +18.717192570422924 + 20 +-35.35062520354774 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +76 + 8 +GEAR + 10 +17.242452635626297 + 20 +-28.137125423685053 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +77 + 8 +GEAR + 10 +19.396913325651607 + 20 +-26.69756081437327 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +78 + 8 +GEAR + 10 +25.496959589947572 + 20 +-30.820529711031583 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +79 + 8 +GEAR + 10 +28.725051910527544 + 20 +-27.836511863692582 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +7A + 8 +GEAR + 10 +25.093396864801004 + 20 +-21.43178559489608 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +7B + 8 +GEAR + 10 +26.69756081437326 + 20 +-19.39691332565162 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +7C + 8 +GEAR + 10 +33.77311702008059 + 20 +-21.433071799159883 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +7D + 8 +GEAR + 10 +35.92110303042462 + 20 +-17.597566794236617 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +7E + 8 +GEAR + 10 +30.488024572872455 + 20 +-12.628553268047982 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +7F + 8 +GEAR + 10 +31.384865037740067 + 20 +-10.197560814373272 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +80 + 8 +GEAR + 10 +38.743326445145236 + 20 +-9.947595486594214 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +81 + 8 +GEAR + 10 +39.6009463086623 + 20 +-5.63604927750332 + 30 +0.0 + 70 +0 + 0 +VERTEX + 5 +82 + 8 +GEAR + 10 +32.89827201319322 + 20 +-2.5891501590189057 + 30 +0.0 + 70 +0 + 0 +SEQEND + 5 +32 + 8 +GEAR + 0 +CIRCLE + 5 +83 + 8 +GEAR + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +10.0 + 0 +CIRCLE + 5 +84 + 8 +GEAR + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +4.0 + 0 +CIRCLE + 5 +85 + 8 +GEAR + 10 +20.0 + 20 +0.0 + 30 +0.0 + 40 +4.0 + 0 +CIRCLE + 5 +86 + 8 +GEAR + 10 +10.000000000000002 + 20 +17.32050807568877 + 30 +0.0 + 40 +4.0 + 0 +CIRCLE + 5 +87 + 8 +GEAR + 10 +-9.999999999999996 + 20 +17.320508075688775 + 30 +0.0 + 40 +4.0 + 0 +CIRCLE + 5 +88 + 8 +GEAR + 10 +-20.0 + 20 +2.4492935982947065e-15 + 30 +0.0 + 40 +4.0 + 0 +CIRCLE + 5 +89 + 8 +GEAR + 10 +-10.000000000000009 + 20 +-17.320508075688767 + 30 +0.0 + 40 +4.0 + 0 +CIRCLE + 5 +8A + 8 +GEAR + 10 +10.000000000000002 + 20 +-17.32050807568877 + 30 +0.0 + 40 +4.0 + 0 +LINE + 5 +8B + 8 +AXES + 10 +-50.0 + 20 +0.0 + 30 +0.0 + 11 +50.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +8C + 8 +AXES + 10 +0.0 + 20 +-50.0 + 30 +0.0 + 11 +0.0 + 21 +50.0 + 31 +0.0 + 0 +ARC + 5 +8D + 8 +GEAR + 62 +4 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +30.0 + 50 +30.0 + 51 +150.0 + 0 +TEXT + 5 +8E + 8 +TEXT + 10 +-45.0 + 20 +-58.0 + 30 +0.0 + 40 +4.0 + 1 +GEAR, 20 TEETH (R12 FILE) + 11 +-45.0 + 21 +-58.0 + 31 +0.0 + 0 +TEXT + 5 +8F + 8 +TEXT + 10 +-45.0 + 20 +-64.0 + 30 +0.0 + 40 +3.0 + 1 +module 4, pitch diameter 80 + 11 +-45.0 + 21 +-64.0 + 31 +0.0 + 0 +POINT + 5 +90 + 8 +AXES + 10 +0.0 + 20 +0.0 + 30 +0.0 + 0 +SOLID + 5 +91 + 8 +DIMS + 10 +55.0 + 20 +40.0 + 30 +0.0 + 11 +65.0 + 21 +40.0 + 31 +0.0 + 12 +55.0 + 22 +46.0 + 32 +0.0 + 13 +65.0 + 23 +46.0 + 33 +0.0 + 0 +3DFACE + 5 +92 + 8 +DIMS + 62 +5 + 10 +55.0 + 20 +-10.0 + 30 +0.0 + 11 +70.0 + 21 +-10.0 + 31 +0.0 + 12 +70.0 + 22 +5.0 + 32 +0.0 + 13 +55.0 + 23 +5.0 + 33 +0.0 + 0 +INSERT + 5 +98 + 8 +DIMS + 2 +TAG + 10 +60.0 + 20 +20.0 + 30 +0.0 + 0 +INSERT + 5 +9A + 8 +DIMS + 2 +TAG + 10 +60.0 + 20 +5.0 + 30 +0.0 + 41 +1.3 + 42 +1.3 + 0 +INSERT + 5 +9C + 8 +DIMS + 2 +TAG + 10 +60.0 + 20 +-10.0 + 30 +0.0 + 41 +1.6 + 42 +1.6 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/linetypes.dxf b/crates/exav-viewer/demo/examples/files/cad/linetypes.dxf new file mode 100644 index 00000000..72ce2da6 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/linetypes.dxf @@ -0,0 +1,7112 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1032 + 9 +$ACADMAINTVER + 90 +4 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$REQUIREDVERSIONS +160 +0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +B3 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +AF +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +165.9919624916276 + 22 +-187.56625834024877 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +398.08914251452245 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +25 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +42 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +EXAV_DASH3 + 70 +0 + 3 +Dash and three dots + 72 +65 + 73 +6 + 40 +10.0 + 49 +6.0 + 74 +0 + 49 +-3.0 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-3.0 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-3.0 + 74 +0 + 0 +LTYPE + 5 +43 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +GAS_LINE + 70 +0 + 3 +Gas line ---- GAS ---- + 72 +65 + 73 +3 + 40 +0.0 + 49 +5.0 + 74 +0 + 49 +-2.0 + 74 +2 + 75 +0 +340 +41 + 46 +1.5 + 50 +0.0 + 44 +-1.0 + 45 +-0.75 + 9 +GAS + 49 +-4.0 + 74 +0 + 0 +LTYPE + 5 +44 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +HOT_WATER + 70 +0 + 3 +Hot water ---- HW ---- + 72 +65 + 73 +3 + 40 +0.0 + 49 +6.0 + 74 +0 + 49 +-1.5 + 74 +2 + 75 +0 +340 +41 + 46 +1.5 + 50 +0.0 + 44 +-1.0 + 45 +-0.75 + 9 +HW + 49 +-3.0 + 74 +0 + 0 +LTYPE + 5 +45 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +FENCE + 70 +0 + 3 +Fence ---- x ---- + 72 +65 + 73 +3 + 40 +0.0 + 49 +4.0 + 74 +0 + 49 +-2.0 + 74 +2 + 75 +0 +340 +41 + 46 +1.4 + 50 +0.0 + 44 +-0.5 + 45 +-0.7 + 9 +x + 49 +-2.0 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +22 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +46 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-00 + 70 +0 + 62 +1 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +49 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-01 + 70 +0 + 62 +2 + 6 +DASHED +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +4C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-02 + 70 +0 + 62 +3 + 6 +DASHED2 +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +4F +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-03 + 70 +0 + 62 +4 + 6 +DASHEDX2 +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +52 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-04 + 70 +0 + 62 +5 + 6 +DASHED +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +55 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-05 + 70 +0 + 62 +6 + 6 +CENTER +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +58 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-06 + 70 +0 + 62 +7 + 6 +CENTER2 +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +5B +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-07 + 70 +0 + 62 +1 + 6 +CENTERX2 +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +5E +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-08 + 70 +0 + 62 +2 + 6 +PHANTOM +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +61 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-09 + 70 +0 + 62 +3 + 6 +PHANTOM2 +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +64 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-10 + 70 +0 + 62 +4 + 6 +DASHDOT +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +67 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-11 + 70 +0 + 62 +5 + 6 +DASHDOT2 +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +6A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-12 + 70 +0 + 62 +6 + 6 +DOT +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +6D +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-13 + 70 +0 + 62 +7 + 6 +DOT2 +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +70 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-14 + 70 +0 + 62 +1 + 6 +DIVIDE +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +73 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-15 + 70 +0 + 62 +2 + 6 +DIVIDE2 +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +76 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-16 + 70 +0 + 62 +3 + 6 +EXAV_DASH3 +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +79 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-17 + 70 +0 + 62 +4 + 6 +GAS_LINE +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +7C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-18 + 70 +0 + 62 +5 + 6 +HOT_WATER +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +7F +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LT-19 + 70 +0 + 62 +6 + 6 +FENCE +370 +25 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +41 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LT-TEXT + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +B0 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +B1 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +47 +330 +17 +100 +AcDbEntity + 8 +LT-00 +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +220.0 + 21 +0.0 + 31 +0.0 + 0 +TEXT + 5 +48 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-1.0 + 30 +0.0 + 40 +3.0 + 1 +Continuous + 7 +LT-TEXT + 11 +230.0 + 21 +-1.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +4A +330 +17 +100 +AcDbEntity + 8 +LT-01 +100 +AcDbLine + 10 +0.0 + 20 +-8.0 + 30 +0.0 + 11 +220.0 + 21 +-8.0 + 31 +0.0 + 0 +TEXT + 5 +4B +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-9.0 + 30 +0.0 + 40 +3.0 + 1 +DASHED + 7 +LT-TEXT + 11 +230.0 + 21 +-9.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +4D +330 +17 +100 +AcDbEntity + 8 +LT-02 +100 +AcDbLine + 10 +0.0 + 20 +-16.0 + 30 +0.0 + 11 +220.0 + 21 +-16.0 + 31 +0.0 + 0 +TEXT + 5 +4E +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-17.0 + 30 +0.0 + 40 +3.0 + 1 +DASHED2 + 7 +LT-TEXT + 11 +230.0 + 21 +-17.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +50 +330 +17 +100 +AcDbEntity + 8 +LT-03 +100 +AcDbLine + 10 +0.0 + 20 +-24.0 + 30 +0.0 + 11 +220.0 + 21 +-24.0 + 31 +0.0 + 0 +TEXT + 5 +51 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-25.0 + 30 +0.0 + 40 +3.0 + 1 +DASHEDX2 + 7 +LT-TEXT + 11 +230.0 + 21 +-25.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +53 +330 +17 +100 +AcDbEntity + 8 +LT-04 +100 +AcDbLine + 10 +0.0 + 20 +-32.0 + 30 +0.0 + 11 +220.0 + 21 +-32.0 + 31 +0.0 + 0 +TEXT + 5 +54 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-33.0 + 30 +0.0 + 40 +3.0 + 1 +DASHED + 7 +LT-TEXT + 11 +230.0 + 21 +-33.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +56 +330 +17 +100 +AcDbEntity + 8 +LT-05 +100 +AcDbLine + 10 +0.0 + 20 +-40.0 + 30 +0.0 + 11 +220.0 + 21 +-40.0 + 31 +0.0 + 0 +TEXT + 5 +57 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-41.0 + 30 +0.0 + 40 +3.0 + 1 +CENTER + 7 +LT-TEXT + 11 +230.0 + 21 +-41.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +59 +330 +17 +100 +AcDbEntity + 8 +LT-06 +100 +AcDbLine + 10 +0.0 + 20 +-48.0 + 30 +0.0 + 11 +220.0 + 21 +-48.0 + 31 +0.0 + 0 +TEXT + 5 +5A +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-49.0 + 30 +0.0 + 40 +3.0 + 1 +CENTER2 + 7 +LT-TEXT + 11 +230.0 + 21 +-49.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +5C +330 +17 +100 +AcDbEntity + 8 +LT-07 +100 +AcDbLine + 10 +0.0 + 20 +-56.0 + 30 +0.0 + 11 +220.0 + 21 +-56.0 + 31 +0.0 + 0 +TEXT + 5 +5D +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-57.0 + 30 +0.0 + 40 +3.0 + 1 +CENTERX2 + 7 +LT-TEXT + 11 +230.0 + 21 +-57.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +5F +330 +17 +100 +AcDbEntity + 8 +LT-08 +100 +AcDbLine + 10 +0.0 + 20 +-64.0 + 30 +0.0 + 11 +220.0 + 21 +-64.0 + 31 +0.0 + 0 +TEXT + 5 +60 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-65.0 + 30 +0.0 + 40 +3.0 + 1 +PHANTOM + 7 +LT-TEXT + 11 +230.0 + 21 +-65.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +62 +330 +17 +100 +AcDbEntity + 8 +LT-09 +100 +AcDbLine + 10 +0.0 + 20 +-72.0 + 30 +0.0 + 11 +220.0 + 21 +-72.0 + 31 +0.0 + 0 +TEXT + 5 +63 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-73.0 + 30 +0.0 + 40 +3.0 + 1 +PHANTOM2 + 7 +LT-TEXT + 11 +230.0 + 21 +-73.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +65 +330 +17 +100 +AcDbEntity + 8 +LT-10 +100 +AcDbLine + 10 +0.0 + 20 +-80.0 + 30 +0.0 + 11 +220.0 + 21 +-80.0 + 31 +0.0 + 0 +TEXT + 5 +66 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-81.0 + 30 +0.0 + 40 +3.0 + 1 +DASHDOT + 7 +LT-TEXT + 11 +230.0 + 21 +-81.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +68 +330 +17 +100 +AcDbEntity + 8 +LT-11 +100 +AcDbLine + 10 +0.0 + 20 +-88.0 + 30 +0.0 + 11 +220.0 + 21 +-88.0 + 31 +0.0 + 0 +TEXT + 5 +69 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-89.0 + 30 +0.0 + 40 +3.0 + 1 +DASHDOT2 + 7 +LT-TEXT + 11 +230.0 + 21 +-89.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +6B +330 +17 +100 +AcDbEntity + 8 +LT-12 +100 +AcDbLine + 10 +0.0 + 20 +-96.0 + 30 +0.0 + 11 +220.0 + 21 +-96.0 + 31 +0.0 + 0 +TEXT + 5 +6C +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-97.0 + 30 +0.0 + 40 +3.0 + 1 +DOT + 7 +LT-TEXT + 11 +230.0 + 21 +-97.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +6E +330 +17 +100 +AcDbEntity + 8 +LT-13 +100 +AcDbLine + 10 +0.0 + 20 +-104.0 + 30 +0.0 + 11 +220.0 + 21 +-104.0 + 31 +0.0 + 0 +TEXT + 5 +6F +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-105.0 + 30 +0.0 + 40 +3.0 + 1 +DOT2 + 7 +LT-TEXT + 11 +230.0 + 21 +-105.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +71 +330 +17 +100 +AcDbEntity + 8 +LT-14 +100 +AcDbLine + 10 +0.0 + 20 +-112.0 + 30 +0.0 + 11 +220.0 + 21 +-112.0 + 31 +0.0 + 0 +TEXT + 5 +72 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-113.0 + 30 +0.0 + 40 +3.0 + 1 +DIVIDE + 7 +LT-TEXT + 11 +230.0 + 21 +-113.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +74 +330 +17 +100 +AcDbEntity + 8 +LT-15 +100 +AcDbLine + 10 +0.0 + 20 +-120.0 + 30 +0.0 + 11 +220.0 + 21 +-120.0 + 31 +0.0 + 0 +TEXT + 5 +75 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-121.0 + 30 +0.0 + 40 +3.0 + 1 +DIVIDE2 + 7 +LT-TEXT + 11 +230.0 + 21 +-121.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +77 +330 +17 +100 +AcDbEntity + 8 +LT-16 +100 +AcDbLine + 10 +0.0 + 20 +-128.0 + 30 +0.0 + 11 +220.0 + 21 +-128.0 + 31 +0.0 + 0 +TEXT + 5 +78 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-129.0 + 30 +0.0 + 40 +3.0 + 1 +EXAV_DASH3 + 7 +LT-TEXT + 11 +230.0 + 21 +-129.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +7A +330 +17 +100 +AcDbEntity + 8 +LT-17 +100 +AcDbLine + 10 +0.0 + 20 +-136.0 + 30 +0.0 + 11 +220.0 + 21 +-136.0 + 31 +0.0 + 0 +TEXT + 5 +7B +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-137.0 + 30 +0.0 + 40 +3.0 + 1 +GAS_LINE + 7 +LT-TEXT + 11 +230.0 + 21 +-137.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +7D +330 +17 +100 +AcDbEntity + 8 +LT-18 +100 +AcDbLine + 10 +0.0 + 20 +-144.0 + 30 +0.0 + 11 +220.0 + 21 +-144.0 + 31 +0.0 + 0 +TEXT + 5 +7E +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-145.0 + 30 +0.0 + 40 +3.0 + 1 +HOT_WATER + 7 +LT-TEXT + 11 +230.0 + 21 +-145.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +80 +330 +17 +100 +AcDbEntity + 8 +LT-19 +100 +AcDbLine + 10 +0.0 + 20 +-152.0 + 30 +0.0 + 11 +220.0 + 21 +-152.0 + 31 +0.0 + 0 +TEXT + 5 +81 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-153.0 + 30 +0.0 + 40 +3.0 + 1 +FENCE + 7 +LT-TEXT + 11 +230.0 + 21 +-153.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +82 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +0.0 + 20 +-160.0 + 30 +0.0 + 40 +4.0 + 1 +Line weights (mm) + 7 +LT-TEXT + 11 +0.0 + 21 +-160.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +83 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +0 +100 +AcDbLine + 10 +0.0 + 20 +-166.0 + 30 +0.0 + 11 +220.0 + 21 +-166.0 + 31 +0.0 + 0 +TEXT + 5 +84 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-167.0 + 30 +0.0 + 40 +3.0 + 1 +0.00 + 7 +LT-TEXT + 11 +230.0 + 21 +-167.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +85 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +5 +100 +AcDbLine + 10 +0.0 + 20 +-171.0 + 30 +0.0 + 11 +220.0 + 21 +-171.0 + 31 +0.0 + 0 +TEXT + 5 +86 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-172.0 + 30 +0.0 + 40 +3.0 + 1 +0.05 + 7 +LT-TEXT + 11 +230.0 + 21 +-172.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +87 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +9 +100 +AcDbLine + 10 +0.0 + 20 +-176.0 + 30 +0.0 + 11 +220.0 + 21 +-176.0 + 31 +0.0 + 0 +TEXT + 5 +88 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-177.0 + 30 +0.0 + 40 +3.0 + 1 +0.09 + 7 +LT-TEXT + 11 +230.0 + 21 +-177.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +89 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +13 +100 +AcDbLine + 10 +0.0 + 20 +-181.0 + 30 +0.0 + 11 +220.0 + 21 +-181.0 + 31 +0.0 + 0 +TEXT + 5 +8A +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-182.0 + 30 +0.0 + 40 +3.0 + 1 +0.13 + 7 +LT-TEXT + 11 +230.0 + 21 +-182.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +8B +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +18 +100 +AcDbLine + 10 +0.0 + 20 +-186.0 + 30 +0.0 + 11 +220.0 + 21 +-186.0 + 31 +0.0 + 0 +TEXT + 5 +8C +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-187.0 + 30 +0.0 + 40 +3.0 + 1 +0.18 + 7 +LT-TEXT + 11 +230.0 + 21 +-187.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +8D +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +25 +100 +AcDbLine + 10 +0.0 + 20 +-191.0 + 30 +0.0 + 11 +220.0 + 21 +-191.0 + 31 +0.0 + 0 +TEXT + 5 +8E +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-192.0 + 30 +0.0 + 40 +3.0 + 1 +0.25 + 7 +LT-TEXT + 11 +230.0 + 21 +-192.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +8F +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +35 +100 +AcDbLine + 10 +0.0 + 20 +-196.0 + 30 +0.0 + 11 +220.0 + 21 +-196.0 + 31 +0.0 + 0 +TEXT + 5 +90 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-197.0 + 30 +0.0 + 40 +3.0 + 1 +0.35 + 7 +LT-TEXT + 11 +230.0 + 21 +-197.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +91 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +50 +100 +AcDbLine + 10 +0.0 + 20 +-201.0 + 30 +0.0 + 11 +220.0 + 21 +-201.0 + 31 +0.0 + 0 +TEXT + 5 +92 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-202.0 + 30 +0.0 + 40 +3.0 + 1 +0.50 + 7 +LT-TEXT + 11 +230.0 + 21 +-202.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +93 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +70 +100 +AcDbLine + 10 +0.0 + 20 +-206.0 + 30 +0.0 + 11 +220.0 + 21 +-206.0 + 31 +0.0 + 0 +TEXT + 5 +94 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-207.0 + 30 +0.0 + 40 +3.0 + 1 +0.70 + 7 +LT-TEXT + 11 +230.0 + 21 +-207.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +95 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +100 +100 +AcDbLine + 10 +0.0 + 20 +-211.0 + 30 +0.0 + 11 +220.0 + 21 +-211.0 + 31 +0.0 + 0 +TEXT + 5 +96 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-212.0 + 30 +0.0 + 40 +3.0 + 1 +1.00 + 7 +LT-TEXT + 11 +230.0 + 21 +-212.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +97 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +140 +100 +AcDbLine + 10 +0.0 + 20 +-216.0 + 30 +0.0 + 11 +220.0 + 21 +-216.0 + 31 +0.0 + 0 +TEXT + 5 +98 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-217.0 + 30 +0.0 + 40 +3.0 + 1 +1.40 + 7 +LT-TEXT + 11 +230.0 + 21 +-217.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +99 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +370 +200 +100 +AcDbLine + 10 +0.0 + 20 +-221.0 + 30 +0.0 + 11 +220.0 + 21 +-221.0 + 31 +0.0 + 0 +TEXT + 5 +9A +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-222.0 + 30 +0.0 + 40 +3.0 + 1 +2.00 + 7 +LT-TEXT + 11 +230.0 + 21 +-222.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +9B +330 +17 +100 +AcDbEntity + 8 +0 + 6 +DASHED + 62 +4 + 48 +0.25 +100 +AcDbLine + 10 +0.0 + 20 +-236.0 + 30 +0.0 + 11 +220.0 + 21 +-236.0 + 31 +0.0 + 0 +TEXT + 5 +9C +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-237.0 + 30 +0.0 + 40 +3.0 + 1 +DASHED, ltscale 0.25 + 7 +LT-TEXT + 11 +230.0 + 21 +-237.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +9D +330 +17 +100 +AcDbEntity + 8 +0 + 6 +DASHED + 62 +4 + 48 +0.5 +100 +AcDbLine + 10 +0.0 + 20 +-242.0 + 30 +0.0 + 11 +220.0 + 21 +-242.0 + 31 +0.0 + 0 +TEXT + 5 +9E +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-243.0 + 30 +0.0 + 40 +3.0 + 1 +DASHED, ltscale 0.5 + 7 +LT-TEXT + 11 +230.0 + 21 +-243.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +9F +330 +17 +100 +AcDbEntity + 8 +0 + 6 +DASHED + 62 +4 +100 +AcDbLine + 10 +0.0 + 20 +-248.0 + 30 +0.0 + 11 +220.0 + 21 +-248.0 + 31 +0.0 + 0 +TEXT + 5 +A0 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-249.0 + 30 +0.0 + 40 +3.0 + 1 +DASHED, ltscale 1 + 7 +LT-TEXT + 11 +230.0 + 21 +-249.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +A1 +330 +17 +100 +AcDbEntity + 8 +0 + 6 +DASHED + 62 +4 + 48 +2.0 +100 +AcDbLine + 10 +0.0 + 20 +-254.0 + 30 +0.0 + 11 +220.0 + 21 +-254.0 + 31 +0.0 + 0 +TEXT + 5 +A2 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-255.0 + 30 +0.0 + 40 +3.0 + 1 +DASHED, ltscale 2 + 7 +LT-TEXT + 11 +230.0 + 21 +-255.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +A3 +330 +17 +100 +AcDbEntity + 8 +0 + 6 +DASHED + 62 +4 + 48 +4.0 +100 +AcDbLine + 10 +0.0 + 20 +-260.0 + 30 +0.0 + 11 +220.0 + 21 +-260.0 + 31 +0.0 + 0 +TEXT + 5 +A4 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-261.0 + 30 +0.0 + 40 +3.0 + 1 +DASHED, ltscale 4 + 7 +LT-TEXT + 11 +230.0 + 21 +-261.0 + 31 +0.0 +100 +AcDbText + 0 +LWPOLYLINE + 5 +A5 +330 +17 +100 +AcDbEntity + 8 +0 + 6 +PHANTOM + 62 +1 +100 +AcDbPolyline + 90 +6 + 70 +128 + 10 +0.0 + 20 +-274.0 + 10 +40.0 + 20 +-286.0 + 10 +80.0 + 20 +-274.0 + 10 +120.0 + 20 +-286.0 + 10 +160.0 + 20 +-274.0 + 10 +200.0 + 20 +-286.0 + 0 +TEXT + 5 +A6 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-275.0 + 30 +0.0 + 40 +3.0 + 1 +lwpolyline, linetype generation on + 7 +LT-TEXT + 11 +230.0 + 21 +-275.0 + 31 +0.0 +100 +AcDbText + 0 +LWPOLYLINE + 5 +A7 +330 +17 +100 +AcDbEntity + 8 +0 + 6 +PHANTOM + 62 +3 +100 +AcDbPolyline + 90 +6 + 70 +0 + 10 +0.0 + 20 +-299.0 + 10 +40.0 + 20 +-311.0 + 10 +80.0 + 20 +-299.0 + 10 +120.0 + 20 +-311.0 + 10 +160.0 + 20 +-299.0 + 10 +200.0 + 20 +-311.0 + 0 +TEXT + 5 +A8 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-300.0 + 30 +0.0 + 40 +3.0 + 1 +lwpolyline, linetype generation off + 7 +LT-TEXT + 11 +230.0 + 21 +-300.0 + 31 +0.0 +100 +AcDbText + 0 +LWPOLYLINE + 5 +A9 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +6 +100 +AcDbPolyline + 90 +5 + 70 +0 + 10 +0.0 + 20 +-330.0 + 40 +0.0 + 41 +6.0 + 10 +50.0 + 20 +-330.0 + 40 +6.0 + 41 +1.0 + 10 +100.0 + 20 +-345.0 + 40 +1.0 + 41 +6.0 + 10 +150.0 + 20 +-330.0 + 40 +6.0 + 41 +6.0 + 10 +200.0 + 20 +-338.0 + 0 +TEXT + 5 +AA +330 +17 +100 +AcDbEntity + 8 +0 + 62 +7 +100 +AcDbText + 10 +230.0 + 20 +-331.0 + 30 +0.0 + 40 +3.0 + 1 +lwpolyline with start and end widths per segment + 7 +LT-TEXT + 11 +230.0 + 21 +-331.0 + 31 +0.0 +100 +AcDbText + 0 +CIRCLE + 5 +AB +330 +17 +100 +AcDbEntity + 8 +0 + 6 +CENTER + 62 +1 +370 +35 +100 +AcDbCircle + 10 +30.0 + 20 +-360.0 + 30 +0.0 + 40 +15.0 + 0 +ARC + 5 +AC +330 +17 +100 +AcDbEntity + 8 +0 + 6 +DASHED + 62 +3 +100 +AcDbCircle + 10 +90.0 + 20 +-360.0 + 30 +0.0 + 40 +15.0 +100 +AcDbArc + 50 +30.0 + 51 +300.0 + 0 +ELLIPSE + 5 +AD +330 +17 +100 +AcDbEntity + 8 +0 + 6 +PHANTOM + 62 +5 +100 +AcDbEllipse + 10 +150.0 + 20 +-360.0 + 30 +0.0 + 11 +25.0 + 21 +0.0 + 31 +0.0 + 40 +0.5 + 41 +0.0 + 42 +6.283185307179586 + 0 +SPLINE + 5 +AE +330 +17 +100 +AcDbEntity + 8 +0 + 6 +DASHDOT + 62 +6 +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +4 + 11 +190.0 + 21 +-370.0 + 31 +0.0 + 11 +200.0 + 21 +-348.0 + 31 +0.0 + 11 +215.0 + 21 +-374.0 + 31 +0.0 + 11 +225.0 + 21 +-350.0 + 31 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +B2 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +DICTIONARYVAR + 5 +B2 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/site-plan.dxf b/crates/exav-viewer/demo/examples/files/cad/site-plan.dxf new file mode 100644 index 00000000..92e0d42c --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/site-plan.dxf @@ -0,0 +1,14098 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1032 + 9 +$ACADMAINTVER + 90 +4 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$REQUIREDVERSIONS +160 +0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +158 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +6 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +154 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +193.55039660541104 + 22 +20.714668452779648 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +384.29437374413936 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +12 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +CONTOUR-MINOR + 70 +0 + 62 +8 + 6 +Continuous +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +CONTOUR-MAJOR + 70 +0 + 62 +30 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +43 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PARCEL + 70 +0 + 62 +1 + 6 +DASHDOT +370 +35 +390 +13 +347 +21 + 0 +LAYER + 5 +44 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +BUILDING + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +45 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +ROAD + 70 +0 + 62 +9 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +46 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +WATER + 70 +0 + 62 +5 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +47 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TREES + 70 +0 + 62 +3 + 6 +Continuous +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +48 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +SPOT + 70 +0 + 62 +2 + 6 +Continuous +370 +9 +390 +13 +347 +21 + 0 +LAYER + 5 +49 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TEXT + 70 +0 + 62 +2 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +4A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +LEGEND + 70 +0 + 62 +7 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +4B +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +SITE + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +155 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +156 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +13 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +4C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +DOOR +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +53 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +WINDOW +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +5C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +BED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +63 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +TABLE +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +6F +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SOFA +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +77 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +WC +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +7C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SINK +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +82 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +BATH +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +87 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +AXIS +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +NORTH +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +92 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +LEVEL +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +4D +330 +4C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +DOOR + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +DOOR + 1 + + 0 +LINE + 5 +4F +330 +4C +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +900.0 + 31 +0.0 + 0 +ARC + 5 +50 +330 +4C +100 +AcDbEntity + 8 +A-DOOR + 6 +DASHED +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +900.0 +100 +AcDbArc + 50 +0.0 + 51 +90.0 + 0 +LWPOLYLINE + 5 +51 +330 +4C +100 +AcDbEntity + 8 +A-DOOR +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +900.0 + 20 +0.0 + 10 +900.0 + 20 +40.0 + 10 +0.0 + 20 +40.0 + 0 +ATTDEF + 5 +52 +330 +4C +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +350.0 + 20 +-330.0 + 30 +0.0 + 40 +110.0 + 1 +D-000 +100 +AcDbAttributeDefinition + 3 + + 2 +TAG + 70 +0 + 71 +1 + 72 +0 + 0 +ENDBLK + 5 +4E +330 +4C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +54 +330 +53 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +WINDOW + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +WINDOW + 1 + + 0 +LINE + 5 +56 +330 +53 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +1.0 + 21 +0.0 + 31 +0.0 + 0 +LINE + 5 +57 +330 +53 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.35 + 30 +0.0 + 11 +1.0 + 21 +0.35 + 31 +0.0 + 0 +LINE + 5 +58 +330 +53 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.65 + 30 +0.0 + 11 +1.0 + 21 +0.65 + 31 +0.0 + 0 +LINE + 5 +59 +330 +53 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +1.0 + 30 +0.0 + 11 +1.0 + 21 +1.0 + 31 +0.0 + 0 +LINE + 5 +5A +330 +53 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +1.0 + 31 +0.0 + 0 +LINE + 5 +5B +330 +53 +100 +AcDbEntity + 8 +A-WIND +100 +AcDbLine + 10 +1.0 + 20 +0.0 + 30 +0.0 + 11 +1.0 + 21 +1.0 + 31 +0.0 + 0 +ENDBLK + 5 +55 +330 +53 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +5D +330 +5C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +BED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +BED + 1 + + 0 +LWPOLYLINE + 5 +5F +330 +5C +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1600.0 + 20 +0.0 + 10 +1600.0 + 20 +2000.0 + 10 +0.0 + 20 +2000.0 + 0 +LWPOLYLINE + 5 +60 +330 +5C +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +80.0 + 20 +1300.0 + 10 +760.0 + 20 +1300.0 + 10 +760.0 + 20 +1920.0 + 10 +80.0 + 20 +1920.0 + 0 +LWPOLYLINE + 5 +61 +330 +5C +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +840.0 + 20 +1300.0 + 10 +1520.0 + 20 +1300.0 + 10 +1520.0 + 20 +1920.0 + 10 +840.0 + 20 +1920.0 + 0 +LINE + 5 +62 +330 +5C +100 +AcDbEntity + 8 +A-FURN +100 +AcDbLine + 10 +0.0 + 20 +1200.0 + 30 +0.0 + 11 +1600.0 + 21 +1200.0 + 31 +0.0 + 0 +ENDBLK + 5 +5E +330 +5C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +64 +330 +63 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +TABLE + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +TABLE + 1 + + 0 +LWPOLYLINE + 5 +66 +330 +63 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1800.0 + 20 +0.0 + 10 +1800.0 + 20 +900.0 + 10 +0.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +67 +330 +63 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +-450.0 + 10 +550.0 + 20 +-450.0 + 10 +550.0 + 20 +-50.0 + 10 +150.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +68 +330 +63 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +950.0 + 10 +550.0 + 20 +950.0 + 10 +550.0 + 20 +1350.0 + 10 +150.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +69 +330 +63 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +600.0 + 20 +-450.0 + 10 +1000.0 + 20 +-450.0 + 10 +1000.0 + 20 +-50.0 + 10 +600.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +6A +330 +63 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +600.0 + 20 +950.0 + 10 +1000.0 + 20 +950.0 + 10 +1000.0 + 20 +1350.0 + 10 +600.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +6B +330 +63 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1050.0 + 20 +-450.0 + 10 +1450.0 + 20 +-450.0 + 10 +1450.0 + 20 +-50.0 + 10 +1050.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +6C +330 +63 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1050.0 + 20 +950.0 + 10 +1450.0 + 20 +950.0 + 10 +1450.0 + 20 +1350.0 + 10 +1050.0 + 20 +1350.0 + 0 +LWPOLYLINE + 5 +6D +330 +63 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +-450.0 + 10 +1900.0 + 20 +-450.0 + 10 +1900.0 + 20 +-50.0 + 10 +1500.0 + 20 +-50.0 + 0 +LWPOLYLINE + 5 +6E +330 +63 +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1500.0 + 20 +950.0 + 10 +1900.0 + 20 +950.0 + 10 +1900.0 + 20 +1350.0 + 10 +1500.0 + 20 +1350.0 + 0 +ENDBLK + 5 +65 +330 +63 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +70 +330 +6F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SOFA + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SOFA + 1 + + 0 +LWPOLYLINE + 5 +72 +330 +6F +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +2200.0 + 20 +0.0 + 10 +2200.0 + 20 +950.0 + 10 +0.0 + 20 +950.0 + 0 +LWPOLYLINE + 5 +73 +330 +6F +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +2200.0 + 20 +0.0 + 10 +2200.0 + 20 +250.0 + 10 +0.0 + 20 +250.0 + 0 +LWPOLYLINE + 5 +74 +330 +6F +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +120.0 + 20 +300.0 + 10 +720.0 + 20 +300.0 + 10 +720.0 + 20 +900.0 + 10 +120.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +75 +330 +6F +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +770.0 + 20 +300.0 + 10 +1370.0 + 20 +300.0 + 10 +1370.0 + 20 +900.0 + 10 +770.0 + 20 +900.0 + 0 +LWPOLYLINE + 5 +76 +330 +6F +100 +AcDbEntity + 8 +A-FURN +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +1420.0 + 20 +300.0 + 10 +2020.0 + 20 +300.0 + 10 +2020.0 + 20 +900.0 + 10 +1420.0 + 20 +900.0 + 0 +ENDBLK + 5 +71 +330 +6F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +78 +330 +77 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +WC + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +WC + 1 + + 0 +LWPOLYLINE + 5 +7A +330 +77 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +400.0 + 20 +0.0 + 10 +400.0 + 20 +200.0 + 10 +0.0 + 20 +200.0 + 0 +ELLIPSE + 5 +7B +330 +77 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbEllipse + 10 +200.0 + 20 +-250.0 + 30 +0.0 + 11 +0.0 + 21 +230.0 + 31 +0.0 + 40 +0.8 + 41 +0.0 + 42 +6.283185307179586 + 0 +ENDBLK + 5 +79 +330 +77 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +7D +330 +7C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SINK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SINK + 1 + + 0 +LWPOLYLINE + 5 +7F +330 +7C +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +600.0 + 20 +0.0 + 10 +600.0 + 20 +450.0 + 10 +0.0 + 20 +450.0 + 0 +CIRCLE + 5 +80 +330 +7C +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbCircle + 10 +300.0 + 20 +225.0 + 30 +0.0 + 40 +160.0 + 0 +CIRCLE + 5 +81 +330 +7C +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbCircle + 10 +300.0 + 20 +225.0 + 30 +0.0 + 40 +25.0 + 0 +ENDBLK + 5 +7E +330 +7C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +83 +330 +82 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +BATH + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +BATH + 1 + + 0 +LWPOLYLINE + 5 +85 +330 +82 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +1700.0 + 20 +0.0 + 10 +1700.0 + 20 +750.0 + 10 +0.0 + 20 +750.0 + 0 +LWPOLYLINE + 5 +86 +330 +82 +100 +AcDbEntity + 8 +A-FIXT +100 +AcDbPolyline + 90 +5 + 70 +1 + 10 +100.0 + 20 +100.0 + 10 +1500.0 + 20 +100.0 + 10 +1600.0 + 20 +375.0 + 10 +1500.0 + 20 +650.0 + 10 +100.0 + 20 +650.0 + 0 +ENDBLK + 5 +84 +330 +82 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +88 +330 +87 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +AXIS + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +AXIS + 1 + + 0 +CIRCLE + 5 +8A +330 +87 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 0 +ATTDEF + 5 +8B +330 +87 +100 +AcDbEntity + 8 +A-GRID +100 +AcDbText + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +300.0 + 1 +A + 72 +4 + 11 +0.0 + 21 +0.0 + 31 +0.0 +100 +AcDbAttributeDefinition + 3 + + 2 +ID + 70 +0 + 71 +1 + 72 +0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 0 +ENDBLK + 5 +89 +330 +87 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +8D +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +NORTH + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +NORTH + 1 + + 0 +CIRCLE + 5 +8F +330 +8C +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbCircle + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +500.0 + 0 +SOLID + 5 +90 +330 +8C +100 +AcDbEntity + 8 +A-ANNO + 62 +7 +100 +AcDbTrace + 10 +0.0 + 20 +650.0 + 30 +0.0 + 11 +-170.0 + 21 +-400.0 + 31 +0.0 + 12 +0.0 + 22 +-200.0 + 32 +0.0 + 13 +170.0 + 23 +-400.0 + 33 +0.0 + 0 +TEXT + 5 +91 +330 +8C +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +0.0 + 20 +800.0 + 30 +0.0 + 40 +300.0 + 1 +N + 72 +1 + 11 +0.0 + 21 +800.0 + 31 +0.0 +100 +AcDbText + 73 +1 + 0 +ENDBLK + 5 +8E +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +93 +330 +92 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +LEVEL + 70 +2 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +LEVEL + 1 + + 0 +LWPOLYLINE + 5 +95 +330 +92 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +-250.0 + 20 +250.0 + 10 +0.0 + 20 +0.0 + 10 +250.0 + 20 +250.0 + 0 +LINE + 5 +96 +330 +92 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbLine + 10 +-250.0 + 20 +250.0 + 30 +0.0 + 11 +900.0 + 21 +250.0 + 31 +0.0 + 0 +ATTDEF + 5 +97 +330 +92 +100 +AcDbEntity + 8 +A-ANNO +100 +AcDbText + 10 +320.0 + 20 +290.0 + 30 +0.0 + 40 +120.0 + 1 ++0.00 +100 +AcDbAttributeDefinition + 3 + + 2 +H + 70 +0 + 71 +1 + 72 +0 + 0 +ENDBLK + 5 +94 +330 +92 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +SPLINE + 5 +98 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +162.72 + 21 +110.0 + 31 +0.0 + 11 +162.75464820619362 + 21 +112.56319826494449 + 31 +0.0 + 11 +161.09938142686286 + 21 +114.8061731409782 + 31 +0.0 + 11 +158.84528137423857 + 21 +116.63396103067893 + 31 +0.0 + 11 +156.18 + 21 +118.02805549308175 + 31 +0.0 + 11 +153.02229025050545 + 21 +118.45950557757982 + 31 +0.0 + 11 +150.0 + 21 +117.92 + 31 +0.0 + 11 +147.33770974949454 + 21 +117.45185185953622 + 31 +0.0 + 11 +144.18 + 21 +117.56040177503814 + 31 +0.0 + 11 +140.43471862576143 + 21 +117.17396103067892 + 31 +0.0 + 11 +137.82061857313715 + 21 +115.2738268590218 + 31 +0.0 + 11 +137.6053517938064 + 21 +112.49085198298809 + 31 +0.0 + 11 +138.72 + 21 +110.0 + 31 +0.0 + 11 +139.57242837525598 + 21 +107.90445545309912 + 31 +0.0 + 11 +140.31477173603633 + 21 +105.8061731409782 + 31 +0.0 + 11 +141.87471862576143 + 21 +103.90603896932107 + 31 +0.0 + 11 +144.18 + 21 +102.43959822496186 + 31 +0.0 + 11 +146.81063316804494 + 21 +101.07284070437659 + 31 +0.0 + 11 +150.0 + 21 +99.92 + 31 +0.0 + 11 +153.54936683195504 + 21 +100.06518698633299 + 31 +0.0 + 11 +156.18 + 21 +101.97194450691825 + 31 +0.0 + 11 +157.40528137423857 + 21 +104.44603896932107 + 31 +0.0 + 11 +158.60522826396368 + 21 +106.2738268590218 + 31 +0.0 + 11 +160.787571624744 + 21 +107.8321091711427 + 31 +0.0 + 11 +162.72 + 21 +110.0 + 31 +0.0 + 0 +SPLINE + 5 +99 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +173.81378607118785 + 21 +108.0 + 31 +0.0 + 11 +173.58551100564256 + 21 +112.13690328730793 + 31 +0.0 + 11 +170.48583079664297 + 21 +115.57158683808454 + 31 +0.0 + 11 +166.61721940455368 + 21 +118.21291455341525 + 31 +0.0 + 11 +162.44810696440607 + 21 +120.2734509732725 + 31 +0.0 + 11 +157.68061818399474 + 21 +121.10122865512437 + 31 +0.0 + 11 +153.0 + 21 +120.61637460359522 + 31 +0.0 + 11 +148.64275899723438 + 21 +120.19608360235772 + 31 +0.0 + 11 +143.44810696440607 + 21 +120.408273034584 + 31 +0.0 + 11 +137.56899452425847 + 21 +119.57325410680613 + 31 +0.0 + 11 +133.80416920335705 + 21 +116.31203855832024 + 31 +0.0 + 11 +133.90489788431614 + 21 +111.83738789346549 + 31 +0.0 + 11 +135.81378607118785 + 21 +108.0 + 31 +0.0 + 11 +136.88032960665797 + 21 +104.76056050188609 + 31 +0.0 + 11 +137.57686545283428 + 21 +101.32158683808456 + 31 +0.0 + 11 +139.74716171946486 + 21 +98.06037128959865 + 31 +0.0 + 11 +143.44810696440607 + 21 +95.591726965416 + 31 +0.0 + 11 +147.84549447009897 + 21 +93.57234260588592 + 31 +0.0 + 11 +153.0 + 21 +92.11637460359522 + 31 +0.0 + 11 +158.47788271113018 + 21 +92.66719755311927 + 31 +0.0 + 11 +162.44810696440607 + 21 +95.7265490267275 + 31 +0.0 + 11 +164.4390522093473 + 21 +99.42071084298954 + 31 +0.0 + 11 +166.7131345471657 + 21 +102.06203855832024 + 31 +0.0 + 11 +170.61007928330073 + 21 +104.46104510804363 + 31 +0.0 + 11 +173.81378607118785 + 21 +108.0 + 31 +0.0 + 0 +SPLINE + 5 +9A +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +185.32168451699252 + 21 +106.0 + 31 +0.0 + 11 +184.46611427526742 + 21 +111.72060424878246 + 31 +0.0 + 11 +179.57671671897407 + 21 +116.20901780823043 + 31 +0.0 + 11 +174.01145761161308 + 21 +119.50859320870981 + 31 +0.0 + 11 +168.50915774150374 + 21 +122.24987257613353 + 31 +0.0 + 11 +162.32561819866925 + 21 +123.705646380037 + 31 +0.0 + 11 +156.0 + 21 +123.56871466111136 + 31 +0.0 + 11 +149.80956051465776 + 21 +123.32727601035833 + 31 +0.0 + 11 +142.50915774150374 + 21 +123.52511817145958 + 31 +0.0 + 11 +134.66685787139443 + 21 +121.9998565964542 + 31 +0.0 + 11 +130.08328328102593 + 21 +117.22226753065821 + 31 +0.0 + 11 +130.72039152839807 + 21 +111.08023800620553 + 31 +0.0 + 11 +133.32168451699252 + 21 +106.0 + 31 +0.0 + 11 +134.23797130823584 + 21 +101.62666148978415 + 31 +0.0 + 11 +134.54339572218328 + 21 +96.70901780823043 + 31 +0.0 + 11 +137.2419049899126 + 21 +91.93142874243446 + 31 +0.0 + 11 +142.50915774150374 + 21 +88.47488182854042 + 31 +0.0 + 11 +148.8670278533382 + 21 +86.03453915476334 + 31 +0.0 + 11 +156.0 + 21 +84.56871466111136 + 31 +0.0 + 11 +163.26815085998882 + 21 +85.65616878508466 + 31 +0.0 + 11 +168.50915774150374 + 21 +89.75012742386647 + 31 +0.0 + 11 +171.43641049309488 + 21 +94.42269213017883 + 31 +0.0 + 11 +175.11660427781672 + 21 +97.72226753065821 + 31 +0.0 + 11 +180.9485344954296 + 21 +100.98629524720721 + 31 +0.0 + 11 +185.32168451699252 + 21 +106.0 + 31 +0.0 + 0 +SPLINE + 5 +9B +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MAJOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +197.08197456212483 + 21 +104.0 + 31 +0.0 + 11 +195.17052761006573 + 21 +111.26889774719355 + 31 +0.0 + 11 +188.2256252190079 + 21 +116.65506694057198 + 31 +0.0 + 11 +181.02432423526957 + 21 +120.51824317645217 + 31 +0.0 + 11 +174.44401271893759 + 21 +124.06236102645488 + 31 +0.0 + 11 +167.01782816409502 + 21 +126.44220655606952 + 31 +0.0 + 11 +159.0 + 21 +126.90381839423613 + 31 +0.0 + 11 +150.83677034427416 + 21 +126.84919087176635 + 31 +0.0 + 11 +141.44401271893759 + 21 +126.80589646087483 + 31 +0.0 + 11 +131.8937012026056 + 21 +124.3297240980458 + 31 +0.0 + 11 +126.8043747809921 + 21 +117.94111466519188 + 31 +0.0 + 11 +128.0568484436899 + 21 +110.21839435309036 + 31 +0.0 + 11 +131.08197456212483 + 21 +104.0 + 31 +0.0 + 11 +131.41942307498724 + 21 +98.45735501461877 + 31 +0.0 + 11 +131.06794856923494 + 21 +91.905066940572 + 31 +0.0 + 11 +134.35527667695743 + 21 +85.51645750771809 + 31 +0.0 + 11 +141.44401271893756 + 21 +81.19410353912518 + 31 +0.0 + 11 +149.93577118732867 + 21 +78.62887815476064 + 31 +0.0 + 11 +159.0 + 21 +77.40381839423614 + 31 +0.0 + 11 +167.9188273210405 + 21 +79.03586247045747 + 31 +0.0 + 11 +174.44401271893759 + 21 +83.9376389735451 + 31 +0.0 + 11 +178.56274876091774 + 21 +89.3279384293117 + 31 +0.0 + 11 +183.96205143076503 + 21 +93.19111466519188 + 31 +0.0 + 11 +191.80795297876838 + 21 +97.40685162051555 + 31 +0.0 + 11 +197.08197456212483 + 21 +104.0 + 31 +0.0 + 0 +TEXT + 5 +9C +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +198.08197456212483 + 20 +104.0 + 30 +0.0 + 40 +3.0 + 1 +100 + 7 +SITE + 11 +198.08197456212483 + 21 +104.0 + 31 +0.0 +100 +AcDbText + 73 +2 + 0 +SPLINE + 5 +9D +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +208.8737876126427 + 21 +102.0 + 31 +0.0 + 11 +205.48066257146198 + 21 +110.73795631679504 + 31 +0.0 + 11 +196.34730905042596 + 21 +116.87282109465201 + 31 +0.0 + 11 +187.71703605188458 + 21 +121.28777703891343 + 31 +0.0 + 11 +180.36310619367865 + 21 +125.85437468417562 + 31 +0.0 + 11 +171.81568470126695 + 21 +129.47447551215362 + 31 +0.0 + 11 +162.0 + 21 +130.69551208388398 + 31 +0.0 + 11 +151.74679538136968 + 21 +130.69911043284617 + 31 +0.0 + 11 +140.36310619367868 + 21 +130.10714954289068 + 31 +0.0 + 11 +129.40917633547275 + 21 +126.44311774839545 + 31 +0.0 + 11 +124.05269094957404 + 21 +118.431666821464 + 31 +0.0 + 11 +125.83064495854407 + 21 +109.26866210558451 + 31 +0.0 + 11 +128.87378761264267 + 21 +102.0 + 31 +0.0 + 11 +128.2065964683365 + 21 +95.20881361064379 + 31 +0.0 + 11 +127.06527674767085 + 21 +86.87282109465202 + 31 +0.0 + 11 +131.14849355696077 + 21 +78.8613701677206 + 31 +0.0 + 11 +140.36310619367865 + 21 +73.89285045710932 + 31 +0.0 + 11 +151.1101610930653 + 21 +71.51892593480952 + 31 +0.0 + 11 +162.0 + 21 +70.69551208388398 + 31 +0.0 + 11 +172.45231898957132 + 21 +72.74356085550205 + 31 +0.0 + 11 +180.36310619367865 + 21 +78.14562531582438 + 31 +0.0 + 11 +185.97771883039653 + 21 +84.0167108772026 + 31 +0.0 + 11 +193.33472325232913 + 21 +88.431666821464 + 31 +0.0 + 11 +203.10471106166952 + 21 +93.73951939943323 + 31 +0.0 + 11 +208.8737876126427 + 21 +102.0 + 31 +0.0 + 0 +SPLINE + 5 +9E +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +220.44587172444687 + 21 +100.0 + 31 +0.0 + 11 +215.2185489394071 + 21 +110.09201472503271 + 31 +0.0 + 11 +203.9337015827805 + 21 +116.85878731702515 + 31 +0.0 + 11 +194.2105617622472 + 21 +121.90792132168542 + 31 +0.0 + 11 +186.39206413777657 + 21 +127.78910647405084 + 31 +0.0 + 11 +176.76687478692395 + 21 +132.93593091332605 + 31 +0.0 + 11 +165.0 + 21 +134.95078163694564 + 31 +0.0 + 11 +152.58391324349594 + 21 +134.75309995484218 + 31 +0.0 + 11 +139.39206413777657 + 21 +133.2656844927521 + 31 +0.0 + 11 +127.34356651330592 + 21 +128.24232511502058 + 31 +0.0 + 11 +121.83629841721952 + 21 +118.69043104602923 + 31 +0.0 + 11 +123.8765347546199 + 21 +108.26424947685172 + 31 +0.0 + 11 +126.44587172444687 + 21 +100.0 + 31 +0.0 + 11 +124.42152126823467 + 21 +91.845272045305 + 31 +0.0 + 11 +122.52731362704324 + 21 +81.60878731702516 + 31 +0.0 + 11 +127.74252433071173 + 21 +72.05689324803383 + 31 +0.0 + 11 +139.39206413777654 + 21 +66.73431550724791 + 31 +0.0 + 11 +152.437884547287 + 21 +64.83816015994672 + 31 +0.0 + 11 +165.0 + 21 +64.45078163694562 + 31 +0.0 + 11 +176.91290348313285 + 21 +66.65532920146288 + 31 +0.0 + 11 +186.39206413777657 + 21 +72.21089352594916 + 31 +0.0 + 11 +193.81160394484138 + 21 +78.39129704136896 + 31 +0.0 + 11 +203.24268637295674 + 21 +83.44043104602923 + 31 +0.0 + 11 +214.67356242579228 + 21 +90.01750679712397 + 31 +0.0 + 11 +220.44587172444687 + 21 +100.0 + 31 +0.0 + 0 +SPLINE + 5 +9F +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +231.55053264809072 + 21 +98.0 + 31 +0.0 + 11 +224.2745897545753 + 21 +109.30904815934838 + 31 +0.0 + 11 +211.0603489523935 + 21 +116.6456780442977 + 31 +0.0 + 11 +200.6723650732226 + 21 +122.50427380491696 + 31 +0.0 + 11 +192.65473367595465 + 21 +130.02743853037464 + 31 +0.0 + 11 +181.90076483781027 + 21 +136.90877047908117 + 31 +0.0 + 11 +168.0 + 21 +139.6042021802084 + 31 +0.0 + 11 +153.40941990157745 + 21 +138.83958967941223 + 31 +0.0 + 11 +138.65473367595467 + 21 +136.1206191761649 + 31 +0.0 + 11 +125.7771022786867 + 21 +129.66717329098498 + 31 +0.0 + 11 +120.07965104760649 + 21 +118.75011977549389 + 31 +0.0 + 11 +121.96575815412771 + 21 +107.25112844508537 + 31 +0.0 + 11 +123.5505326480907 + 21 +98.0 + 31 +0.0 + 11 +119.9546005153559 + 21 +88.3447055060442 + 31 +0.0 + 11 +117.52960534367412 + 21 +76.14567804429772 + 31 +0.0 + 11 +124.30483270507546 + 21 +65.22862452880662 + 31 +0.0 + 11 +138.65473367595462 + 21 +59.8793808238351 + 31 +0.0 + 11 +153.94830796673804 + 21 +58.668778549666634 + 31 +0.0 + 11 +168.0 + 21 +58.604202180208404 + 31 +0.0 + 11 +181.36187677264965 + 21 +60.599597749997685 + 31 +0.0 + 11 +192.65473367595465 + 21 +65.97256146962538 + 31 +0.0 + 11 +202.14463464683382 + 21 +72.39152401487462 + 31 +0.0 + 11 +213.61039465632584 + 21 +78.25011977549389 + 31 +0.0 + 11 +226.28574739334704 + 21 +86.28678579178114 + 31 +0.0 + 11 +231.55053264809072 + 21 +98.0 + 31 +0.0 + 0 +SPLINE + 5 +A0 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +241.97869256386974 + 21 +96.0 + 31 +0.0 + 11 +232.62816735388895 + 21 +108.38491325511347 + 31 +0.0 + 11 +217.88217571324788 + 21 +116.30057757617925 + 31 +0.0 + 11 +207.29643062760906 + 21 +123.22232297070678 + 31 +0.0 + 11 +199.25565371806513 + 21 +132.70517088057093 + 31 +0.0 + 11 +187.2228793400974 + 21 +141.40845745672718 + 31 +0.0 + 11 +171.0 + 21 +144.52160511425322 + 31 +0.0 + 11 +154.29431866282246 + 21 +142.75983864405134 + 31 +0.0 + 11 +138.25565371806513 + 21 +138.5361535657052 + 31 +0.0 + 11 +124.7248768085212 + 21 +130.7063423936091 + 31 +0.0 + 11 +118.62782428675212 + 21 +118.67781730956753 + 31 +0.0 + 11 +119.83332720706096 + 21 +106.28255149069165 + 31 +0.0 + 11 +119.97869256386977 + 21 +96.0 + 31 +0.0 + 11 +114.7852165466226 + 21 +84.70297062823282 + 31 +0.0 + 11 +112.22707645154637 + 21 +70.55057757617926 + 31 +0.0 + 11 +121.02940332285021 + 21 +58.5220524921377 + 31 +0.0 + 11 +138.25565371806508 + 21 +53.4638464342948 + 31 +0.0 + 11 +155.64695583758987 + 21 +53.02624435127743 + 31 +0.0 + 11 +171.0 + 21 +53.02160511425322 + 31 +0.0 + 11 +185.87024216532996 + 21 +54.377625538601585 + 31 +0.0 + 11 +199.25565371806513 + 21 +59.29482911942907 + 31 +0.0 + 11 +210.99190411328 + 21 +66.00607191504 + 31 +0.0 + 11 +224.2829235484536 + 21 +72.92781730956752 + 31 +0.0 + 11 +237.67627801432727 + 21 +82.60060886381095 + 31 +0.0 + 11 +241.97869256386974 + 21 +96.0 + 31 +0.0 + 0 +SPLINE + 5 +A1 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MAJOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +251.59177955329739 + 21 +94.0 + 31 +0.0 + 11 +240.35779355666563 + 21 +107.33538789626478 + 31 +0.0 + 11 +224.61873766490416 + 21 +115.91855636265359 + 31 +0.0 + 11 +214.27880498462827 + 21 +124.20910373847119 + 31 +0.0 + 11 +206.2641102233513 + 21 +135.91230862588517 + 31 +0.0 + 11 +192.71218111520287 + 21 +146.37610798177593 + 31 +0.0 + 11 +174.0 + 21 +149.51284953911244 + 31 +0.0 + 11 +155.31036754823066 + 21 +146.3129934135935 + 31 +0.0 + 11 +138.26411022335134 + 21 +140.42228256012757 + 31 +0.0 + 11 +124.12941546207435 + 21 +131.40293840344424 + 31 +0.0 + 11 +117.26126233509584 + 21 +118.56859409823397 + 31 +0.0 + 11 +117.21143733319828 + 21 +105.41233712942031 + 31 +0.0 + 11 +115.59177955329739 + 21 +94.0 + 31 +0.0 + 11 +108.99188118135231 + 21 +80.93584529580767 + 31 +0.0 + 11 +106.83928275022049 + 21 +64.91855636265362 + 31 +0.0 + 11 +118.11228274325775 + 21 +52.08421205744336 + 31 +0.0 + 11 +138.26411022335128 + 21 +47.57771743987244 + 31 +0.0 + 11 +157.51279098126005 + 21 +47.85167370029099 + 31 +0.0 + 11 +174.0 + 21 +47.51284953911243 + 31 +0.0 + 11 +190.50975768217344 + 21 +47.78855913210852 + 31 +0.0 + 11 +206.2641102233513 + 21 +52.08769137411484 + 31 +0.0 + 11 +220.2959377034448 + 21 +59.27804672241638 + 31 +0.0 + 11 +235.04071724977945 + 21 +67.56859409823397 + 31 +0.0 + 11 +248.57734970851152 + 21 +79.01279452896313 + 31 +0.0 + 11 +251.59177955329739 + 21 +94.0 + 31 +0.0 + 0 +TEXT + 5 +A2 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +252.59177955329739 + 20 +94.0 + 30 +0.0 + 40 +3.0 + 1 +110 + 7 +SITE + 11 +252.59177955329739 + 21 +94.0 + 31 +0.0 +100 +AcDbText + 73 +2 + 0 +SPLINE + 5 +A3 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +260.3464189221045 + 21 +92.0 + 31 +0.0 + 11 +247.63915514445503 + 21 +106.19577843123014 + 31 +0.0 + 11 +231.5303603072676 + 21 +115.61233865180617 + 31 +0.0 + 11 +221.79147448886206 + 21 +125.59360586664654 + 31 +0.0 + 11 +213.70179053894776 + 21 +139.67702445665617 + 31 +0.0 + 11 +198.32159503188961 + 21 +151.67995696806514 + 31 +0.0 + 11 +177.0 + 21 +154.35248695861517 + 31 +0.0 + 11 +156.520517929023 + 21 +149.32285070118658 + 31 +0.0 + 11 +138.70179053894776 + 21 +141.75083346909315 + 31 +0.0 + 11 +123.86210658903349 + 21 +131.85342005822488 + 31 +0.0 + 11 +115.71963969273239 + 21 +118.53517438957866 + 31 +0.0 + 11 +113.86515081673686 + 21 +104.68769888968708 + 31 +0.0 + 11 +110.34641892210448 + 21 +92.0 + 31 +0.0 + 11 +102.75028120109478 + 21 +77.07863585719655 + 31 +0.0 + 11 +101.6265497396018 + 21 +59.3623386518062 + 31 +0.0 + 11 +115.72545731087988 + 21 +46.04409298315996 + 31 +0.0 + 11 +138.7017905389477 + 21 +42.24916653090684 + 31 +0.0 + 11 +159.4987382665115 + 21 +43.01330151054496 + 31 +0.0 + 11 +177.0 + 21 +41.85248695861517 + 31 +0.0 + 11 +195.34337469440106 + 21 +40.656195243666396 + 31 +0.0 + 11 +213.70179053894776 + 21 +44.32297554334383 + 31 +0.0 + 11 +229.9281237670156 + 21 +52.303907174738285 + 31 +0.0 + 11 +245.62345026039816 + 21 +62.28517438957864 + 31 +0.0 + 11 +258.7540247600971 + 21 +75.57055631565343 + 31 +0.0 + 11 +260.3464189221045 + 21 +92.0 + 31 +0.0 + 0 +SPLINE + 5 +A4 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +268.3086208793091 + 21 +90.0 + 31 +0.0 + 11 +254.7307773468165 + 21 +105.01803857987214 + 31 +0.0 + 11 +238.88767397846223 + 21 +115.49911081756206 + 31 +0.0 + 11 +229.95768253436816 + 21 +127.46826190077613 + 31 +0.0 + 11 +221.53568956034547 + 21 +143.9564434844449 + 31 +0.0 + 11 +203.98184946367417 + 21 +157.1261104934253 + 31 +0.0 + 11 +180.0 + 21 +158.80614462491127 + 31 +0.0 + 11 +157.96988001827322 + 21 +151.66314530148202 + 31 +0.0 + 11 +139.53568956034547 + 21 +142.56468118104107 + 31 +0.0 + 11 +123.73369658632276 + 21 +132.19972756025794 + 31 +0.0 + 11 +113.73232602153777 + 21 +118.69474455752663 + 31 +0.0 + 11 +109.62611405054523 + 21 +104.14246943129727 + 31 +0.0 + 11 +104.3086208793091 + 21 +90.0 + 31 +0.0 + 11 +96.31894183540929 + 21 +73.18329603226208 + 31 +0.0 + 11 +96.85950775781428 + 21 +53.99911081756209 + 31 +0.0 + 11 +113.99217041977431 + 21 +40.4941278148308 + 31 +0.0 + 11 +139.53568956034542 + 21 +37.43531881895895 + 31 +0.0 + 11 +161.53552606686077 + 21 +38.317233859869916 + 31 +0.0 + 11 +180.0 + 21 +35.8061446249113 + 31 +0.0 + 11 +200.41620341508658 + 21 +32.85426866792661 + 31 +0.0 + 11 +221.53568956034547 + 21 +36.0435565155551 + 31 +0.0 + 11 +239.69920870091653 + 21 +45.22559347431258 + 31 +0.0 + 11 +255.76049224218568 + 21 +57.19474455752662 + 31 +0.0 + 11 +268.03794956195236 + 21 +72.30772688368715 + 31 +0.0 + 11 +268.3086208793091 + 21 +90.0 + 31 +0.0 + 0 +SPLINE + 5 +A5 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +275.6552759865501 + 21 +88.0 + 31 +0.0 + 11 +261.948464748782 + 21 +103.86563302983481 + 31 +0.0 + 11 +246.93791112697645 + 21 +115.68592765043665 + 31 +0.0 + 11 +238.83172356101434 + 21 +129.87379267076074 + 31 +0.0 + 11 +229.67736200672496 + 21 +148.63567191919958 + 31 +0.0 + 11 +209.60832482703114 + 21 +162.47771511908246 + 31 +0.0 + 11 +183.0 + 21 +162.65971295697 + 31 +0.0 + 11 +159.6783269602572 + 21 +153.27825152637212 + 31 +0.0 + 11 +140.677362006725 + 21 +142.97871948602298 + 31 +0.0 + 11 +123.51300045243556 + 21 +132.61524966067333 + 31 +0.0 + 11 +111.05208887302356 + 21 +119.15435939259334 + 31 +0.0 + 11 +104.4201594504798 + 21 +103.79155361245776 + 31 +0.0 + 11 +97.65527598655011 + 21 +88.00000000000001 + 31 +0.0 + 11 +90.01366766932783 + 21 +69.3132905086483 + 31 +0.0 + 11 +92.78538925334634 + 21 +48.93592765043668 + 31 +0.0 + 11 +112.96671650980882 + 21 +35.475037382356675 + 31 +0.0 + 11 +140.6773620067249 + 21 +33.021280513977025 + 31 +0.0 + 11 +163.53853479878248 + 21 +33.52661730949184 + 31 +0.0 + 11 +182.99999999999997 + 21 +29.15971295697001 + 31 +0.0 + 11 +205.74811698850584 + 21 +24.32715371678151 + 31 +0.0 + 11 +229.67736200672496 + 21 +27.364328080800412 + 31 +0.0 + 11 +249.37800750364102 + 21 +38.216494372269224 + 31 +0.0 + 11 +265.2046107466536 + 21 +52.40435939259331 + 31 +0.0 + 11 +276.35495652993393 + 21 +69.23921109127117 + 31 +0.0 + 11 +275.6552759865501 + 21 +88.0 + 31 +0.0 + 0 +SPLINE + 5 +A6 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +282.66216449324327 + 21 +86.0 + 31 +0.0 + 11 +269.63102300052367 + 21 +102.80664880638423 + 31 +0.0 + 11 +255.87182740148103 + 21 +116.2553887692621 + 31 +0.0 + 11 +248.38584026920222 + 21 +132.78938020190168 + 31 +0.0 + 11 +237.98891775337836 + 21 +153.53558523452818 + 31 +0.0 + 11 +215.11023842160583 + 21 +167.48066660740744 + 31 +0.0 + 11 +186.0 + 21 +165.74799271712703 + 31 +0.0 + 11 +161.63569887818144 + 21 +154.19660725815072 + 31 +0.0 + 11 +141.98891775337836 + 21 +143.17207291043098 + 31 +0.0 + 11 +122.95199523755448 + 21 +133.28600357183416 + 31 +0.0 + 11 +107.488172598519 + 21 +119.99661851361085 + 31 +0.0 + 11 +98.28520419293238 + 21 +103.62733152557347 + 31 +0.0 + 11 +90.66216449324332 + 21 +86.00000000000001 + 31 +0.0 + 11 +84.17326435302257 + 21 +65.53670631162123 + 31 +0.0 + 11 +89.59494987486876 + 21 +44.25538876926211 + 31 +0.0 + 11 +112.62133828138504 + 21 +30.966003711038837 + 31 +0.0 + 11 +141.9889177533783 + 21 +28.827927089569023 + 31 +0.0 + 11 +165.41698176192185 + 21 +28.387347621781622 + 31 +0.0 + 11 +185.99999999999997 + 21 +21.747992717127033 + 31 +0.0 + 11 +211.32895553786537 + 21 +15.103288272524864 + 31 +0.0 + 11 +237.98891775337836 + 21 +18.46441476547183 + 31 +0.0 + 11 +258.7164972253716 + 21 +31.46262708097128 + 31 +0.0 + 11 +273.7650501251312 + 21 +47.996618513610834 + 31 +0.0 + 11 +283.74296284043345 + 21 +66.3573890308104 + 31 +0.0 + 11 +282.66216449324327 + 21 +86.0 + 31 +0.0 + 0 +SPLINE + 5 +A7 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MAJOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +289.6792102724861 + 21 +84.0 + 31 +0.0 + 11 +278.10086537956244 + 21 +101.90587869252595 + 31 +0.0 + 11 +265.7951834933446 + 21 +117.25328989676191 + 31 +0.0 + 11 +258.5061316860145 + 21 +136.12959876451086 + 31 +0.0 + 11 +246.29539486375697 + 21 +158.42890120781095 + 31 +0.0 + 11 +220.40119987005744 + 21 +171.89315500025964 + 31 +0.0 + 11 +189.0 + 21 +167.9793924599353 + 31 +0.0 + 11 +163.80030512244247 + 21 +154.53490621370847 + 31 +0.0 + 11 +143.295394863757 + 21 +143.37202367688482 + 31 +0.0 + 11 +121.81465804149943 + 21 +134.38900646887544 + 31 +0.0 + 11 +102.93481650665538 + 21 +121.26731764330279 + 31 +0.0 + 11 +91.3768399004238 + 21 +103.61853518344175 + 31 +0.0 + 11 +83.67921027248609 + 21 +84.00000000000001 + 31 +0.0 + 11 +79.12014516401436 + 21 +61.91833622418649 + 31 +0.0 + 11 +87.39395031375024 + 21 +40.003289896761935 + 31 +0.0 + 11 +112.84213476158564 + 21 +26.881601071189287 + 31 +0.0 + 11 +143.29539486375688 + 21 +24.627976323115185 + 31 +0.0 + 11 +167.08447657893817 + 21 +22.65761483859861 + 31 +0.0 + 11 +188.99999999999997 + 21 +13.479392459935312 + 31 +0.0 + 11 +217.11702841356168 + 21 +5.299366052047418 + 31 +0.0 + 11 +246.29539486375697 + 21 +9.571098792189048 + 31 +0.0 + 11 +267.4786549659282 + 21 +25.14100877555383 + 31 +0.0 + 11 +281.33604968624974 + 21 +44.01731764330274 + 31 +0.0 + 11 +290.35756011597186 + 21 +63.63099271510221 + 31 +0.0 + 11 +289.6792102724861 + 21 +84.0 + 31 +0.0 + 0 +TEXT + 5 +A8 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +290.6792102724861 + 20 +84.0 + 30 +0.0 + 40 +3.0 + 1 +120 + 7 +SITE + 11 +290.6792102724861 + 21 +84.0 + 31 +0.0 +100 +AcDbText + 73 +2 + 0 +SPLINE + 5 +A9 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +297.09519980414063 + 21 +82.0 + 31 +0.0 + 11 +287.6238385648448 + 21 +101.21674774046065 + 31 +0.0 + 11 +276.7083605162395 + 21 +118.67979605999705 + 31 +0.0 + 11 +268.9984246076013 + 21 +139.74881845570098 + 31 +0.0 + 11 +254.4024000979297 + 21 +163.06309561289146 + 31 +0.0 + 11 +225.4099757694517 + 21 +175.51579528842916 + 31 +0.0 + 11 +192.0 + 21 +169.3535821312729 + 31 +0.0 + 11 +166.101073367697 + 21 +154.49208253994016 + 31 +0.0 + 11 +144.4024000979297 + 21 +143.83109601154092 + 31 +0.0 + 11 +119.90637558825807 + 21 +136.07021830880646 + 31 +0.0 + 11 +97.3916394837605 + 21 +122.96662180873003 + 31 +0.0 + 11 +83.96031210214717 + 21 +103.71186034205518 + 31 +0.0 + 11 +77.09519980414066 + 21 +82.00000000000001 + 31 +0.0 + 11 +75.12015678124975 + 21 +58.51160529854472 + 31 +0.0 + 11 +86.18277168366298 + 21 +36.17979605999709 + 31 +0.0 + 11 +113.43493274656076 + 21 +23.07619955992064 + 31 +0.0 + 11 +144.40240009792961 + 21 +20.168903988459085 + 31 +0.0 + 11 +168.46978584689717 + 21 +16.138033950732904 + 31 +0.0 + 11 +191.99999999999997 + 21 +4.35358213127293 + 31 +0.0 + 11 +223.0412632902515 + 21 +-4.885678797756128 + 31 +0.0 + 11 +254.4024000979297 + 21 +0.936904387108541 + 31 +0.0 + 11 +275.4698674492985 + 21 +19.397599413026093 + 31 +0.0 + 11 +287.917228316337 + 21 +40.466621808729975 + 31 +0.0 + 11 +296.4639938857421 + 21 +61.006717900139165 + 31 +0.0 + 11 +297.09519980414063 + 21 +82.0 + 31 +0.0 + 0 +SPLINE + 5 +AA +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +305.29547714798207 + 21 +80.0 + 31 +0.0 + 11 +298.37287227290756 + 21 +100.77400823360838 + 31 +0.0 + 11 +288.4969068196331 + 21 +120.48534824053439 + 31 +0.0 + 11 +279.60396831283094 + 21 +143.45297623462324 + 31 +0.0 + 11 +262.117261426009 + 21 +167.18788014104774 + 31 +0.0 + 11 +230.0902297418953 + 21 +178.21889018451336 + 31 +0.0 + 11 +195.0 + 21 +169.96967976800613 + 31 +0.0 + 11 +168.44329427676334 + 21 +154.33323128058083 + 31 +0.0 + 11 +145.117261426009 + 21 +144.79957822312127 + 31 +0.0 + 11 +117.100554539187 + 21 +138.42458409560976 + 31 +0.0 + 11 +90.97309318036686 + 21 +125.04497199145948 + 31 +0.0 + 11 +76.38908085641586 + 21 +103.83627499852744 + 31 +0.0 + 11 +71.29547714798206 + 21 +80.00000000000001 + 31 +0.0 + 11 +72.34622892126559 + 21 +55.35126581811599 + 31 +0.0 + 11 +85.84696233407446 + 21 +32.735348240534435 + 31 +0.0 + 11 +114.14098151517878 + 21 +19.355736136384152 + 31 +0.0 + 11 +145.11726142600892 + 21 +15.200421776878756 + 31 +0.0 + 11 +169.52657318790546 + 21 +8.69890767078185 + 31 +0.0 + 11 +194.99999999999997 + 21 +-5.530320231993883 + 31 +0.0 + 11 +229.0069508307531 + 21 +-15.186751233150659 + 31 +0.0 + 11 +262.117261426009 + 21 +-7.187880141047728 + 31 +0.0 + 11 +282.5635413368391 + 21 +14.327343997370647 + 31 +0.0 + 11 +293.6230376659255 + 21 +37.294971991459434 + 31 +0.0 + 11 +302.4157242080578 + 21 +58.413532583034964 + 31 +0.0 + 11 +305.29547714798207 + 21 +80.0 + 31 +0.0 + 0 +SPLINE + 5 +AB +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +314.61707062052267 + 21 +78.0 + 31 +0.0 + 11 +310.39982562073607 + 21 +100.58808187835643 + 31 +0.0 + 11 +300.9347020451329 + 21 +122.57203345103352 + 31 +0.0 + 11 +290.02369799366005 + 21 +147.01777349524502 + 31 +0.0 + 11 +269.27146468973865 + 21 +170.5843484793589 + 31 +0.0 + 11 +234.42806617887177 + 21 +179.96354535077322 + 31 +0.0 + 11 +198.0 + 21 +170.0235113448158 + 31 +0.0 + 11 +170.71750253251963 + 21 +154.36475002950453 + 31 +0.0 + 11 +145.27146468973868 + 21 +146.49637662454666 + 31 +0.0 + 11 +113.3592313858173 + 21 +141.48057646063705 + 31 +0.0 + 11 +83.90529795486715 + 21 +127.40445520415068 + 31 +0.0 + 11 +69.07167628839522 + 21 +103.90968016501718 + 31 +0.0 + 11 +66.6170706205227 + 21 +78.00000000000001 + 31 +0.0 + 11 +70.85022070104716 + 21 +52.44773948928757 + 31 +0.0 + 11 +86.16040190659201 + 21 +29.572033451033548 + 31 +0.0 + 11 +114.66121625939617 + 21 +15.49591219454721 + 31 +0.0 + 11 +145.2714646897386 + 21 +9.50362337545333 + 31 +0.0 + 11 +170.24094299344665 + 21 +0.3013416610065178 + 31 +0.0 + 11 +197.99999999999997 + 21 +-15.976488655184184 + 31 +0.0 + 11 +234.9046257179447 + 21 +-25.297453660262192 + 31 +0.0 + 11 +269.27146468973865 + 21 +-14.584348479358908 + 31 +0.0 + 11 +288.721713120081 + 21 +9.958715159939203 + 31 +0.0 + 11 +298.67959809340795 + 21 +34.40445520415064 + 31 +0.0 + 11 +308.6212812080841 + 21 +55.76933777594823 + 31 +0.0 + 11 +314.61707062052267 + 21 +78.0 + 31 +0.0 + 0 +SPLINE + 5 +AC +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +325.30619319380804 + 21 +76.0 + 31 +0.0 + 11 +323.6191801154361 + 21 +100.64178271637311 + 31 +0.0 + 11 +313.70008640020285 + 21 +124.80056891563837 + 31 +0.0 + 11 +299.94879679625774 + 21 +150.2115975971933 + 31 +0.0 + 11 +275.741903403096 + 21 +173.09258061142555 + 31 +0.0 + 11 +238.44639969149011 + 21 +180.81389965687947 + 31 +0.0 + 11 +201.0 + 21 +169.79364241341398 + 31 +0.0 + 11 +172.81066332134702 + 21 +154.9030275372984 + 31 +0.0 + 11 +144.741903403096 + 21 +149.08141123221665 + 31 +0.0 + 11 +108.74501000993425 + 21 +145.19124249254932 + 31 +0.0 + 11 +76.50991359979716 + 21 +129.90578867094766 + 31 +0.0 + 11 +62.429950065534825 + 21 +103.84729973131024 + 31 +0.0 + 11 +63.30619319380807 + 21 +76.00000000000001 + 31 +0.0 + 11 +70.5466136277002 + 21 +49.78384035372778 + 31 +0.0 + 11 +86.80143060867987 + 21 +26.550568915638408 + 31 +0.0 + 11 +114.6868201253822 + 21 +11.26511509403673 + 31 +0.0 + 11 +144.7419034030959 + 21 +2.918588767783362 + 31 +0.0 + 11 +170.6358098746297 + 21 +-8.990525208922435 + 31 +0.0 + 11 +200.99999999999997 + 21 +-26.706357586586023 + 31 +0.0 + 11 +240.62125313820738 + 21 +-34.90139732850352 + 31 +0.0 + 11 +275.741903403096 + 21 +-21.09258061142556 + 31 +0.0 + 11 +294.00698668080963 + 21 +6.2447599893927475 + 31 +0.0 + 11 +303.40856939132004 + 21 +31.655788670947608 + 31 +0.0 + 11 +315.50251655327065 + 21 +52.98935736866483 + 31 +0.0 + 11 +325.30619319380804 + 21 +76.0 + 31 +0.0 + 0 +SPLINE + 5 +AD +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MAJOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +337.4830200860729 + 21 +74.0 + 31 +0.0 + 11 +337.80607878372757 + 21 +100.88992306435622 + 31 +0.0 + 11 +326.40387787526345 + 21 +127.00243388085306 + 31 +0.0 + 11 +309.09448277351214 + 21 +152.8208620801341 + 31 +0.0 + 11 +281.4684899569636 + 21 +174.63452044332516 + 31 +0.0 + 11 +242.2054805241532 + 21 +180.93859583279217 + 31 +0.0 + 11 +204.0 + 21 +169.61711448079248 + 31 +0.0 + 11 +174.61853921606337 + 21 +156.239828334683 + 31 +0.0 + 11 +143.4684899569636 + 21 +152.63273814005362 + 31 +0.0 + 11 +103.42249714041503 + 21 +149.43312714468874 + 31 +0.0 + 11 +69.17612212473654 + 21 +132.38045163835443 + 31 +0.0 + 11 +56.852921562128785 + 21 +103.57095562702003 + 31 +0.0 + 11 +61.483020086072855 + 21 +74.00000000000001 + 31 +0.0 + 11 +71.21055072794468 + 21 +47.314380728134424 + 31 +0.0 + 11 +87.38086643075837 + 21 +23.502433880853104 + 31 +0.0 + 11 +113.93301116602497 + 21 +6.449758374518808 + 31 +0.0 + 11 +143.4684899569635 + 21 +-4.632738140053604 + 31 +0.0 + 11 +170.77142407585745 + 21 +-19.008050209044967 + 31 +0.0 + 11 +203.99999999999997 + 21 +-37.382885519207505 + 31 +0.0 + 11 +246.05259566435902 + 21 +-43.706817707154116 + 31 +0.0 + 11 +281.4684899569636 + 21 +-26.634520443325158 + 31 +0.0 + 11 +298.5839687479021 + 21 +3.0620234390733856 + 31 +0.0 + 11 +308.1991335692416 + 21 +28.88045163835438 + 31 +0.0 + 11 +323.44844961791154 + 21 +49.99541329079813 + 31 +0.0 + 11 +337.4830200860729 + 21 +74.0 + 31 +0.0 + 0 +TEXT + 5 +AE +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +338.4830200860729 + 20 +74.0 + 30 +0.0 + 40 +3.0 + 1 +130 + 7 +SITE + 11 +338.4830200860729 + 21 +74.0 + 31 +0.0 +100 +AcDbText + 73 +2 + 0 +SPLINE + 5 +AF +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbSpline + 70 +0 + 71 +3 + 72 +0 + 73 +0 + 74 +25 + 11 +351.11807155880115 + 21 +72.0 + 31 +0.0 + 11 +352.6097441378531 + 21 +101.2620100138801 + 31 +0.0 + 11 +338.6268386404416 + 21 +128.99609304122876 + 31 +0.0 + 11 +317.23344872074955 + 21 +154.67508654056215 + 31 +0.0 + 11 +286.46596422059946 + 21 +175.22931562689658 + 31 +0.0 + 11 +245.79929922142856 + 21 +180.60071699432928 + 31 +0.0 + 11 +207.0 + 21 +169.85699815784898 + 31 +0.0 + 11 +176.05793000529485 + 21 +158.6080329836941 + 31 +0.0 + 11 +141.4659642205995 + 21 +157.1312096962188 + 31 +0.0 + 11 +97.64847972044937 + 21 +154.01364020966298 + 31 +0.0 + 11 +62.323161359558384 + 21 +134.64690880092223 + 31 +0.0 + 11 +52.6510981942246 + 21 +103.01824769361616 + 31 +0.0 + 11 +61.11807155880112 + 21 +72.00000000000001 + 31 +0.0 + 11 +72.4912545140233 + 21 +44.96886770408183 + 31 +0.0 + 11 +87.47947154295434 + 21 +20.246093041228818 + 31 +0.0 + 11 +112.17248217665069 + 21 +0.8793616324880986 + 31 +0.0 + 11 +141.4659642205994 + 21 +-13.131209696218804 + 31 +0.0 + 11 +170.74177614169758 + 21 +-29.4881502235431 + 31 +0.0 + 11 +206.99999999999997 + 21 +-47.64300184215102 + 31 +0.0 + 11 +251.1154530850258 + 21 +-51.4808342341783 + 31 +0.0 + 11 +286.4659642205995 + 21 +-31.22931562689662 + 31 +0.0 + 11 +302.70944626454815 + 21 +0.2179153015888602 + 31 +0.0 + 11 +313.4705284570456 + 21 +25.896908800922198 + 31 +0.0 + 11 +332.76958781805433 + 21 +46.7251053838178 + 31 +0.0 + 11 +351.11807155880115 + 21 +72.0 + 31 +0.0 + 0 +LWPOLYLINE + 5 +B0 +330 +17 +100 +AcDbEntity + 8 +ROAD +100 +AcDbPolyline + 90 +5 + 70 +0 + 43 +9.0 + 10 +-40.0 + 20 +40.0 + 10 +60.0 + 20 +50.0 + 10 +140.0 + 20 +20.0 + 10 +260.0 + 20 +30.0 + 10 +360.0 + 20 +70.0 + 0 +LWPOLYLINE + 5 +B1 +330 +17 +100 +AcDbEntity + 8 +ROAD +100 +AcDbPolyline + 90 +3 + 70 +0 + 43 +6.0 + 10 +140.0 + 20 +20.0 + 10 +150.0 + 20 +-40.0 + 10 +130.0 + 20 +-90.0 + 0 +CIRCLE + 5 +B2 +330 +17 +100 +AcDbEntity + 8 +ROAD +100 +AcDbCircle + 10 +140.0 + 20 +20.0 + 30 +0.0 + 40 +12.0 + 0 +LWPOLYLINE + 5 +B3 +330 +17 +100 +AcDbEntity + 8 +PARCEL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20.0 + 20 +60.0 + 10 +120.0 + 20 +66.0 + 10 +116.0 + 20 +150.0 + 10 +14.0 + 20 +140.0 + 0 +MTEXT + 5 +B4 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +67.5 + 20 +64.0 + 30 +0.0 + 40 +5.0 + 71 +5 + 1 +Parcel 101\P8322 m² + 7 +SITE + 0 +LWPOLYLINE + 5 +B5 +330 +17 +100 +AcDbEntity + 8 +PARCEL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +120.0 + 20 +66.0 + 10 +230.0 + 20 +62.0 + 10 +240.0 + 20 +160.0 + 10 +116.0 + 20 +150.0 + 0 +MTEXT + 5 +B6 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +176.5 + 20 +69.5 + 30 +0.0 + 40 +5.0 + 71 +5 + 1 +Parcel 102\P10638 m² + 7 +SITE + 0 +LWPOLYLINE + 5 +B7 +330 +17 +100 +AcDbEntity + 8 +PARCEL +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +230.0 + 20 +62.0 + 10 +330.0 + 20 +80.0 + 10 +320.0 + 20 +170.0 + 10 +240.0 + 20 +160.0 + 0 +MTEXT + 5 +B8 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +280.0 + 20 +78.0 + 30 +0.0 + 40 +5.0 + 71 +5 + 1 +Parcel 103\P8460 m² + 7 +SITE + 0 +LWPOLYLINE + 5 +B9 +330 +17 +100 +AcDbEntity + 8 +BUILDING +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +40.0 + 20 +90.0 + 10 +80.0 + 20 +90.0 + 10 +80.0 + 20 +116.0 + 10 +40.0 + 20 +116.0 + 0 +HATCH + 5 +BA +330 +17 +100 +AcDbEntity + 8 +BUILDING + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +40.0 + 20 +90.0 + 10 +80.0 + 20 +90.0 + 10 +80.0 + 20 +116.0 + 10 +40.0 + 20 +116.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +3.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-6.7351920909 + 46 +6.7351920909 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +BB +330 +17 +100 +AcDbEntity + 8 +BUILDING +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +150.0 + 20 +95.0 + 10 +184.0 + 20 +95.0 + 10 +184.0 + 20 +125.0 + 10 +150.0 + 20 +125.0 + 0 +HATCH + 5 +BC +330 +17 +100 +AcDbEntity + 8 +BUILDING + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +150.0 + 20 +95.0 + 10 +184.0 + 20 +95.0 + 10 +184.0 + 20 +125.0 + 10 +150.0 + 20 +125.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +3.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-6.7351920909 + 46 +6.7351920909 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +BD +330 +17 +100 +AcDbEntity + 8 +BUILDING +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +262.0 + 20 +110.0 + 10 +306.0 + 20 +110.0 + 10 +306.0 + 20 +138.0 + 10 +262.0 + 20 +138.0 + 0 +HATCH + 5 +BE +330 +17 +100 +AcDbEntity + 8 +BUILDING + 62 +8 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +ANSI31 + 70 +0 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +262.0 + 20 +110.0 + 10 +306.0 + 20 +110.0 + 10 +306.0 + 20 +138.0 + 10 +262.0 + 20 +138.0 + 97 +0 + 75 +1 + 76 +1 + 52 +0.0 + 41 +3.0 + 77 +0 + 78 +1 + 53 +45.0 + 43 +0.0 + 44 +0.0 + 45 +-6.7351920909 + 46 +6.7351920909 + 79 +0 + 98 +0 + 0 +LWPOLYLINE + 5 +BF +330 +17 +100 +AcDbEntity + 8 +WATER +100 +AcDbPolyline + 90 +5 + 70 +1 + 10 +280.0 + 20 +-10.0 + 10 +310.0 + 20 +-25.0 + 10 +340.0 + 20 +-5.0 + 10 +325.0 + 20 +20.0 + 10 +295.0 + 20 +18.0 + 0 +HATCH + 5 +C0 +330 +17 +100 +AcDbEntity + 8 +WATER + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +5 + 10 +280.0 + 20 +-10.0 + 10 +310.0 + 20 +-25.0 + 10 +340.0 + 20 +-5.0 + 10 +325.0 + 20 +20.0 + 10 +295.0 + 20 +18.0 + 97 +0 + 75 +0 + 76 +1 + 98 +0 +450 +1 +451 +0 +460 +1.5707963267948966 +461 +0.0 +452 +0 +462 +0.0 +453 +2 +463 +0 +421 +9226495 +463 +1 +421 +1989290 +470 +LINEAR + 0 +CIRCLE + 5 +C1 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +204.2446101602927 + 20 +1.2680483891094099 + 30 +0.0 + 40 +2.851829061461684 + 0 +CIRCLE + 5 +C2 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +312.03699879955013 + 20 +-105.6492157574578 + 30 +0.0 + 40 +2.291558512442779 + 0 +CIRCLE + 5 +C3 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +304.3241770302242 + 20 +-93.01910530202835 + 30 +0.0 + 40 +2.2974173812296783 + 0 +CIRCLE + 5 +C4 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +68.76621974313892 + 20 +-28.435871114610435 + 30 +0.0 + 40 +2.475700019477771 + 0 +CIRCLE + 5 +C5 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +-15.278891747995207 + 20 +-77.49052993042278 + 30 +0.0 + 40 +1.9751200222188876 + 0 +CIRCLE + 5 +C6 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +37.45751644889376 + 20 +9.572048714680676 + 30 +0.0 + 40 +1.7359046112781353 + 0 +CIRCLE + 5 +C7 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +202.28290736780198 + 20 +-90.99511511745955 + 30 +0.0 + 40 +1.5030172657443088 + 0 +CIRCLE + 5 +C8 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +293.70570810074156 + 20 +-78.58154262573231 + 30 +0.0 + 40 +1.8663179876820448 + 0 +CIRCLE + 5 +C9 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +84.14986038889354 + 20 +34.221698342512525 + 30 +0.0 + 40 +2.4166798970803782 + 0 +CIRCLE + 5 +CA +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +224.01897181021323 + 20 +-79.28307281993108 + 30 +0.0 + 40 +3.0996592018495983 + 0 +CIRCLE + 5 +CB +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +228.63109879848696 + 20 +34.98464684757931 + 30 +0.0 + 40 +3.0193608518800135 + 0 +CIRCLE + 5 +CC +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +87.56400322739239 + 20 +-55.82150979164238 + 30 +0.0 + 40 +1.7821252971205674 + 0 +CIRCLE + 5 +CD +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +32.4526874346457 + 20 +-100.22904299364856 + 30 +0.0 + 40 +2.012310471308086 + 0 +CIRCLE + 5 +CE +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +197.11959906675557 + 20 +-109.49253209384649 + 30 +0.0 + 40 +2.6524882242310754 + 0 +CIRCLE + 5 +CF +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +101.64287018603144 + 20 +-63.50631025953068 + 30 +0.0 + 40 +2.8914807269000207 + 0 +CIRCLE + 5 +D0 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +153.068267186814 + 20 +-62.631034123033395 + 30 +0.0 + 40 +2.3180712566026687 + 0 +CIRCLE + 5 +D1 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +233.68088829072732 + 20 +-101.44986056963151 + 30 +0.0 + 40 +3.1576692573452 + 0 +CIRCLE + 5 +D2 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +-11.768397229020543 + 20 +2.469253343690994 + 30 +0.0 + 40 +2.9362975119598205 + 0 +CIRCLE + 5 +D3 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +-13.495687263729156 + 20 +8.16074559706513 + 30 +0.0 + 40 +2.122513608931163 + 0 +CIRCLE + 5 +D4 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +188.26677846047485 + 20 +-108.63824197707073 + 30 +0.0 + 40 +1.5794361017882006 + 0 +CIRCLE + 5 +D5 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +45.13101566237722 + 20 +33.27698493867496 + 30 +0.0 + 40 +1.834086839872114 + 0 +CIRCLE + 5 +D6 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +252.0651084824744 + 20 +29.448297939628162 + 30 +0.0 + 40 +3.101474510027089 + 0 +CIRCLE + 5 +D7 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +103.97745269908431 + 20 +-56.78101924131317 + 30 +0.0 + 40 +2.391993095178304 + 0 +CIRCLE + 5 +D8 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +259.2170852916383 + 20 +-93.79206964027506 + 30 +0.0 + 40 +2.772276696023927 + 0 +CIRCLE + 5 +D9 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +267.0016039328798 + 20 +18.954147878137007 + 30 +0.0 + 40 +1.5622736859028068 + 0 +CIRCLE + 5 +DA +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +320.48806661518614 + 20 +-96.32302037423472 + 30 +0.0 + 40 +2.079258910357178 + 0 +CIRCLE + 5 +DB +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +199.89791371334474 + 20 +27.71307782999682 + 30 +0.0 + 40 +2.077931195263936 + 0 +CIRCLE + 5 +DC +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +92.48213296344649 + 20 +-62.48000215049342 + 30 +0.0 + 40 +1.8017122183584806 + 0 +CIRCLE + 5 +DD +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +8.150643599406454 + 20 +-87.66979330651272 + 30 +0.0 + 40 +2.6715967984930877 + 0 +CIRCLE + 5 +DE +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +338.8216532332134 + 20 +-85.77058033630756 + 30 +0.0 + 40 +1.5825386780323756 + 0 +CIRCLE + 5 +DF +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +126.11968764258575 + 20 +-74.39950942185689 + 30 +0.0 + 40 +2.509732375178605 + 0 +CIRCLE + 5 +E0 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +277.4663842475056 + 20 +-41.65023477640028 + 30 +0.0 + 40 +2.216987379249702 + 0 +CIRCLE + 5 +E1 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +0.05460316284163724 + 20 +27.410402266298632 + 30 +0.0 + 40 +1.5556260739477688 + 0 +CIRCLE + 5 +E2 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +157.68311143149958 + 20 +15.764392175219825 + 30 +0.0 + 40 +1.721972106160304 + 0 +CIRCLE + 5 +E3 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +243.39919241560352 + 20 +32.46978439328481 + 30 +0.0 + 40 +2.5716787988248706 + 0 +CIRCLE + 5 +E4 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +263.6834379209285 + 20 +-94.00540854541117 + 30 +0.0 + 40 +2.238743824020743 + 0 +CIRCLE + 5 +E5 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +33.72832403449282 + 20 +16.71008455080444 + 30 +0.0 + 40 +2.001181974560351 + 0 +CIRCLE + 5 +E6 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +143.13576885999143 + 20 +39.894992210420185 + 30 +0.0 + 40 +2.9488295405193634 + 0 +CIRCLE + 5 +E7 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +331.3627251087871 + 20 +-41.969001561256505 + 30 +0.0 + 40 +2.329870297543901 + 0 +CIRCLE + 5 +E8 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +242.6218071160675 + 20 +-38.14363247822725 + 30 +0.0 + 40 +1.9947384491498574 + 0 +CIRCLE + 5 +E9 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +125.3641894807649 + 20 +-88.02404562470278 + 30 +0.0 + 40 +2.1409012712351494 + 0 +CIRCLE + 5 +EA +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +335.81957214646536 + 20 +33.97238252244469 + 30 +0.0 + 40 +2.5658404506232264 + 0 +CIRCLE + 5 +EB +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +159.75608223980376 + 20 +-59.22818595902589 + 30 +0.0 + 40 +1.6515336076629565 + 0 +CIRCLE + 5 +EC +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +78.03164828914694 + 20 +7.302845658474254 + 30 +0.0 + 40 +2.9745575715214136 + 0 +CIRCLE + 5 +ED +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +110.07744388818116 + 20 +7.903386945074544 + 30 +0.0 + 40 +2.817326756601691 + 0 +CIRCLE + 5 +EE +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +230.054829802322 + 20 +-10.39723325410651 + 30 +0.0 + 40 +2.7913858519471324 + 0 +CIRCLE + 5 +EF +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +110.83543480450777 + 20 +-4.329563898994536 + 30 +0.0 + 40 +1.9774484948806108 + 0 +CIRCLE + 5 +F0 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +154.84668057671962 + 20 +5.462120973642854 + 30 +0.0 + 40 +2.674501025804691 + 0 +CIRCLE + 5 +F1 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +85.78673649190924 + 20 +31.83213301756237 + 30 +0.0 + 40 +2.6044766538121102 + 0 +CIRCLE + 5 +F2 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +189.03789748740132 + 20 +-108.26290144662875 + 30 +0.0 + 40 +2.4298845165619176 + 0 +CIRCLE + 5 +F3 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +70.24922368918189 + 20 +-9.253561414385103 + 30 +0.0 + 40 +2.2869962810799858 + 0 +CIRCLE + 5 +F4 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +267.14534080300797 + 20 +-57.81732379584502 + 30 +0.0 + 40 +2.5949072240851008 + 0 +CIRCLE + 5 +F5 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbCircle + 10 +245.61784697700637 + 20 +14.228300238179813 + 30 +0.0 + 40 +2.0950767574307805 + 0 +POINT + 5 +F6 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +278.1502637782402 + 20 +136.1769256399019 + 30 +0.0 + 0 +TEXT + 5 +F7 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +279.6502637782402 + 20 +136.6769256399019 + 30 +0.0 + 40 +1.8 + 1 +97.04 + 7 +SITE + 11 +279.6502637782402 + 21 +136.6769256399019 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +F8 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +227.15119576773597 + 20 +163.79176895850463 + 30 +0.0 + 0 +TEXT + 5 +F9 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +228.65119576773597 + 20 +164.29176895850463 + 30 +0.0 + 40 +1.8 + 1 +106.61 + 7 +SITE + 11 +228.65119576773597 + 21 +164.29176895850463 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +FA +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +315.65043740221824 + 20 +44.71625191631267 + 30 +0.0 + 0 +TEXT + 5 +FB +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +317.15043740221824 + 20 +45.21625191631267 + 30 +0.0 + 40 +1.8 + 1 +109.24 + 7 +SITE + 11 +317.15043740221824 + 21 +45.21625191631267 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +FC +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +174.68222665390067 + 20 +-46.79511940740176 + 30 +0.0 + 0 +TEXT + 5 +FD +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +176.18222665390067 + 20 +-46.29511940740176 + 30 +0.0 + 40 +1.8 + 1 +89.47 + 7 +SITE + 11 +176.18222665390067 + 21 +-46.29511940740176 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +FE +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +276.08469284675675 + 20 +153.71923969687705 + 30 +0.0 + 0 +TEXT + 5 +FF +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +277.58469284675675 + 20 +154.21923969687705 + 30 +0.0 + 40 +1.8 + 1 +98.95 + 7 +SITE + 11 +277.58469284675675 + 21 +154.21923969687705 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +100 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +157.48884784685973 + 20 +89.77065205944737 + 30 +0.0 + 0 +TEXT + 5 +101 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +158.98884784685973 + 20 +90.27065205944737 + 30 +0.0 + 40 +1.8 + 1 +99.23 + 7 +SITE + 11 +158.98884784685973 + 21 +90.27065205944737 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +102 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +237.49653705495027 + 20 +99.89189064744619 + 30 +0.0 + 0 +TEXT + 5 +103 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +238.99653705495027 + 20 +100.39189064744619 + 30 +0.0 + 40 +1.8 + 1 +101.39 + 7 +SITE + 11 +238.99653705495027 + 21 +100.39189064744619 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +104 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +56.702829029690825 + 20 +112.8966233388964 + 30 +0.0 + 0 +TEXT + 5 +105 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +58.202829029690825 + 20 +113.3966233388964 + 30 +0.0 + 40 +1.8 + 1 +89.38 + 7 +SITE + 11 +58.202829029690825 + 21 +113.3966233388964 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +106 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +191.67894907616233 + 20 +83.04461958611074 + 30 +0.0 + 0 +TEXT + 5 +107 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +193.17894907616233 + 20 +83.54461958611074 + 30 +0.0 + 40 +1.8 + 1 +108.85 + 7 +SITE + 11 +193.17894907616233 + 21 +83.54461958611074 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +108 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +138.8613700857744 + 20 +72.17164429898583 + 30 +0.0 + 0 +TEXT + 5 +109 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +140.3613700857744 + 20 +72.67164429898583 + 30 +0.0 + 40 +1.8 + 1 +93.96 + 7 +SITE + 11 +140.3613700857744 + 21 +72.67164429898583 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +10A +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +255.6525661509654 + 20 +75.58533378231209 + 30 +0.0 + 0 +TEXT + 5 +10B +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +257.1525661509654 + 20 +76.08533378231209 + 30 +0.0 + 40 +1.8 + 1 +103.97 + 7 +SITE + 11 +257.1525661509654 + 21 +76.08533378231209 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +10C +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +237.7367766103562 + 20 +-82.81827590722068 + 30 +0.0 + 0 +TEXT + 5 +10D +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +239.2367766103562 + 20 +-82.31827590722068 + 30 +0.0 + 40 +1.8 + 1 +111.09 + 7 +SITE + 11 +239.2367766103562 + 21 +-82.31827590722068 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +10E +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +52.80761237090758 + 20 +24.677788036008693 + 30 +0.0 + 0 +TEXT + 5 +10F +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +54.30761237090758 + 20 +25.177788036008693 + 30 +0.0 + 40 +1.8 + 1 +105.79 + 7 +SITE + 11 +54.30761237090758 + 21 +25.177788036008693 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +110 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +214.53863091502416 + 20 +-33.050077279364636 + 30 +0.0 + 0 +TEXT + 5 +111 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +216.03863091502416 + 20 +-32.550077279364636 + 30 +0.0 + 40 +1.8 + 1 +100.20 + 7 +SITE + 11 +216.03863091502416 + 21 +-32.550077279364636 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +112 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +226.36588655096244 + 20 +74.02494212091122 + 30 +0.0 + 0 +TEXT + 5 +113 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +227.86588655096244 + 20 +74.52494212091122 + 30 +0.0 + 40 +1.8 + 1 +111.23 + 7 +SITE + 11 +227.86588655096244 + 21 +74.52494212091122 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +114 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +13.814362775791947 + 20 +32.61223118556728 + 30 +0.0 + 0 +TEXT + 5 +115 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +15.314362775791947 + 20 +33.11223118556728 + 30 +0.0 + 40 +1.8 + 1 +104.03 + 7 +SITE + 11 +15.314362775791947 + 21 +33.11223118556728 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +116 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +74.66005656641728 + 20 +-75.92281720138668 + 30 +0.0 + 0 +TEXT + 5 +117 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +76.16005656641728 + 20 +-75.42281720138668 + 30 +0.0 + 40 +1.8 + 1 +94.51 + 7 +SITE + 11 +76.16005656641728 + 21 +-75.42281720138668 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +118 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +44.06387199888762 + 20 +-7.488781126775365 + 30 +0.0 + 0 +TEXT + 5 +119 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +45.56387199888762 + 20 +-6.988781126775365 + 30 +0.0 + 40 +1.8 + 1 +118.38 + 7 +SITE + 11 +45.56387199888762 + 21 +-6.988781126775365 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +11A +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +59.910580361473656 + 20 +-39.7263735341773 + 30 +0.0 + 0 +TEXT + 5 +11B +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +61.410580361473656 + 20 +-39.2263735341773 + 30 +0.0 + 40 +1.8 + 1 +113.50 + 7 +SITE + 11 +61.410580361473656 + 21 +-39.2263735341773 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +11C +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +11.766991668281122 + 20 +30.985842580243627 + 30 +0.0 + 0 +TEXT + 5 +11D +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +13.266991668281122 + 20 +31.485842580243627 + 30 +0.0 + 40 +1.8 + 1 +104.07 + 7 +SITE + 11 +13.266991668281122 + 21 +31.485842580243627 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +11E +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +125.4986206340262 + 20 +69.06642719145128 + 30 +0.0 + 0 +TEXT + 5 +11F +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +126.9986206340262 + 20 +69.56642719145128 + 30 +0.0 + 40 +1.8 + 1 +99.26 + 7 +SITE + 11 +126.9986206340262 + 21 +69.56642719145128 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +120 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +194.75425271273303 + 20 +-28.15993348744543 + 30 +0.0 + 0 +TEXT + 5 +121 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +196.25425271273303 + 20 +-27.65993348744543 + 30 +0.0 + 40 +1.8 + 1 +96.10 + 7 +SITE + 11 +196.25425271273303 + 21 +-27.65993348744543 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +122 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +298.0501666175098 + 20 +-89.82823989439535 + 30 +0.0 + 0 +TEXT + 5 +123 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +299.5501666175098 + 20 +-89.32823989439535 + 30 +0.0 + 40 +1.8 + 1 +97.12 + 7 +SITE + 11 +299.5501666175098 + 21 +-89.32823989439535 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +124 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +133.77399701025467 + 20 +-17.582701574845416 + 30 +0.0 + 0 +TEXT + 5 +125 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +135.27399701025467 + 20 +-17.082701574845416 + 30 +0.0 + 40 +1.8 + 1 +87.04 + 7 +SITE + 11 +135.27399701025467 + 21 +-17.082701574845416 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +126 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +135.31396385962077 + 20 +-60.08064598513852 + 30 +0.0 + 0 +TEXT + 5 +127 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +136.81396385962077 + 20 +-59.58064598513852 + 30 +0.0 + 40 +1.8 + 1 +93.24 + 7 +SITE + 11 +136.81396385962077 + 21 +-59.58064598513852 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +128 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +274.35211971841903 + 20 +7.208710541847154 + 30 +0.0 + 0 +TEXT + 5 +129 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +275.85211971841903 + 20 +7.708710541847154 + 30 +0.0 + 40 +1.8 + 1 +126.01 + 7 +SITE + 11 +275.85211971841903 + 21 +7.708710541847154 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +12A +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +11.90049649386008 + 20 +69.52666370838458 + 30 +0.0 + 0 +TEXT + 5 +12B +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +13.40049649386008 + 20 +70.02666370838458 + 30 +0.0 + 40 +1.8 + 1 +87.73 + 7 +SITE + 11 +13.40049649386008 + 21 +70.02666370838458 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +12C +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +31.291149090549617 + 20 +51.757424423757186 + 30 +0.0 + 0 +TEXT + 5 +12D +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +32.79114909054962 + 20 +52.257424423757186 + 30 +0.0 + 40 +1.8 + 1 +93.73 + 7 +SITE + 11 +32.79114909054962 + 21 +52.257424423757186 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +12E +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +111.99319313365189 + 20 +61.03302192859255 + 30 +0.0 + 0 +TEXT + 5 +12F +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +113.49319313365189 + 20 +61.53302192859255 + 30 +0.0 + 40 +1.8 + 1 +105.95 + 7 +SITE + 11 +113.49319313365189 + 21 +61.53302192859255 + 31 +0.0 +100 +AcDbText + 0 +POINT + 5 +130 +330 +17 +100 +AcDbEntity + 8 +SPOT + 62 +2 +100 +AcDbPoint + 10 +316.2391662302166 + 20 +122.81571994410109 + 30 +0.0 + 0 +TEXT + 5 +131 +330 +17 +100 +AcDbEntity + 8 +SPOT +100 +AcDbText + 10 +317.7391662302166 + 20 +123.31571994410109 + 30 +0.0 + 40 +1.8 + 1 +101.01 + 7 +SITE + 11 +317.7391662302166 + 21 +123.31571994410109 + 31 +0.0 +100 +AcDbText + 0 +INSERT + 5 +132 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbBlockReference + 2 +NORTH + 10 +370.0 + 20 +180.0 + 30 +0.0 + 41 +0.02 + 42 +0.02 + 43 +0.02 + 0 +LWPOLYLINE + 5 +134 +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +-130.0 + 10 +20.0 + 20 +-130.0 + 10 +20.0 + 20 +-125.0 + 10 +0.0 + 20 +-125.0 + 0 +HATCH + 5 +135 +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +252 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +0.0 + 20 +-130.0 + 10 +20.0 + 20 +-130.0 + 10 +20.0 + 20 +-125.0 + 10 +0.0 + 20 +-125.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +136 +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +7 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20.0 + 20 +-130.0 + 10 +40.0 + 20 +-130.0 + 10 +40.0 + 20 +-125.0 + 10 +20.0 + 20 +-125.0 + 0 +HATCH + 5 +137 +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +20.0 + 20 +-130.0 + 10 +40.0 + 20 +-130.0 + 10 +40.0 + 20 +-125.0 + 10 +20.0 + 20 +-125.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +138 +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +40.0 + 20 +-130.0 + 10 +60.0 + 20 +-130.0 + 10 +60.0 + 20 +-125.0 + 10 +40.0 + 20 +-125.0 + 0 +HATCH + 5 +139 +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +252 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +40.0 + 20 +-130.0 + 10 +60.0 + 20 +-130.0 + 10 +60.0 + 20 +-125.0 + 10 +40.0 + 20 +-125.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +13A +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +7 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +60.0 + 20 +-130.0 + 10 +80.0 + 20 +-130.0 + 10 +80.0 + 20 +-125.0 + 10 +60.0 + 20 +-125.0 + 0 +HATCH + 5 +13B +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +7 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +60.0 + 20 +-130.0 + 10 +80.0 + 20 +-130.0 + 10 +80.0 + 20 +-125.0 + 10 +60.0 + 20 +-125.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +LWPOLYLINE + 5 +13C +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +8 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +80.0 + 20 +-130.0 + 10 +100.0 + 20 +-130.0 + 10 +100.0 + 20 +-125.0 + 10 +80.0 + 20 +-125.0 + 0 +HATCH + 5 +13D +330 +17 +100 +AcDbEntity + 8 +LEGEND + 62 +252 +100 +AcDbHatch + 10 +0.0 + 20 +0.0 + 30 +0.0 +210 +0.0 +220 +0.0 +230 +1.0 + 2 +SOLID + 70 +1 + 71 +0 + 91 +1 + 92 +3 + 72 +0 + 73 +1 + 93 +4 + 10 +80.0 + 20 +-130.0 + 10 +100.0 + 20 +-130.0 + 10 +100.0 + 20 +-125.0 + 10 +80.0 + 20 +-125.0 + 97 +0 + 75 +1 + 76 +1 + 98 +0 + 0 +TEXT + 5 +13E +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +0.0 + 20 +-138.0 + 30 +0.0 + 40 +3.0 + 1 +0 + 7 +SITE + 72 +1 + 11 +0.0 + 21 +-138.0 + 31 +0.0 +100 +AcDbText + 73 +3 + 0 +TEXT + 5 +13F +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +20.0 + 20 +-138.0 + 30 +0.0 + 40 +3.0 + 1 +20 + 7 +SITE + 72 +1 + 11 +20.0 + 21 +-138.0 + 31 +0.0 +100 +AcDbText + 73 +3 + 0 +TEXT + 5 +140 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +40.0 + 20 +-138.0 + 30 +0.0 + 40 +3.0 + 1 +40 + 7 +SITE + 72 +1 + 11 +40.0 + 21 +-138.0 + 31 +0.0 +100 +AcDbText + 73 +3 + 0 +TEXT + 5 +141 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +60.0 + 20 +-138.0 + 30 +0.0 + 40 +3.0 + 1 +60 + 7 +SITE + 72 +1 + 11 +60.0 + 21 +-138.0 + 31 +0.0 +100 +AcDbText + 73 +3 + 0 +TEXT + 5 +142 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +80.0 + 20 +-138.0 + 30 +0.0 + 40 +3.0 + 1 +80 + 7 +SITE + 72 +1 + 11 +80.0 + 21 +-138.0 + 31 +0.0 +100 +AcDbText + 73 +3 + 0 +TEXT + 5 +143 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +100.0 + 20 +-138.0 + 30 +0.0 + 40 +3.0 + 1 +100 + 7 +SITE + 72 +1 + 11 +100.0 + 21 +-138.0 + 31 +0.0 +100 +AcDbText + 73 +3 + 0 +TEXT + 5 +144 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +108.0 + 20 +-138.0 + 30 +0.0 + 40 +3.0 + 1 +m + 7 +SITE + 11 +108.0 + 21 +-138.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +145 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MINOR +100 +AcDbLine + 10 +370.0 + 20 +170.0 + 30 +0.0 + 11 +390.0 + 21 +170.0 + 31 +0.0 + 0 +TEXT + 5 +146 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +394.0 + 20 +169.0 + 30 +0.0 + 40 +3.0 + 1 +Contour, 2 m + 7 +SITE + 11 +394.0 + 21 +169.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +147 +330 +17 +100 +AcDbEntity + 8 +CONTOUR-MAJOR +100 +AcDbLine + 10 +370.0 + 20 +162.0 + 30 +0.0 + 11 +390.0 + 21 +162.0 + 31 +0.0 + 0 +TEXT + 5 +148 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +394.0 + 20 +161.0 + 30 +0.0 + 40 +3.0 + 1 +Contour, 10 m + 7 +SITE + 11 +394.0 + 21 +161.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +149 +330 +17 +100 +AcDbEntity + 8 +PARCEL +100 +AcDbLine + 10 +370.0 + 20 +154.0 + 30 +0.0 + 11 +390.0 + 21 +154.0 + 31 +0.0 + 0 +TEXT + 5 +14A +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +394.0 + 20 +153.0 + 30 +0.0 + 40 +3.0 + 1 +Parcel boundary + 7 +SITE + 11 +394.0 + 21 +153.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +14B +330 +17 +100 +AcDbEntity + 8 +BUILDING +100 +AcDbLine + 10 +370.0 + 20 +146.0 + 30 +0.0 + 11 +390.0 + 21 +146.0 + 31 +0.0 + 0 +TEXT + 5 +14C +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +394.0 + 20 +145.0 + 30 +0.0 + 40 +3.0 + 1 +Building + 7 +SITE + 11 +394.0 + 21 +145.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +14D +330 +17 +100 +AcDbEntity + 8 +ROAD +100 +AcDbLine + 10 +370.0 + 20 +138.0 + 30 +0.0 + 11 +390.0 + 21 +138.0 + 31 +0.0 + 0 +TEXT + 5 +14E +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +394.0 + 20 +137.0 + 30 +0.0 + 40 +3.0 + 1 +Road + 7 +SITE + 11 +394.0 + 21 +137.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +14F +330 +17 +100 +AcDbEntity + 8 +WATER +100 +AcDbLine + 10 +370.0 + 20 +130.0 + 30 +0.0 + 11 +390.0 + 21 +130.0 + 31 +0.0 + 0 +TEXT + 5 +150 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +394.0 + 20 +129.0 + 30 +0.0 + 40 +3.0 + 1 +Water + 7 +SITE + 11 +394.0 + 21 +129.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +151 +330 +17 +100 +AcDbEntity + 8 +TREES +100 +AcDbLine + 10 +370.0 + 20 +122.0 + 30 +0.0 + 11 +390.0 + 21 +122.0 + 31 +0.0 + 0 +TEXT + 5 +152 +330 +17 +100 +AcDbEntity + 8 +LEGEND +100 +AcDbText + 10 +394.0 + 20 +121.0 + 30 +0.0 + 40 +3.0 + 1 +Tree + 7 +SITE + 11 +394.0 + 21 +121.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +153 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-160.0 + 30 +0.0 + 40 +8.0 + 1 +SITE PLAN 1:1000 + 7 +SITE + 11 +0.0 + 21 +-160.0 + 31 +0.0 +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +157 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +DICTIONARYVAR + 5 +157 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/cad/text-styles.dxf b/crates/exav-viewer/demo/examples/files/cad/text-styles.dxf new file mode 100644 index 00000000..25fe2cbe --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/cad/text-styles.dxf @@ -0,0 +1,5696 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1032 + 9 +$ACADMAINTVER + 90 +4 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$REQUIREDVERSIONS +160 +0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2451545.0 + 9 +$TDUCREATE + 40 +2451545.0 + 9 +$TDUPDATE + 40 +2451545.0 + 9 +$TDUUPDATE + 40 +2451545.0 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +87 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$VERSIONGUID + 2 +{00000000-0000-0000-0000-000000000000} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +83 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +305.0 + 22 +-179.42799732083054 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +389.3987943737442 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +4 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TEXT + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +NOTE + 70 +0 + 62 +8 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +11 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +43 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-ARIAL + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +STYLE + 5 +44 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-TIMES + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +times.ttf + 4 + + 0 +STYLE + 5 +45 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-COURIER + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +cour.ttf + 4 + + 0 +STYLE + 5 +46 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-NARROW + 70 +0 + 40 +0.0 + 41 +0.6 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +STYLE + 5 +47 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-WIDE + 70 +0 + 40 +0.0 + 41 +1.6 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +STYLE + 5 +48 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-SLANT + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +20.0 + 71 +0 + 42 +2.5 + 3 +times.ttf + 4 + + 0 +STYLE + 5 +49 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-ROMANS + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +romans.shx + 4 + + 0 +STYLE + 5 +4A +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-SIMPLEX + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +simplex.shx + 4 + + 0 +STYLE + 5 +4B +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-BACKWARDS + 70 +2 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +STYLE + 5 +4C +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +S-UPSIDE + 70 +4 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +arial.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +84 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +85 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +TEXT + 5 +4D +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +6.0 + 1 +S-ARIAL (arial.ttf): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-ARIAL + 11 +0.0 + 21 +0.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +4E +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-12.0 + 30 +0.0 + 40 +6.0 + 1 +S-TIMES (times.ttf): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-TIMES + 11 +0.0 + 21 +-12.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +4F +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-24.0 + 30 +0.0 + 40 +6.0 + 1 +S-COURIER (cour.ttf): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-COURIER + 11 +0.0 + 21 +-24.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +50 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-36.0 + 30 +0.0 + 40 +6.0 + 1 +S-NARROW (arial.ttf): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-NARROW + 11 +0.0 + 21 +-36.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +51 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-48.0 + 30 +0.0 + 40 +6.0 + 1 +S-WIDE (arial.ttf): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-WIDE + 11 +0.0 + 21 +-48.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +52 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-60.0 + 30 +0.0 + 40 +6.0 + 1 +S-SLANT (times.ttf): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-SLANT + 11 +0.0 + 21 +-60.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +53 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-72.0 + 30 +0.0 + 40 +6.0 + 1 +S-ROMANS (romans.shx): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-ROMANS + 11 +0.0 + 21 +-72.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +54 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-84.0 + 30 +0.0 + 40 +6.0 + 1 +S-SIMPLEX (simplex.shx): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-SIMPLEX + 11 +0.0 + 21 +-84.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +55 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-96.0 + 30 +0.0 + 40 +6.0 + 1 +S-BACKWARDS (arial.ttf): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-BACKWARDS + 11 +0.0 + 21 +-96.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +56 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-108.0 + 30 +0.0 + 40 +6.0 + 1 +S-UPSIDE (arial.ttf): Sphinx of black quartz, judge my vow 0123456789 + 7 +S-UPSIDE + 11 +0.0 + 21 +-108.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +57 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +15.0 + 20 +-126.0 + 30 +0.0 + 11 +25.0 + 21 +-126.0 + 31 +0.0 + 0 +LINE + 5 +58 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +20.0 + 20 +-131.0 + 30 +0.0 + 11 +20.0 + 21 +-121.0 + 31 +0.0 + 0 +TEXT + 5 +59 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +20.0 + 20 +-126.0 + 30 +0.0 + 40 +6.0 + 1 +TOP_LEFT + 7 +S-ARIAL + 11 +20.0 + 21 +-126.0 + 31 +0.0 +100 +AcDbText + 73 +3 + 0 +LINE + 5 +5A +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +145.0 + 20 +-126.0 + 30 +0.0 + 11 +155.0 + 21 +-126.0 + 31 +0.0 + 0 +LINE + 5 +5B +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +150.0 + 20 +-131.0 + 30 +0.0 + 11 +150.0 + 21 +-121.0 + 31 +0.0 + 0 +TEXT + 5 +5C +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +150.0 + 20 +-126.0 + 30 +0.0 + 40 +6.0 + 1 +MIDDLE_CENTER + 7 +S-ARIAL + 72 +1 + 11 +150.0 + 21 +-126.0 + 31 +0.0 +100 +AcDbText + 73 +2 + 0 +LINE + 5 +5D +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +275.0 + 20 +-126.0 + 30 +0.0 + 11 +285.0 + 21 +-126.0 + 31 +0.0 + 0 +LINE + 5 +5E +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +280.0 + 20 +-131.0 + 30 +0.0 + 11 +280.0 + 21 +-121.0 + 31 +0.0 + 0 +TEXT + 5 +5F +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +280.0 + 20 +-126.0 + 30 +0.0 + 40 +6.0 + 1 +BOTTOM_RIGHT + 7 +S-ARIAL + 72 +2 + 11 +280.0 + 21 +-126.0 + 31 +0.0 +100 +AcDbText + 73 +1 + 0 +LINE + 5 +60 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +405.0 + 20 +-126.0 + 30 +0.0 + 11 +415.0 + 21 +-126.0 + 31 +0.0 + 0 +LINE + 5 +61 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +410.0 + 20 +-131.0 + 30 +0.0 + 11 +410.0 + 21 +-121.0 + 31 +0.0 + 0 +TEXT + 5 +62 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +410.0 + 20 +-126.0 + 30 +0.0 + 40 +6.0 + 1 +ALIGNED + 7 +S-ARIAL + 72 +3 + 11 +480.0 + 21 +-126.0 + 31 +0.0 +100 +AcDbText + 0 +LINE + 5 +63 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +535.0 + 20 +-126.0 + 30 +0.0 + 11 +545.0 + 21 +-126.0 + 31 +0.0 + 0 +LINE + 5 +64 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbLine + 10 +540.0 + 20 +-131.0 + 30 +0.0 + 11 +540.0 + 21 +-121.0 + 31 +0.0 + 0 +TEXT + 5 +65 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +540.0 + 20 +-126.0 + 30 +0.0 + 40 +6.0 + 1 +FIT + 7 +S-ARIAL + 72 +5 + 11 +610.0 + 21 +-126.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +66 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +20.0 + 20 +-150.0 + 30 +0.0 + 40 +6.0 + 1 +rotated 0 degrees + 7 +S-TIMES + 11 +20.0 + 21 +-150.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +67 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +65.0 + 20 +-150.0 + 30 +0.0 + 40 +6.0 + 1 +rotated 30 degrees + 50 +30.0 + 7 +S-TIMES + 11 +65.0 + 21 +-150.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +68 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +110.0 + 20 +-150.0 + 30 +0.0 + 40 +6.0 + 1 +rotated 60 degrees + 50 +60.0 + 7 +S-TIMES + 11 +110.0 + 21 +-150.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +69 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +155.0 + 20 +-150.0 + 30 +0.0 + 40 +6.0 + 1 +rotated 90 degrees + 50 +90.0 + 7 +S-TIMES + 11 +155.0 + 21 +-150.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +6A +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-220.0 + 30 +0.0 + 40 +5.0 + 1 +Special: %%d degree 45%%d, %%p plus/minus 0.5, %%c diameter 25, %%%% percent, %%u under%%u, %%o over%%o + 7 +S-ARIAL + 11 +0.0 + 21 +-220.0 + 31 +0.0 +100 +AcDbText + 0 +TEXT + 5 +6B +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbText + 10 +0.0 + 20 +-230.0 + 30 +0.0 + 40 +5.0 + 1 +Unicode: éèê üöä ß ñ ΔΩπ ЖÑ� € ½ ²³ + 7 +S-ARIAL + 11 +0.0 + 21 +-230.0 + 31 +0.0 +100 +AcDbText + 0 +MTEXT + 5 +6C +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +0.0 + 20 +-250.0 + 30 +0.0 + 40 +4.0 + 41 +120.0 + 71 +1 + 3 +{\C1;Red}, {\C3;green}, {\C5;blue} and {\c16744192;orange by RGB}.\P{\fTimes New Roman|b1;Bold serif}, {\fArial|i1;italic sans}, {\fCourier New|b1|i1;bold italic mono}.\PUnderline \Lis on\l, overline \Ois on\o, \Kstrike-through\k.\PHeight \H2x;double + 1 +s\H1x; and returns; width \W2;wide\W1; and back; slant \Q20;oblique\Q0;.\PStacked: \S1/2; \S1#2; \S1^2; \S\U+00B1 0.1^ -0.1; and x\S2^;, H\S2^ ;.\PTab\Pstops: A\tB\tC + 7 +S-ARIAL + 0 +MTEXT + 5 +6D +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +130.0 + 20 +-250.0 + 30 +0.0 + 40 +4.2 + 41 +90.0 + 71 +1 + 1 +{\L\C2;Paragraph 1}\PA justified-looking paragraph with a long text that wraps inside the box of the entity, set at width 90, to see how line breaks are made by the viewer. + 7 +S-ARIAL + 0 +MTEXT + 5 +6E +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +230.0 + 20 +-250.0 + 30 +0.0 + 40 +4.2 + 41 +70.0 + 71 +1 + 1 +Background box, with a border in the text colour: the viewer fills the box behind the text. + 7 +S-ARIAL + 45 +1.5 + 90 +1 + 63 +0 +421 +16771980 + 0 +MTEXT + 5 +6F +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +0.0 + 20 +-320.0 + 30 +0.0 + 40 +3.5 + 41 +40.0 + 46 +40.0 + 71 +1 + 1 +Column one text, a short paragraph that sits at the left.\NColumn two: more text, in the middle.\NColumn three at the right. + 7 +S-ARIAL +101 +Embedded Object + 70 +1 + 10 +1.0 + 20 +0.0 + 30 +0.0 + 11 +0.0 + 21 +-320.0 + 31 +0.0 + 40 +40.0 + 41 +40.0 + 42 +132.0 + 43 +40.0 + 71 +1 + 72 +3 + 44 +40.0 + 45 +6.0 + 73 +0 + 74 +0 + 0 +TEXT + 5 +70 +330 +17 +100 +AcDbEntity + 8 +NOTE +100 +AcDbText + 10 +0.0 + 20 +-364.0 + 30 +0.0 + 40 +3.0 + 1 +MTEXT in three static columns + 7 +S-ARIAL + 11 +0.0 + 21 +-364.0 + 31 +0.0 +100 +AcDbText + 0 +MTEXT + 5 +71 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +150.0 + 20 +-320.0 + 30 +0.0 + 40 +4.0 + 41 +30.0 + 71 +1 + 1 +AP 1 + 7 +S-ARIAL + 0 +CIRCLE + 5 +72 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbCircle + 10 +150.0 + 20 +-320.0 + 30 +0.0 + 40 +0.8 + 0 +MTEXT + 5 +73 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +190.0 + 20 +-320.0 + 30 +0.0 + 40 +4.0 + 41 +30.0 + 71 +2 + 1 +AP 2 + 7 +S-ARIAL + 0 +CIRCLE + 5 +74 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbCircle + 10 +190.0 + 20 +-320.0 + 30 +0.0 + 40 +0.8 + 0 +MTEXT + 5 +75 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +230.0 + 20 +-320.0 + 30 +0.0 + 40 +4.0 + 41 +30.0 + 71 +3 + 1 +AP 3 + 7 +S-ARIAL + 0 +CIRCLE + 5 +76 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbCircle + 10 +230.0 + 20 +-320.0 + 30 +0.0 + 40 +0.8 + 0 +MTEXT + 5 +77 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +150.0 + 20 +-334.0 + 30 +0.0 + 40 +4.0 + 41 +30.0 + 71 +4 + 1 +AP 4 + 7 +S-ARIAL + 0 +CIRCLE + 5 +78 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbCircle + 10 +150.0 + 20 +-334.0 + 30 +0.0 + 40 +0.8 + 0 +MTEXT + 5 +79 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +190.0 + 20 +-334.0 + 30 +0.0 + 40 +4.0 + 41 +30.0 + 71 +5 + 1 +AP 5 + 7 +S-ARIAL + 0 +CIRCLE + 5 +7A +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbCircle + 10 +190.0 + 20 +-334.0 + 30 +0.0 + 40 +0.8 + 0 +MTEXT + 5 +7B +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +230.0 + 20 +-334.0 + 30 +0.0 + 40 +4.0 + 41 +30.0 + 71 +6 + 1 +AP 6 + 7 +S-ARIAL + 0 +CIRCLE + 5 +7C +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbCircle + 10 +230.0 + 20 +-334.0 + 30 +0.0 + 40 +0.8 + 0 +MTEXT + 5 +7D +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +150.0 + 20 +-348.0 + 30 +0.0 + 40 +4.0 + 41 +30.0 + 71 +7 + 1 +AP 7 + 7 +S-ARIAL + 0 +CIRCLE + 5 +7E +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbCircle + 10 +150.0 + 20 +-348.0 + 30 +0.0 + 40 +0.8 + 0 +MTEXT + 5 +7F +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +190.0 + 20 +-348.0 + 30 +0.0 + 40 +4.0 + 41 +30.0 + 71 +8 + 1 +AP 8 + 7 +S-ARIAL + 0 +CIRCLE + 5 +80 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbCircle + 10 +190.0 + 20 +-348.0 + 30 +0.0 + 40 +0.8 + 0 +MTEXT + 5 +81 +330 +17 +100 +AcDbEntity + 8 +TEXT +100 +AcDbMText + 10 +230.0 + 20 +-348.0 + 30 +0.0 + 40 +4.0 + 41 +30.0 + 71 +9 + 1 +AP 9 + 7 +S-ARIAL + 0 +CIRCLE + 5 +82 +330 +17 +100 +AcDbEntity + 8 +0 + 62 +1 +100 +AcDbCircle + 10 +230.0 + 20 +-348.0 + 30 +0.0 + 40 +0.8 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +280 +0 +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +86 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +DICTIONARYVAR + 5 +86 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +0.0 @ 2000-01-01T00:00:00.000000+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/crates/exav-viewer/demo/examples/files/converted/deck.fodp b/crates/exav-viewer/demo/examples/files/converted/deck.fodp new file mode 100644 index 00000000..27e4ca5a --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/converted/deck.fodp @@ -0,0 +1,54 @@ + + +exav projectHarbour reviewA deck made for the exav demo (CC0-1.0)2000-01-01T00:00:00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 + + + +Harbour reviewThe east quay, first half of the yearexavWelcome. These notes are speaker notes: the viewer does not show them.AgendaWhere we areberths letby lengthby seasonvisitorsWhat the figures sayWhat we do nextorder the new chainpaint the numbersFour items.Berths let, month by month +Berths let by month +This yearLast yearJan2018Feb2623Mar3228Apr3833May4438Jun5043Up 40% on last yearThe chart is a native chart, with its data inside the slide.FeesLengthBerthsFeeup to 6 m1218.006 to 8 m1824.508 to 10 m1431.00over 10 m1648.00Season fee includes waterPower is meteredVisitors pay by the nightA picture and shapesiVBORw0KGgoAAAANSUhEUgAAAyAAAAHCCAIAAACYATqfAAAVJ0lEQVR42u3caZhU1YGA4VvdBSiKICCggIIIooi44RYCgnHQgOIKURCXwQ13RGcykpmJSybJmBijwhiBBh1HcYNAFKPiMuIICAjtFhZZVBZtQMEVETo/emybbmh7OdV1q+p9n/4hWF1dfe+pc7/n3EMl9u18TQQAQDh5DgEAgMACABBYAAACCwCAGktGCQcBACAkK1gAAAILAEBgAQAILAAAas4mdwCAwKxgAQAElowsYQEABGUFCwAgsKT1KwCAwIHlDiEAQFhuEQIACCwAAIEFACCwAACoOZvcAQBCB5a+AgAIyy1CAACBBQAgsAAAckoyStiFBQAQkhUsAACBBQAQbz6mAQAgMCtYAACB+SR3AIDArGABAAgsAACBBQCQU/wrQgCA0IFlkzsAQFhuEQIACCwAAIEFAJBTkgmbsAAAwgaWvgIACMstQgAAgQUAILAAAHKKT3IHAAgdWDa5AwCE5RYhAIDAAgAQWAAAOcUmdwCA0IFlkzsAQFhuEQIACCwAgHizBwsAIDArWAAAgdnkDgAQPLAUFgBA2MCSVwAAYdmDBQAgsAAA4s0mdwCA0IGlrwAAwnKLEABAYAEACCwAgJySTNiEBQAQlBUsAACBBQAgsAAAcorPwQIACB1YPskdACAstwgBAAQWAEC82YMFABCYFSwAgMCSkY9yBwAIygoWAEBg9mABAARmBQsAQGABAMRb0h53AICwrGABAAgsAACBBQCQU3xMAwBA6MCKFBYAQFBuEQIABOYWIQBAYFawAAAEFgBAvNnkDgAQOrD0FQBAWG4RAgAILACAeHOLEAAgdGDZ5A4AEJZbhAAAgblFCAAQmBUsAACBBQAQb8mEe4QAAEFZwQIAEFgAAPGWjHwQFgBA2MCSVwAAgQPLAhYAQFj2YAEABOYWIQBAYFawAAAEFgBAvPkkdwCAwKxgAQAILACAePOvCAEAArOCBQAQmE9yBwAIHVj6CgAgLLcIAQAEFgBAvLlFCAAQOrBscgcACMstQgCAwNwiBAAIzAoWAEBgVrAAAEIHlk3uAABhuUUIABCYW4QAAIFZwQIAEFgAAPGWTLhHCAAQlBUsAACBBQAQb/4VIQBAYFawAAAC80nuAAChA0tfAQCE5RYhAEBgVrAAAAKzggUAEJhN7gAAoQNLXwEAhOUWIQCAwAIAiDe3CAEAQgeWTe4AAIEDS18BAIRlDxYAgMACAIg3twgBAEIHlk3uAACBA0tfAQCEZQ8WAIDAAgCIN7cIAQBCB5ZN7gAAYblFCAAQmFuEAACBWcECAAjMChYAQOjAsskdACAstwgBAAJzixAAIDArWAAAgVnBAgAIHVg2uQMAhOUWIQBAYG4RAgAEZgULACAwK1gAAKEDyyZ3AIDAgaWvAADCsgcLAEBgAQDEm1uEAAChA8smdwCAwIGlrwAAwrIHCwBAYAEAxJtbhAAAoQPLJncAgMCBpa8AAMKyBwsAIDArWAAAgVnBAgAIzCZ3AIDQgaWvAADCcosQACAwK1gAAIFZwQIACCyZsIQFABCUFSwAAIEFABBvNrkDAARmBQsAIDCb3AEAArOCBQAQWDIRFTsKAAABWcECAAjMvyIEAAgdWJHCAgAIyi1CAIDA3CIEAAjMChYAQGBWsAAAQgeWTe4AAIEDS18BAIRlDxYAQGBWsAAAArOCBQAQmE3uAAChA0tfAQCE5RYhAEBgVrAAAAKzggUAEFgyYQkLACBsYDkEANS9ggeGVf6Ai4aOdZTI4MCygAVA3Rj/Q1G1swK7WGyRcYHlEAAQq7Sq5NuVFhkTWFawAEiRcbXrqp2V1j/KLOIfWD7JHYCU1NXEYal65pLMukBmEePA0lcAhDU2ZWlVMeCGySxiyedgAZB5dZWuHwdVZAULgDDuT1PrjJ047BLrWMSMFSwAMriu4vDToaJklCh2FACoVd9MuCQOhXfJhfc7F8QlsNwiBKA2/hSDuirtvEs1FvHgFiGQMWa8ucBBUFeZ9XrIWVawgNh5fuchVa6xftL1MIeLio11mXUs0i1x6TkjHAVScYF05aPGFVVjRl0duy/Gy0Uai/RKJixhEdRzhQvKXj5POtQFz5BYUGc/q1y0GX4p9V8Fsb4Z5+pGmkfgZQOtYJHyS6nrnJCKA+MwpwIriqLLL7q/ikPX2CA4e7AI49lKL66ll95/MItl9YnOrAQ0GmtjTEEG7CVPVHkwlx0bBgZhht/lVrCo84uu+UtIxZBhmX2BFUXRFTtaxKr68DYqqDErWNTKX2t0GS6Z3fqaubLonGZfUxqflRhdkDEfhZCo3dguOyoMCao39q4YZAWLml6JFwa4EvftZs7KqhOarQzU7QJrfCZ91lSHI680GEhDYA2PZWA9s3DByUZwjD0T+krsdGfZCc16uTxi782cugqeVkYCmR1YLt45ezF2pqSV3hJYGdFVhgE/HFhXxiywpsd7uj8lt98/dXZ2TjFP5fx7LUPlwtC9J8aBla60MokR98DKlBk/B985aTk1ZihppbTUVWZ1lamM7wPrqp/FJbCeXpBhk/5PD8uV90x6T03uHGdvsayUZQP47nExCqyYd5UJTWDFIrAyeurP4jdMfM6LWUlaKS2BldFpZU7LucC6OgaB9VRWzP79su7dEsPz0s+UlJNvriyT0cP4j2kNrCzoKjObwHIByOn3ScxPislIWpkuciqwsrWrzG9ZHljXpDWw/pKl14D+mfwOyaCT0t9MlEtvqyyWKSP5rjqvq5xKK1NcbSa9GB6rxDXnXp+2I/LGwix/bxzeLcPGaGaekYw7zs4gGTqY7xp7qa4yPGI718Xt+CSuTVNgTcuZK8GpmfCWyILTcWpuTz3TpJVJI/X+kPrAklZmvNrMcrE6LOkJrNy8GMT2/ZBNp8Okg0kjEwNLV5n6Qk1u8TkUievqPLCm5vb14LQ4vQ2y9VyclhuZNVVamTfq1p2hA0tXmQBTNK3F4SAIrNx9A2T9icjizPImMnVkemBJKzNhqmeztP/iievPq9PA+vN8F4bvDTgiPac/185Cuo6zc0eWjerf31/bwNJVJsO6nMfS+/vWaWC5NsRhBOTsWcj0zPL2IQ4Du5LGEk8GTwynrzTO/IkRdRVYU1weKnV66geBU1A3x9lZI8vGtkFo/GT0sEnXtF9HgeX9mfah4BRkXH45ZdTBMDbMCDUZxnkspWVuT9yQ+sCa7A1cfWeEGw2OfwxPilMGZMHUl0GT1Rl13liJGwanNrAmz3OpqMWAOLKbg58jJ8spA4j/LF2NwBqZysB60tUihDNrNCYc/Mw6ic4XQGwvqfEKLBeMdI0JRx4A0ttYeYkoSsWXa3wqgvXJeQsdeQCo5fU0RfGz3deNKVjBesI1PvXOqhDgDjsA1PgyGlZeIhGF/XKZrxtPzFv4xLyFDjsA1OwyGjyBtvu6ach1AV/u43MLnTMAICOcfdShKXrmkHuw1BUAkEEen1uYoj1YeaFe4mPqCgDINI/NLUxFw4RZwVJXAEBGZ1boFaxaP8ej6goAyHCPzi0MWFi1XcF69HV1BQBkRWO9XhiLPViT1BUAkEVCtU3NV7DUFQCQlY2VthWsR9QVAJClat85yUSi2t/z8Bx1BQBkf2Ode3QNP4m02itY6goAyBE1zp7q7cFSVwBArjVWavdg/Y+6AgByTw0SqKorWOoKAMjlxqruJ7kXV+kLACCHPTSnsKrVlCiu0grWQ7PfdFgBgFxvrNlvBtuD9d/qCgCgOl2UrPxjsB5UVwAAFRrr/GO6VvKYPHUFAFBdlWdSXiIR7fBLXQEAVN5YO+uoHW9yf2CWugIA+AEPzHqzqpvcJ6orAICq2WE4lV/BUlcAANVtrMpWsCaoKwCA6isXUcnEd5/TUPCaugIAqFVjXXRc16h0BUtdAQDUXklT5SXUFQBA0MZKtDl2iAMBABBQnkMAACCwAAAEFgCAwAIAQGABAAgsAACBBQCAwAIAEFgAAAILAACBBQAgsAAABBYAgMACAEBgAQAILAAAgQUAgMACABBYAAACCwAAgQUAILAAAAQWAAACCwBAYAEACCwAAIEFAIDAAgAQWAAAAgsAAIEFACCwAAAEFgAAAgsAQGABAAgsAAAEFgCAwAIAEFgAAAILAACBBQAgsAAABBYAAAILAEBgAQAILAAABBYAgMACABBYAAAILAAAgQUAILAAAAQWAAACCwBAYAEACCwAAAQWAIDAAgAQWAAACCwAAIEFACCwAAAEFgAAAgsAQGABAAgsAAAEFgCAwAIAEFgAAAgsAACBBQAgsAAAEFgAAAILAEBgAQAILAAABBYAgMACABBYAAAILAAAgQUAILAAABBYAAACCwBAYAEAILAAAAQWAIDAAgAQWAAACCwAAIEFACCwAAAQWAAAAgsAQGABACCwAAAEFgCAwAIAQGABAAgsAACBBQAgsAAAEFgAAAILAEBgAQAgsAAABBYAgMACAEBgAQAILAAAgQUAILAAABBYAAACCwBAYAEAILAAAAQWAIDAAgBAYAEACCwAAIEFAIDAAgAQWAAAAgsAQGABACCwAAAEFgCAwAIAQGABAAgsAACBBQCAwAIAEFgAAAILAACBBQAgsAAAsjCwOrdv+cefn/VywdVvTf7nZ++7YvigHsnk/z/Domk3jx51Tsl/T7rjwmFnHVfJ84wedc6iaTcH/2V+8OcCAMQrsHoddcCTd16czM/75ZhnLr/l0RdfX3LVeT1vvapfxUfWr5dfP5lf979MtX5uiiIPACBZ9YfeeGGflWs+uer2x7cVF0dRNPONZV9v/vbygT+aNH3+gkWryj7yzOvGFRen4ZdJ188FAKhhYO27954LF6/eViZhJr9Q2Ll9y5bNGpV75N+m3jxj9uLhtz1W8sddd6k3fFCPfj27NG+y24cfffrw9PnlHl/ygP49u7Ro2mjt+k1PPL/wvkdf3bqtfCstmnbzjNmLH/rL3GuG9Oq0315btmyd+cbyOybMWF20acc/t0G9Kwb16N+rS/Mmu636eOPUF98cP2X25m++LXmq0ueMoujAU28vff7Sb4+iaPSoc048plPZ/ztz/rLrzz+h4M+zRz8ys+qvHAAQWDu29P2iIzq3Oa5bu9cWrij5mxWrNlx2y6TKv2uX+skHfzWk034txkyaWbh49UH7t7x2cK8vvtpc+oB6yfyCWwe3adF49KSZy1etP+zA1pcP6tGq2R7/eu/TFZ+tbcsmv7/pjIIps//w4Esd2ja/bsgJRxzcZsA1Yzd+9lW5R9avl19w63md9295z8OvvL10zeGd21x57o87t2957W+ejKJo0MgJIy/s0/2QfQeNnFD1I3BA2+bdOu1zzyOvvDRnSXVfOQAgsHbg9vufG/fLcyfcNvj1t95/ed7Sl+e+t3jFxz/4XRcMOLprx32u/c2Tz8x8N4qiVxcsf2vp2om3D/7+Aad1P7Tj3gOuHbtkZVEURa8tXLFl67aRF/QZP2XWilUbyj1bp3YtLhz1UEnhvbZwxbpPv7jrn8687Jzjfzt+RsWfe/hBbYbf9tiM2YtLHhwlomsH9+o2pfXCRasWLFq16Yuvoygqd3Ozcm333vPsEQVvL11Tg1cOAOSOamxyn/fOB30vH1MwZXar5nuMvKDPtLsveex3F3XtuE/l33VKj4PXrtv011ffLf2bWYUrln24vvSP/XsdUrhkzbIP1+fn55V8vThnSSIRHdu1XcVnW7FqQ+n6WRRFz776t6INn594dKeKj+zfs8vqjzeW1FWJR6bP/+34Gd9+u7XGB2vJyqLSuqruKwcAckeyWo/+aP1nvx73/K/HPd9x370G9O469LTuD/7HkLOuH//eB+t29i3tWjctXLS63N7zD9Z+sn+bZiX/3b5Ns13qJ9+Z8vNy39i0ccOKz/b+2k/K/nFbcfHy1RsOO7B1xUe2b91s/rsflv2bDRu/HDd5VrV+30QiUfaPq4s2bvcjqvPKAQCB9QOWvF90x8QXFq346I6Rp//s5MNvv/+5nf6A/LztKyWKoqhB/e9/bl4iMe+dD341tvwzrF33WcVnK674rwSLi7du3VbxkXl5ifz8an+MarlX2qhhg+16bvvd69V65QBA7qhqggw6+fAn77y4S4dWZf/yhTlLoijaq2mjSr5xddGmdq2blmusfVvtWfrfa4o2Ntyl/ltL1pR+ff7l5t7dO+62a/2Kz9amZZPteigR7bdP0+Wr11d85JqiTW1bbffgJo127d29Y8V/81hq67bihtv/0P3bNqvkV6vWKwcABFZ5i1cWdTlg72sG9yq7HlXSW5VvdX9xzuIWTRv1KbNNqnf3jvu0aFz6x5fnLu3cvuUBbZuX/s2FA465YuCPPtn0ZcVn69C2+dFd9yv94yk9Dm7ZrNH0V96t+MiZbyzbu/kevY46oPRvLjn7+DG/GJj8blmrZDGs9JPooyj6eMNnnfZrUe+7jyrtccT+zRrvVsmvVq1XDgDkjvw92hxalcetXbep+Z67n9qrS/cu+27e8m3TJg37Ht951GV9167bNOqep77ZsnX4oB5bt257dcHyjZ9/ffV5PZevWv/U/74TRdE77609tdchp/Q46POvvtl91wb9eh58wwV9Plj7SbMmu93z8CtRFL277KP+vbqceVK3L77a3Hj3Xc/v331wv6PufPCl/1uwvNxruPq8nmvXbTq9T9dtW4sb7lKvX88uPx920ttL1/zb6OkldwnL/txFKz4e0LvrT3988NebtzTctf7AvocPO+vYB6bOefq7Gjv+sPZdOrTasmVrfn7eqo82RlHUZI+GPY/scGD7Fp9u+urIg9uOurTvqo83Nv/udZZ98hJVf+UAQE6pxh6sfx89/ZV57w09rfu/DDtp990afPjRpw89NW/85FlffPVNFEWTXyg87YSuY34xsN/w+8p+14aNX55708QbLzpxxNATkvl5i1cWjfjPyeef2r1TuxYlD1j36ReDRk4YMbT3iKG9G9RPvvf+uht/N2Xay2/v8DW8/d7aWYUrh/Q7cp8WjYs2fD5x6pzRj8ws+ezQikU4cGTBiKG9rz6vZ716+StXb7hlzDOTnnmj9AETp8457MDWV53b8+tvthw56I4oiu5+6OXibcVn/OTQe0eds+zD9bf/6dkBfboe+N3rrKharxwAyB2JNscOyZTXWvGT1gEAYijPIQAAEFgAAAILACCXZNIeLACAjGAFCwBAYAEACCwAAIEFAIDAAgAQWAAAAgsAgCr5O4kk04EqXgwyAAAAAElFTkSuQmCCA lake at duskStartMoorPay?StarShapes with gradients, arrows and text. + + + diff --git a/crates/exav-viewer/demo/examples/files/converted/deck.odp b/crates/exav-viewer/demo/examples/files/converted/deck.odp new file mode 100644 index 00000000..2fdb16ba Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/converted/deck.odp differ diff --git a/crates/exav-viewer/demo/examples/files/converted/deck.ppt b/crates/exav-viewer/demo/examples/files/converted/deck.ppt new file mode 100644 index 00000000..f3f914c4 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/converted/deck.ppt differ diff --git a/crates/exav-viewer/demo/examples/files/converted/diagram.fodg b/crates/exav-viewer/demo/examples/files/converted/diagram.fodg new file mode 100644 index 00000000..5dad6ee6 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/converted/diagram.fodg @@ -0,0 +1,28 @@ + + +exav projectFlowchartA drawing made for the exav demo (CC0-1.0)2000-01-01T00:00:00 + + + + + + + + + + + + + + + + + + + + + + + +Boat calls the officeIs a berth free?Give berth numberOffer the waiting listBoat moorsPay at the officeBoat waits at anchoryesnostart or endstepdecisionFlowchart for the exav demo (CC0-1.0). Connectors follow their shapes.Harbour office1Berth register2Billing3Layer 1Layer 2Layer 3Layer 4On a hidden layer + diff --git a/crates/exav-viewer/demo/examples/files/converted/diagram.odg b/crates/exav-viewer/demo/examples/files/converted/diagram.odg new file mode 100644 index 00000000..aefe2197 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/converted/diagram.odg differ diff --git a/crates/exav-viewer/demo/examples/files/converted/handbook.doc b/crates/exav-viewer/demo/examples/files/converted/handbook.doc new file mode 100644 index 00000000..b5ead741 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/converted/handbook.doc differ diff --git a/crates/exav-viewer/demo/examples/files/converted/handbook.fodt b/crates/exav-viewer/demo/examples/files/converted/handbook.fodt new file mode 100644 index 00000000..f54b1c3f --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/converted/handbook.fodt @@ -0,0 +1,99 @@ + + +exav projectHarbour handbookA document made for the exav demo (CC0-1.0)demoen-US2000-01-01T00:00:00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +// + + + + + + + + + + + + + + + + + + +Harbour handbookexav demoPage 1 of 1 +Harbour handbook (landscape section)Page 1 + + + +exav2000-01-01T00:00:00 +exav2000-01-01T00:00:00some deleted words + +Harbour handbookThe east quay: rules, figures and notesexav project, written for the demo (CC0-1.0)1 January 2000ContentsContents1. Introduction2. Text and styles3. Lists4. Tables5. Pictures and shapes6. Links, fields and notes7. Wide table (landscape)IntroductionThe east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell.Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master. 1The register of 2000 lists sixty berths.“A harbour is a promise that the sea will wait.â€� Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and its draught, and to say how many nights they plan to stay.Text and stylesCharacter formattingBold, italic, underlined, struck through, red, highlighted, small capitals, large and monospaced text; H2O and 103; a mixed style.Paragraph formattingThe east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell. Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master.A centred lineA line aligned to the rightAn indented paragraph with a left border: Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and its draught, and to say how many nights they plan to stay.moor --boat Aurora --length 6.5berth 14 free until 06:00A section in two columnsThe east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell. Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master. Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and its draught, and to say how many nights they plan to stay. The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell.ListsWater at every berthcold, from the quayhot, in the shower blockElectricity16 A at berths 1 to 40with a meterwithout a meter32 A at the east pontoonWi-Fi on the quayNumbered, with other numbering styles at each level:Call the office on channel 9Give the length and the draughtlength over all, in metresdraught, in metresTake the berth you are giventie up bow firsttie up stern firstPay at the officeTablesTable 1: berths by length, with merged cells and a repeated header rowLengthBerthsLet byFee (EUR)up to 6 m12week18.006 to 8 m18season24.508 to 10 m14season31.0010 to 12 m9year42.00over 12 m7year58.50Total60Merged across two columnsMerged down two rowsabcdePictures and shapesiVBORw0KGgoAAAANSUhEUgAAAoAAAAFoCAIAAABIUN0GAAAUj0lEQVR42u3dd5wU5f0H8FnuDjiUoiCggIooYKGJBUHBGoxCCETBgiUKGhsgQQ3RmPxiTHsZEyNiB2yxRelojIgKiUIUKaJBpBg9pB16KAoi3O+PhfPou8fu7O7N+/26P7hjbnbmu/M8H77z7C6xA1sOCACAcFVRAgAQwAAggAGA9MgPYooAADpgABDAAIAABoBKwhowAOiAASAqHbAWGADCD2DxCwCZ6IAlMACEzhowAAhgABDAAECaWAMGgEwEsPwFgPC5BQ0AAhgAoiE/iLkJDQA6YACIQges/wUAHTAARKMD9j5gANABA4AABgDSw4uwACATAWwNGADC5xY0AAhgAIiG/Jh70AAQfgDLXwAIn1vQACCAASAavA8YADIRwNaAASB8bkEDgAAGgGiwBgwAmQhga8AAED63oAEgEx2wBhgAdMAAEI0OOIjpgQFABwwAUeiA9b8AoAMGgGh0wN4HDAAZCGD5CwDhcwsaAAQwAERDvrcBA4AOGAAEMAAggAGg0rAGDAA6YAAQwABAmvgkLADIRAD7/4ABQAcMAJFgDRgABDAARIP3AQOADhgABDAAkCZeBQ0AmQjgQAIDQOjcggaATHTAGmAA0AEDQDQ6YGvAAJCBAJa/ABA+t6ABQAcMABEJYGvAABA+t6ABIBMdsAYYAHTAABCRDlgLDAA6YACIRAcceBk0AIQfwOIXADLRAUtgANABA0AUeBEWAAhgAIgG7wMGAB0wAESlA1YDANABA0AkOmDvAwaADASw/AWA8LkFDQA6YACISABbAwYAHTAARII1YAAQwAAQDT4LGgB0wAAQlQ5YDQBABwwA0eiAtcAAoAMGgGh0wGoAADpgAIhGB6wFBgAdMAAIYAAgLbwICwAyEcD+P2AA0AEDQCRYAwYAHTAARCSArQEDgA4YACLBGjAA6IABICIBbA0YAHTAABAJ1oABQAcMABEJYGvAAKADBoBIsAYMADpgAIhIAFsDBgAdMABEgjVgANABA0BEAtgaMADogAEgEqwBA4AOGAAiEsDWgAFABwwAkWANGAB0wAAQkQC2BgwAOmAAiARrwACgAwaAiASwNWAA0AEDQCRYAwYAHTAARCSArQEDgA4YAAQwAJAeXoQFABnpgLXAAKADBoBodMBqAAA6YACIRgesBQYAHTAARKMDDkpVAQB0wAAQhQ7YGjAAZCCA1QAAQucWNADogAEgIgHs/wMGAB0wAESCNWAA0AEDQFQCWAIDQOjcggaAjHTAagAAOmAAiEYHrAUGgPADWAkAEjHy0X673ebHFz+kUCTcAasBwM6NSCB3tw/pyyQxAhigAh5OJnd3FtuXi2F2EcA+Cxpg2/R9pF9q9hOP4UvEMDpggF16KEXRu02c95PBbMfbkADSmL5le07fztEBA+SwB9MfkA890q+/VpjvAjhWqgpATpg8d9ZprdqmY88PjOofWsxfcemDnkoCt6CBXIneyXNnxf+Qu+mbkYcja8WuOHewKgBZ6+WdJO7pKWqF789QHF6pD9YBKwGQtdH78s773ZdT0Qrfrxklc3wWNJB1/jknoXCNZ/AZrdvm4jneP6r/T36sCdYBkzUTCkR8mMS/whlc943McPub8QMg0x2wGqTfS3NmxaeJ7+XmP9UhnDGyh//ATWp83Zsd4WcGjrLYVb29CCuN/rGjaaWrGIZdjpEKS3xwDc+a7vNqN6Ij2wH7B1gaZ5bZs3Y243RtI4MxQGalfp9zZgVBsNvxNXxENt37NQlHNoA99enw4u5mlvjUc6YYxgBJT7Tn0OAyCUdW7Jo+bkGn2AvJTC7fl8EYHWmzs/E1bER2vfrp2svchc7wZZmRqdiroDM8v4Q8H1XWeZacuCTCvyp2+IjZlr7GbzZclhk5Ze8DTplJs2btybV+Vtu2lawOu76gK835kr6hkcIsyfLrLePz8PbPUSWblxI85ZDPN3bteW5BZ8sUUwmu9VRNteLZuEi5sovq7oezsQO+7vIHs/mpqXwdQjacqRdhpcDEFM0yk2bNOjtnr/KJKZ1qdz1IzhbP5cqendWYmE3RW/6iytqLJ5bdz0iWVy+FFQhzHo4N0AHvgQnpmWW65dRVPiHLptpuEYjnRGqekTpMyL7c3d7Ct+/JwqMamOoOOH3PRc4NsQqUIpxzjA04/3o5WsEn9Z3ZaUyRdm0iXoGI1DD8mqepLFl+MWR/DA/s90DOPQXZP8T2pCYhnF1soABO3viwLvTuWXx9j8+dCbd77idxCNWucJXG51r0ZmcGD0o+gLOk8lk4vlJVmXSfWsYCePw7s3N0Wgz5oq/EF7eZIidKvYui5e6VkIUxnEgAZ3PBs2RwpbxEaT2v2KBMBPC4cjX6QU7NieMyMQCyp0TjKsWEmxOXXOUotQxO3PU7CuBcvAwyNb7SV6v0nVHs+gvCDuCxM3dcph5HZ/W0uLPDDk1m65Px0698JY1OncVwIgb3f6AyXQBhDq4Qipam0wk1gBMpU3bGcJaMikwVp9KnQsavOrkb2Qxu1v4agysnBk46TiQ2OKwAHpNkpX6YNUk8Jpsmx5DLMiZiwRD+VTdG9EYshit94oYwsjI1alI+P8R+GkoAj65ovXpmNIZHZ+vkGEJZRkc7GNJd4dFyNzIZHM3ETdOwyvjASe3MEPvphWkP4NFvp6BkPduHncQpOew0XgdpK0iWn3hO11ltoxDDEjcdwyp7xk4K54TYkHQG8POpLlmvUGL4+dyZJVNekOclRHpKrbCVOIMlblrHVBaOnVRNvGkM4LRWLX1JnHMTZapKISHSUW1VrZQxLHFDGFNZPnZSMvHGbkhPAD8XSu1+lOoYfi43p8s9rMNzQiLVNVdSqPCYypXhs+cBFLuxb+oD+O9vhV2+c45pk3PHnA1FqARnnW1lV1KI7PCvQAAPSmn0zsloLVrn4mFnqgKV5qwBci53giCI3ZS6AH42ayb0c5OpyLOVK4cSOfdnRS9AJhJnqwD+WYoC+Jnsm9N7764oz1TeHNrZuT8jegFCj5udBPBFKQjgZ/6T1dN672Nb59wxp/ysK/35AmRb0OwmgIfuWQA/nTvTep8t1Xk6SlEUP+unpS9AWCmTaAD/fA8C+CnTOgBscV4yGVxF+gJASiSVjFVisaACX9IXAHaYwQkmadId8JMz5jw5Q/oCwE6DMrEOOAgS/xK9AJBIBu82UpMI4L9JXwBIzN92l8FVglhpIl9PSF8ASMYTM+bsIljzY7v7/cenz1VEAKhIBk+f2/f4Vjv8q93cgpa+ALAnHp8+dye3oHfuMekLAHtsh3m60/cBS18ASGEGb5OzO1gDfuRN0QsAKfbom3Mv6fDdenAV6QsA4Sgfslu9CEv6AkC6M3jLB3FsuRk9SvoCQPqNenNuLBbkB0Ew8g3RCwDhGfnG3FjjDn0VAgBCVkUJAEAAA4AABgAEMAAIYABAAAOAAAYABDAACGAAQAADgAAGAAEMAAhgABDAAIAABgABDAAIYAAQwACAAAYAAQwAAhgAEMAAIIABAAEMAAIYABDAACCAAQABDAACGAAEMAAggAFAAAMAAhgABDAAIIABQAADAAIYAAQwAAhgAEAAA4AABgAEMAAIYABAAAOAAAYABDAACGAAEMAAgAAGAAEMAAhgABDAAIAABgABDAAIYAAQwAAggAEAAQwAAhgAEMAAIIABAAEMAAIYABDAACCAAUAAAwACGAAEMAAggAFAAAMAAhgABDAAIIABQAADgAAGAAQwAAhgAEAAA4AABgAEMAAIYABAAAOAAAYAAQwACGAAEMAAgAAGAAEMAAhgABDAAIAABgABDAAIYAAQwAAggAEAAQwAAhgAEMAAIIABAAEMAAIYABDAACCAAUAAAwBplFercWtVSIn5428+olnDia+/l9rdPn3HpXVqFs58/xMVBtABE56qBXlV8/MS3Hj4LefOH3+zogFkv3wlyHK9Bj1cWqoMABEO4MLqBVf3ObFb5yPr71tzWfGa516eff8z/9q4qfTOG3qe2allz0EPz1+yIr7lX3/W66T2zbpf++Anyz8PguDA/fcZfPEpxxzRZO+9qn20dPWosTNGT54TBMHouy6vW3uvLj/+a/mAueumXh3bNu140V82fLsxrY8eBMH88TdPnv7B/CUr+pzZrrBawSszFvzi7kkd2za94dJTG+5X64MlK341/IV5C5eV3/iJCW8N6Nul+UH7bdiwcdo7i+8YNXnpyjVJlWubzXa72/+Ou3ny9A+u/s2zm3dbreCqPid263JkvTp7Fa0oGTdl7ogx09d/8218V2X7DIKgRffby/Zf9uvxLvm045uX/9tpMxddf9HJI8dOH/7UtMSPHIAwArggP2/kbRc2rl97+NPTFhcVt23R6Cd9TmxYt9at90z69X0vHt/6oP+75qzzbxxVWhp0bt+sa6fDb71nUjz/9qlV4/HfXbTmy3W/vv8fa79a37f7sb8f1H3lZ19Om7lo7CvvDu13etuWjd/ZssBZrWp+l2MOHffqu9ukbzoePb7now7dv7BawU1/Ht+h9cH9enWoXjX/sIPq3/nolOpV82+5out9t/Y++bJhGzduim/cpEGdO2/sOXLM9L889mqzJvUG9T356CMa9xjwUMkXXyderu1rm/huqxbkjbztgpaHNBj25NR5H37armXja84/qWXTBgP/8HwQBH2GjBpy6anHHnVgnyGjEr8CDm1Sr03zA4Y9NfXVGQuSPXIA0h7Al/zg2NaH7d9j4EMLPloZBMEbs5ds2LhpyCWnjhjz5pKi1b8a/sKwn5/Tu2u7sa/MvfUnZ059e+HTL74T/8WzTjqiQd2a19z+97kLlgZB8P7i5W88fv0ZJ7SYNnPRhNfn3XjZaWd2alkWwCcd3aywesHYKXNDePT4Bvvtu3fPgQ8Xl6yd+vbC049vfnqHFrfcPfHFae8HQXBok/36n3PCUYfuP3t+UXzj5gfXv/SWJ96YvSR+DKs+X3vXTb2uPLfjH0dMTuqAt9k4id32OK7d4Y2v/s2zk6d/EN84iAUDL+zSZkyj2fOLZs0vWrN2XRAEs7YccCKa7L/POYNHzvvw0wocOQAVluiLsLp1OWrOgk8XfVKcl1cl/jVlxoJYLOjQ6uAgCP75xvwJr88bcsmpQ/ufUXvv6j//68SyX5zynwV9howqm98/W/NVvDENgmDVZ1/++51FXTsdHott3rhrx5ZFK0pmvv9xCI8et+CjlcUla+N//njZZ5tTLQiCIIj30PuW23hJ0eqyvw2C4KV//Xfl6i9PO655suXaRhK77Xzk0hUl8fSNe+qFmX8cMfnbrW8YJGXBRyvL6pPskQOQ9g64aeO61avmvzdm6DY/37f25ny67b5/TBx+5XlnHn3DnWNXrP6ibIOlK0oa1K15+8BuRzVrWG+fvatXyw+CID9vc/CPffXdO37ao3XzRrPnFxXk551y3GGPTfjP9q85StOjB0Gw6vO1ZX+O3/cu3vKT+LdVqsTKNvjfss/KP/qm0tLFS1e3bdGoAuUqL4ndNqq7zfuRVpd89fDoN5N6ymOxWPlvl64sqfCRA5D2AK4Si7393se/feif2/x82arNaVejsGphtYIgCBrWrVV+gx+d3ub2Ad1ee+vDPz/+WrzFnDDsirK/ffmN+V+t++bMTofPnl90QpuDa+5VbdyUd0N79CAI1q3fUPbneO5/veUnpaWl28RV6fb/NCgtLVshTuqAt95HwrutEsvLS/qdY7Gtv61Zo9pWeb/1q6uSOnIA0h7An64sqVG96rsLvrtXeXCjfbt3OWrcq++u+iyIxYLfDuj2yfLPp85cdN0FnSdP/2Dhx6vim117/kkLP1l11W3PbCotDYJgr8Kq5Xf79foNL/17ftdOLf8w4uWunVq+u+DTxUXFoT16sho3qLN1KxkcdMC+i5cmfcB7sNs1TRputXGdmoXtWjZ+b9Gy5cU7DsiNm0prbH3WhzSpW+EnGoBUSbSdeu2tD1s2bXBok3plP7m0x/FX9e4UX1W94KxjOrQ++Jf3TLr7ideWF3/xu4Hd8rbcua1Tq3D152s3bWnyTmjTdJu2cuyUuY3q127botFpxzff/uVX6X70pDRrUu+4VgeVffv9E49oULfmC1PfT/aAK7zbae8s2r9erS7HHFr2k/7ndLz3F73LbqrHm+n8/O+e1hWrv2h+UP2CLR/lceLRh9StvVeFn2gAUiXRj6J8f9Hybl2O7HVGm7Vfr6+9d+FF3Y698Oxj/vzYq/+etfjA/fcZNvSc5yfPefKFmd9u3LSoqPiKczp+te6b+Gplq8MOOPHoQ9Z/820sFuvcvtmV53bcu7BayRfrnn95dnzPRctLzv1e23aHN27ScJ+hd034utw94XQ/+nUXdF5cVFz24ZFndz7ykMZ1hz05Nf7t4Yc0OL1Di0lT31v0SXF842Wr1vzw1FabNpbWqF5wducjh/Y7Y96Hn/5y+Avx28VX9zlx48ZN/5q1uOTLdbs44G1Obbe7LX+Q85es6HFKq7NOOmLd+g01Cqv27tqu3486PDpuxqQtad2xbdMjmzXcsGFjXl6VouUlQRDUqVWjc/tmLZrW/3zN1+2PaHLLFV2LVpTUq7NX/DS3qcCuS220AKRQoregV32+ts+QUYMvPmXwxadUq5q/8H+rbvjTmPGvzasSi/1+UPev1n9zxyOvbO7SZi6aNPW9ARd2mTx9weKi4lvunvjFV+sv79mh8LyCRUXFfxo1ZWj/M/apVVi2502lpeNfm9evV4fX315Y9oLk0B49KfMWLntzzkd9z25/QP3aK1d/+ci4GcOfmhb/EIwgCEa/MucHJ7e69xe9z776/p0dcAV2W96yVWt6Dxk5+OJTrrugc0FB3kdLV//63hfL3nMVBMEj42a0bdHo2vM7r/tmQ/s+dwRBcPcTr5VuKu15eut7bjl30SfFtz/wUo9TW7U4uH6yT7ShApBascYd+qpCIrb/SKls3i0AWc5/xgAAAhgABDAAkCbWgAFABwwAAhgAEMAAIIABAAEMAAIYABDAAJB9/h/jQKerSTk5XAAAAABJRU5ErkJggg==A lake at duskA made-up picture of a lake and a hill at dusk.The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell. Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master. Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and its draught, and to say how many nights they plan to stay. The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell. Figure 1: a picture that text flows aroundiVBORw0KGgoAAAANSUhEUgAAAeAAAAEOCAIAAADe+FMwAAARDUlEQVR42u3de1zUZb7A8We4KZhCgKACKqBEEiAqZsVC0rZaqFQaqKBYaamZlmvbafOcfW3mXm3dzpq+SgXMOqmlkVrqmrcVV/HGxdS4iKByURCDvKQIc/741exwG2ZgLs8Mn/eLPwQG/P2emfnw9WFmVPUPni8AAPKxYwkAgEADAAzgIFQsAgAwQQMACDQAWDu2OABA2kBTaACQM9DkGQBknaApNABIiV8SAgCBBgAYgi0OAJA10PQZAOTEFgcAyDpBCxUzNAAwQQMA9J+gmZ8BQNJA8ygOAJATWxwAIOsEzQANAJIGmi0OAJATWxwAIOsEzQANAJIGmieqAICc2OIAAFknaOZnAJA00DyKAwDkxBYHAMg6QTNAA4CkgWaLAwDkxBYHAMg6QTNAA4CkgeaJKgAgJ7Y4AEDWCZr5GQAkDTSP4gAAJmgAgAHYgwYAaSdoRmgAYIIGABBoALB6bHEAABM0AMCwCZo1AAA5A81rcQAAEzQAwADsQQOAtBM0IzQAMEEDAAyZoFkDAJAz0LzcKADIiS0OAJB1gmaABgBJA80WBwAwQQMADMAeNABIO0EzQgOApIFmDQBASmxxAIC0EzQjNAAwQQMADJmgeSA0AMgZaPoMANJO0AAAGbEHDQDSTtCM0ADABA0AMGSCZg0AQM5A8ygOAGCCBgAQaACwgUCzxQEATNAAAAPwMDsAkHaCZoQGAEkDzRoAgJTY4gAAaSdoRmgAYIIGABgyQbMGACBpoCk0AEiJLQ4AkHaCZg0AQM5A81ocAMAEDQAg0ABgA4FmiwMAmKABAAQaAGwg0GxxAAATNACAQAOADQSaLQ4AYIIGABiAF0sCAGknaEZoAJA00KwBAEiJLQ4AkHaCZoQGAEkDzRoAAIEGABgQaJ6oAgBM0AAAAg0ANhBotjgAgAkaAECgAcAGAs0WBwAwQQMACDQA2EKgKTQASIlXswMAaSdo1gAAJA00hQYAKbHFAQDSTtCsAQBIGmgKDQBM0AAAAwIthJpVAAApJ2hGaACQNNCsAQAQaACAAYHm5UYBgAkaAECgAcAWAk2hAUBKvBYHAEg7QbMGACBpoCk0ADBBAzCn1I9m6r7A89PXsEpSB5olALpgmptdjFLLOkEzQgM2ZO26mR34KqXUL6SQadkCzRoANmFNh9LcLO4zaTSBBmBcqztdZ03lZ9FoiQKt4vWgAev2Yfos47b+xRmrWVUZ8EQVgDqb43uiI4FWCcGbGd72nMphEXgz+pvpSvph+iyW1+JvPIrDHHbn5QghvjmVI4R4PGwoCwKr8EH6rNnPsddhSfyS0OT+mZfTMta/ItMmW+2us7ar0ky+EUEfLEs1J2Ehq2Aiu5qmuaUxZNpkq90V1nZlmjl2iucyRFtwguZHpKl6kZujf1PGhFNqIy/1rrwc217Vlanm+j0eibDgBD03kQna+HbqUeeWxpJpEyy1ra7q+6nme6DFy88zRFtoguano9Ht6FCdNa15gkwbdZ135uawpMzQVhto1t54vs7J6fw3Ubrz5FCaYrR1tr0l/cdasz5OmUowQVu9r4xR52YNirNoU77KyYmTLGqdWeSv5TsdJmi0s/KvTGYPWro6N2O2rOg4CxnSZqxFtoFM/+9acz/Tb/4Lq812LfNzVGuC5rU4Om17dq4ZwjQuItyCR266YzD/Clv2XKx1lGsaCpPe5jU/ibmOrG+LY1t2rhBivBzX3DYTp7nVu0Rnzr2TB2yUY5Bkhbdn546n0foH2mpv89a97K9Oec2KDndr0xvHBItebVvNeEttSc9zN/VBmu4qMNvyTrD0nX9rdq6hx7B8zYtmPsjA4S/LUIAJXazUqtemyh7oL0+2f0eNHxYu4VGZQcsTt9SBGfcqMPNZmP/209Y56nkkf1ttvkBLkmYZrjILBHqh3IHOMOSO+pS5rrMMOeosoc5fBRZcWzPcfvQ/O90H867pAy1nly14ryfQxrmLmvQKI82muxYkWVtT3H46c2qtHo9JA20tae4KpVb9OkmuQH9xwmj30qeHh0t7bF2HPteCbAtrrFuOcc+r2VEt+9DIjbbSLpvhjm/JQC+SINBbTHn/fMZI19YW6myaK0Lahe3YLccMp6Mc2F+NF2ibSbOJ7vuWDPTrlg70ZrPcPyd27qraTJ1NcEXIv6r632wsci7nTrxPl81w97dkoH+TbIFAf37cMvfMSSPCreVQIQ8dNxtJbh6GlrrrpLnzBbBwoN9IftXMf+Vnx/MsftrPjgizlkOFDLRvMDLfKnSXust2ufMpsFig/8ssgd4k5W06Qed1s4k6w2o1KzVpNlYWzB7oaSYP9KZjUpcuITLM6o4ZgGUTYaZAv2myQG+0tsYlRoZZ42EDsEgrzBHo35og0BtoHICuZLJpkm3M/1Hl06N0GUBXpJlKp4w0ZqmN83Kj/0eaAeDnOXWqkTLdqUB/QpcBoO2ZNalzpe7g/6jycdYprgMA0HOKTX4wtCOBpssAYGqaeBpUan23ONaTZgAwUqmn6Zfpdh7F8dERugwARqYZeaePCu3IBL2ONAOAiWmG4JTWSt080Ol0GQDMTjMTz9Aq9X+2ONIOk2YAsDBlSn7uoVAhhMp3VDIrAgASsmMJAIBAAwAINAAQaAAAgQYAAg0AINAAAAINAAQaAECgAYBAAwAINACAQAMAgQYAEGgAINAAAAINAAQaAECgAQAEGgAINACAQAMAgQYAEGgAAIEGAAINACDQAECgAQAEGgAINACAQAMACDQAEGgAAIEGAAINACDQAAACDQAEGgBAoAGAQAMACDQAEGgAAIEGABBoACDQAAACDQAEGgBAoAEABBoACDQAgEADAIEGABBoAACBBgACDQAg0ABAoAEABBoACDQAgEADAAg0ABBoAACBBgACDQAg0AAAAg0ABBoAQKABgEADAAg0ABBoAACBBgDowcGgSwf7e8+dHBUe1M/DrUf5ldqMvac+3Pzvu3cbJTyxlYuffezBoPvGLzX0Czcum7H7cP6azYe5cQCwmgk6ZsSgLcufd7C3+/2qnbPf3rTvWOG8qdFL5sXZ2Io4Odo7Odjr/2Mgf9tb3IwAWHiCfn1GbGnFtXlLP29Uq4UQmdnFP96+OzvhkY07Tubkl9nMijzz6lq1mhsGAKsKdP++9+YWlDdq1euLvXnB/t7eHj2Vd527O85NjBoXHeLl3rPyat3mb3I/2HSoofGnyzt3c5yTGDUuJsTTrUfZldqt+06lZmTdvnNX+Wx3J4eZEx+Kiw7x9Xa7U3/3bPHldVuP7j6cr3w2f9tbe7IKPt+d+3JiVKCf57W6mxl7T63YcLChoVH7r46LDvF063Hp8vef7jjZ1lko3+qT7cfnJ8cEDehdX9+QmX1+Wfqe8qo65QLfbX1rT1bB3Hc+a/ewNbOz8gdlO0X5/povF003W5TPZp4sfm3ao2lfZq3ckNnuugEg0O0rulA1LNj3ofCBh3NLlI+UlNW89PZG5c+ODvZpS5J8vVxXbsw8X3Z16H0+sxOj+nj0+p/3v1b2DdKWTA0O8F7x6cHTRRURwb4vT/lFsL/3gj9vEUI4ONilLpkaHuSTlpF1MPtcj+5OCWMjVvx20l/T9qzZckT5/v16uy6Z9+R7Hx+4WHltwujQuZOjhBDvfXJAifv6PyQHDfBatTEzr6D8/gDvBUkxN27dbutE/Lzd/vabp9Mysv6+fn+gn+eryY8OG+IbP39N7Q+3Wm536DjsxEXpi2bERj7QP3FRuv7LOMjPMzyo34oNB/cfLWx33QAQaL0sXb177e+npL+TdOzbCwdOFB04fq6g5IrmsykTIsMG941fsKawtEoIcTi3pL6hcVFKbGrGkZKympT4kRH3+85957M9WQXKZ4VKLEiKCc/wyc0vmz4+cvgQvyUf7Pp4+3Hlu+07Vpj69tSFKbG7D+eXVlwTQtwf4D1nyaa9RwuFEFl5paPCBj4VG6oEOiV+ZOjgfgv+vGVn5lkhxKGc898WVa5bmtTWiQQN9Jqx+BPlx8zh3JLq72+898YzLz378F9S9zS7pO7Dzskvq7vxoxDCoB0ev773TlqYdrqoQp914wYKdGUG/JLwxJmLY2avSsvI6uPZa1FK7LZ/zPrs3edCB/dTPjsu5oG8woriS1ft7e2Ut31HC1UqMSp0oBBiXHRI+ZVaJXOKDTtO/iV1z927DUKICY+G1tTe1N6XUKvFqo2Z9naqcTEPKB+pqb2571ih8udGtbroQpW3Zy/l3SeihlRW1+06dFbz5UfySoovXW3rRErKajT/CBBC/PPQd1U11x8bGdTykroPu2MKS6s0dW533QAwQevr8tUf/rT2mz+t/WZw/97xo0OnT4hc/8fkia+lnrtY7e/r0d3J4UzGm82+xN3VRQjh7+Nx8uwl7Y/X1N5c+8VP2xcBvh55BeWaDWVFfmmVECLAz+OnDZaL1dq/u6u/22Bvp1L+PNDHPS+/vNlv9i5WXgvw9Wj1LC5UXtN+t1GtPl9eM/Q+n5aX1H3YelKpVNrvllfVNvkrdK4bAAJt+Bh4oWrZur35JZeXLXpq8tiIpat326lUJ85c/MOa3c0uWVn9gxDCzk5lb2/Yk2LUarUySiuu32xzT9nB3q5pA4UQopuTg+7v3OxDzX48/PTvC8MPWwjR7Fh6unRr8vOg6W//dK8bALY42pc4NmLL8udDAvtof1DZEe7t3lMIUVFV69Ld6dvCCs3b9Zu3R0cO7uHsJISoqKrz6+Om/bVuPZ1HRw5WHgFSfOlqoJ+ng0OTgwn29xZCnP95p0Ld9mPfyqvqBvq4N2t0/z73tnV5X2+3phOuGNDP/Xx5K1siug+7VQ2NahdnJ+2PaP4R0Crd6waAQLevoLQqZFDf+Ukx2sOq0mvlV4UHjhcF+3sP8vPUfHZG/INzEh65VndTCJGZXdzXs1fMiEGaz86a9PCq/05wsLcTQmzdf8rd1WXy2GHa0ZyT8EhDo3r7gW/bPbZ9Rwu83HvGam0ij44c3M/Lta3LB/p5jgwdoHn3iagh3h49dxw82/KSug9b82ND+0fLlZofggZ4Of78VJeoYQEerj10HLzudQPQldn38g3T53KV1XWe994zPiYkMqT/7fq77m4uYx4OXvzSmMrqusUrvrpT33C2+PK4mJBnHg+/ceu26z3O08ZFJsWNWL5+/79zzgsh8kuuxI8OffIXQ368Xe/i7JQwJmLmxFEfbT369cGzQohTRRWjwgZOenyoc3fHRrU6qL/XGy/8Mnp44Lvpe/cdKxJCvDI1+nzZ1a/+dUZzPHHRIQG+His+PSiEOHOucnzMA09E3X/91p17nLvFRQ/5dUrsxcprHm49lAtoe2VqdGV13VOxoY0NapfujnHRIW/OfPx0UcXvVu5Qdjm0/y7dhy2EeHiof0hgn/r6Bnt7u7LLtUIIt14u0cMD7/P3+r7u1vAhfotfHFN2pdbz5yNpeSK61w1AV6byHZWs/6UfezBo+oTIQF/Pe3p0u3T5+12Hvkv94siNW3eUz/b17LVw+ujoEYHdnBzOXahO/zJr24HTmq/16+O2cProqIgAR0f70vKaDTtObtyZrXnai3M3x5kTH4qLHuLr7abkPv3LrGZPVGnr2R9CCB8v19efe+yRCH8He7uC0qq/r98/bXxkq6/FoXyrI3mlyXHD+3m5VtVc3/6v0ys3ZN66Xd/q36X7sAcP6L389af9fTx+vFM/PHGZEMLeTjVvSvTTvwxz6+lcfOnqB5sOxceGNnuiivaJtLtuAAh0V9FqIgFAQrzcKAAQaAAAgQYAG9Dl9qABgAkaAECgAYBAAwAINAAQaAAAgQYAEGgAsHr/D8Hg2xT5E4m1AAAAAElFTkSuQmCCThe second pictureA shape with text in itA line of text beside a shape.Links, fields and notesAn external link: exav.org. An internal link: back to the introduction. A reference to a bookmark: Tables.Fields: this document is titled “Harbour handbookâ€�, and this line is on page 1. A fixed date: 01/01/2000.A comment is attached to this wordexav2000-01-01T00:00:00A comment: the viewer shows or ignores it.. A change that was tracked: this text was inserted, and some text was deleted before this one.Citations [1] [2]. 2A second footnote, with emphasis.Wide table (landscape)MonthBerth 1Berth 2Berth 3Berth 4Berth 5Berth 6Berth 7Berth 8Berth 9Berth 10TotalJan162738495146Feb849516273853Mar627384951651Apr495162738449May273849516247Jun951627384954Jul738495162752Aug516273849550Sep384951627348Oct162738495146Nov849516273853Dec627384951651Nights let at each berth, by month. + + diff --git a/crates/exav-viewer/demo/examples/files/converted/handbook.odt b/crates/exav-viewer/demo/examples/files/converted/handbook.odt new file mode 100644 index 00000000..ebd0b157 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/converted/handbook.odt differ diff --git a/crates/exav-viewer/demo/examples/files/converted/handbook.rtf b/crates/exav-viewer/demo/examples/files/converted/handbook.rtf new file mode 100644 index 00000000..39371eec --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/converted/handbook.rtf @@ -0,0 +1,620 @@ +{\rtf1\ansi\deff3\adeflang1025 +{\fonttbl{\f0\froman\fprq2\fcharset0 Times New Roman;}{\f1\froman\fprq2\fcharset2 Symbol;}{\f2\fswiss\fprq2\fcharset0 Arial;}{\f3\froman\fprq2\fcharset0 Liberation Serif{\*\falt Times New Roman};}{\f4\fmodern\fprq1\fcharset0 Liberation Mono{\*\falt Courier New};}{\f5\fswiss\fprq2\fcharset0 Liberation Sans{\*\falt Arial};}{\f6\fswiss\fprq0\fcharset128 FreeSans;}} +{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0;\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128;\red192\green192\blue192;\red192\green57\blue43;\red6\green69\blue173;\red106\green27\blue154;\red11\green79\blue108;\red20\green108\blue148;\red232\green241\blue245;\red255\green235\blue59;\red219\green233\blue244;\red240\green240\blue240;\pagebb} +{\stylesheet{\s0\snext0\rtlch\af8\afs24\alang1081 \ltrch\lang1033\langfe2052\hich\af3\loch\widctlpar\hyphpar0\ltrpar\cf0\f3\fs22\lang1033\kerning1\dbch\af7\langfe2052 Normal;} +{\s1\sbasedon33\snext34\loch\ilvl0\outlinelevel0\cf20\fs34 heading 1;} +{\s2\sbasedon33\snext34\loch\ilvl1\outlinelevel1\cf21\fs26 heading 2;} +{\*\cs15\snext15\loch\b Strong;} +{\*\cs16\snext16\loch\i Emphasis;} +{\*\cs17\snext17\loch\ul\ulc0 Underline;} +{\*\cs18\snext18\loch\strike Strike;} +{\*\cs19\snext19\loch\cf17 Red;} +{\*\cs20\snext20\loch\chcbpat23 Highlight;} +{\*\cs21\snext21\loch\scaps SmallCaps;} +{\*\cs22\snext22\loch\fs30 Big;} +{\*\cs23\snext23\hich\af4\loch\f4\fs20 Mono;} +{\*\cs24\snext24\loch\sub Sub;} +{\*\cs25\snext25\loch\super Super;} +{\*\cs26\snext26\loch\cf18\ul\ulc0 Hyperlink;} +{\*\cs27\snext27\loch\cf19\ul\ulc0 FollowedHyperlink;} +{\*\cs28\snext28 Index Link;} +{\*\cs29\snext29 Footnote Characters;} +{\*\cs30\snext30\loch\super footnote reference;} +{\*\cs31\snext31\loch\super endnote reference;} +{\*\cs32\snext32 Endnote Characters;} +{\s33\sbasedon0\snext34\hich\af5\loch\sb255\sa113\keepn\f5\b Heading;} +{\s34\sbasedon0\snext34\loch\sl300\slmult1\sb0\sa125 Body Text;} +{\s35\sbasedon34\snext35\rtlch\af6 \ltrch List;} +{\s36\sbasedon0\snext36\loch\sb57\sa113\cf0\fs18\i caption;} +{\s37\sbasedon0\snext37\rtlch\af6 \ltrch\loch\noline Index;} +{\s38\sbasedon0\snext39\hich\af5\loch\ql\sb1701\sa170\cf20\f5\fs60\b Title;} +{\s39\sbasedon0\snext39\hich\af5\loch\sb0\sa340\cf0\f5\fs32 Subtitle;} +{\s40\sbasedon0\snext40\loch\sb0\sa57\cf0\fs20 Author;} +{\s41\sbasedon1\snext41 Heading 1 Landscape;} +{\s42\sbasedon33\snext42\rtlch\afs32\ab \ltrch\loch\fi0\li0\lin0\noline\fs32\b index heading;} +{\s43\sbasedon0\snext43\hich\af5\loch\sb850\sa170\f5\fs30\b TOC Heading;} +{\s44\sbasedon0\snext44\loch\sb0\sa57 toc 1;} +{\s45\sbasedon0\snext45\loch\li567\lin567\ri567\rin567\sb113\sa170\brdrt\brdrnone\brdrl\brdrs\brdrw45\brdrcf21\brsp85\brdrb\brdrnone\brdrr\brdrnone\cf0\i Block Quotation;} +{\s46\sbasedon34\snext46\loch\qj Justified;} +{\s47\sbasedon34\snext47\loch\qc Centered;} +{\s48\sbasedon34\snext48\loch\qr Right;} +{\s49\sbasedon34\snext49\loch\li850\lin850\brdrt\brdrnone\brdrl\brdrs\brdrw20\brdrcf0\brsp113\brdrb\brdrnone\brdrr\brdrnone Indented;} +{\s50\sbasedon0\snext50\hich\af4\loch\li283\lin283\sb0\sa0\brdrt\brdrnone\brdrl\brdrnone\brdrb\brdrnone\brdrr\brdrnone\cbpat25\f4\fs19 Code;} +{\s51\sbasedon0\snext51\loch\fi-283\li283\lin283\fs18 footnote text;} +{\s52\sbasedon0\snext52\loch\tqc\tx4819\tqr\tx9638\noline Header and Footer;} +{\s53\sbasedon0\snext53\loch\cf0\fs18 header;} +{\s54\sbasedon0\snext54\loch\qc\cf0\fs18 footer;} +{\s55\sbasedon0\snext55\loch\sb0\sa45 List Contents;} +{\s56\sbasedon0\snext56 Table Contents;} +{\s57\sbasedon56\snext57\loch\qc\cf8\b Table Heading;} +{\s58\sbasedon56\snext58\loch\qr Table Right;} +}{\*\listtable{\list\listtemplateid1 +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'03\'00. ;}{\levelnumbers\'01;}\fi-510\li510} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'04\'00.\'01 ;}{\levelnumbers\'01\'03;}\fi-680\li680} +{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0} +{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0} +{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0} +{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0} +{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0} +{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0} +{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}\listid1} +{\list\listtemplateid2 +{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u8226 ?;}{\levelnumbers;}\f9\fi-283\li510} +{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u9702 ?;}{\levelnumbers;}\f9\fi-283\li964} +{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u9642 ?;}{\levelnumbers;}\f9\fi-283\li1417} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'03.;}{\levelnumbers\'01;}\fi-360\li1800} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'04.;}{\levelnumbers\'01;}\fi-360\li2160} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'05.;}{\levelnumbers\'01;}\fi-360\li2520} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'06.;}{\levelnumbers\'01;}\fi-360\li2880} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'07.;}{\levelnumbers\'01;}\fi-360\li3240} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'08.;}{\levelnumbers\'01;}\fi-360\li3600}\listid2} +{\list\listtemplateid3 +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'00.;}{\levelnumbers\'01;}\fi-340\li510} +{\listlevel\levelnfc4\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'01);}{\levelnumbers\'01;}\fi-340\li1020} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'02.;}{\levelnumbers\'01;}\fi-360\li1440} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'03.;}{\levelnumbers\'01;}\fi-360\li1800} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'04.;}{\levelnumbers\'01;}\fi-360\li2160} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'05.;}{\levelnumbers\'01;}\fi-360\li2520} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'06.;}{\levelnumbers\'01;}\fi-360\li2880} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'07.;}{\levelnumbers\'01;}\fi-360\li3240} +{\listlevel\levelnfc0\leveljc0\levelstartat1\levelfollow0{\leveltext \'02\'08.;}{\levelnumbers\'01;}\fi-360\li3600}\listid3} +}{\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}{\listoverride\listid2\listoverridecount0\ls2}{\listoverride\listid3\listoverridecount0\ls3}}{\*\revtbl {Unknown;}{exav;}} +{\*\generator LibreOffice/25.2.3.2$Linux_AARCH64 LibreOffice_project/520$Build-2}{\info{\title Harbour handbook}{\subject A document made for the exav demo (CC0-1.0)}{\keywords demo}{\author exav project}{\creatim\yr2000\mo1\dy1\hr0\min0}{\revtim\yr0\mo0\dy0\hr0\min0}{\printim\yr0\mo0\dy0\hr0\min0}}{\*\userprops}\deftab709 +\hyphauto1\viewscale100\formprot\formshade\nobrkwrptbl\paperh16838\paperw11906\margl1247\margr1247\margt850\margb680\sectd\sbknone\sftnnar\saftnnrlc\sectunlocked1\pgwsxn11906\pghsxn16838\marglsxn1247\margrsxn1247\margtsxn1284\headery850\margbsxn1114\footery680{\header\pard\plain \s53\loch\cf0\fs18\ql\ltrpar{\loch +Harbour handbook\tab \tab exav demo} +\par }{\footer\pard\plain \s54\loch\qc\cf0\fs18\qc\ltrpar{\loch +Page }{\loch +{\field{\*\fldinst PAGE }{\fldrslt 5}}}{\loch + of }{\loch +{\field{\*\fldinst NUMPAGES }{\fldrslt 5}}} +\par }\ftnbj\ftnstart1\ftnrstcont\ftnnar\aenddoc\fet\aftnrstcont\aftnstart1\aftnnrlc +{\*\ftnsep\chftnsep}\pgndec\pard\plain \s38\hich\af5\loch\ql\sb1701\sa170\cf20\f5\fs60\b\ql\sb1701\sa170\ltrpar{\loch +Harbour handbook} +\par \pard\plain \s39\hich\af5\loch\sb0\sa340\cf0\f5\fs32\ql\sb0\sa340\ltrpar{\loch +The east quay: rules, figures and notes} +\par \pard\plain \s40\loch\sb0\sa57\cf0\fs20\ql\sb0\sa57\ltrpar{\loch +exav project, written for the demo (CC0-1.0)} +\par \pard\plain \s40\loch\sb0\sa57\cf0\fs20\ql\sb0\sa57\ltrpar{\loch +1 January 2000} +\par \pard\plain \s43\hich\af5\loch\sb850\sa170\f5\fs30\b\ql\sb850\sa170\ltrpar{\loch\fs30\b\loch +Contents} +\par \pard\plain \s44\loch\sb0\sa57\ql\sb0\sa57\ltrpar +{\field{\*\fldinst { TOC \\f \\o "1-2" \\h}}{\fldrslt {{{\field{\*\fldinst HYPERLINK "#intro" }{\fldrslt {\loch +1. Introduction}}}} +\par \pard\plain \s44\loch\sb0\sa57\ql\sb0\sa57\ltrpar{{\field{\*\fldinst HYPERLINK "#text" }{\fldrslt {\loch +2. Text and styles}}}} +\par \pard\plain \s44\loch\sb0\sa57\ql\sb0\sa57\ltrpar{{\field{\*\fldinst HYPERLINK "#lists" }{\fldrslt {\loch +3. Lists}}}} +\par \pard\plain \s44\loch\sb0\sa57\ql\sb0\sa57\ltrpar{{\field{\*\fldinst HYPERLINK "#tables" }{\fldrslt {\loch +4. Tables}}}} +\par \pard\plain \s44\loch\sb0\sa57\ql\sb0\sa57\ltrpar{{\field{\*\fldinst HYPERLINK "#pictures" }{\fldrslt {\loch +5. Pictures and shapes}}}} +\par \pard\plain \s44\loch\sb0\sa57\ql\sb0\sa57\ltrpar{{\field{\*\fldinst HYPERLINK "#links" }{\fldrslt {\loch +6. Links, fields and notes}}}} +\par \pard\plain \s44\loch\sb0\sa57\ql\sb0\sa57\ltrpar{{\field{\*\fldinst HYPERLINK "#wide" }{\fldrslt {\loch +7. Wide table (landscape)}}}}}}} +\par \pard\plain \s1\loch\ilvl0\outlinelevel0\cf20\fs34{\listtext\pard\plain 1. \tab}\ls1 \fi-510\li510\lin510\ql\fi0\li0\lin0\ltrpar{\loch +{\*\bkmkstart intro}{\*\bkmkend intro}Introduction} +\par \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell.} +\par \ql\fi-283\li283\lin283\ltrpar\pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master. }{{\super \loch \chftn{\*\footnote \chftn\pard\plain \s51\loch\fi-283\li283\lin283\fs18{\loch +\tab The register of 2000 lists sixty berths.} +}} +} +\par \pard\plain \s45\loch\li567\lin567\ri567\rin567\sb113\sa170\brdrt\brdrnone\brdrl\brdrs\brdrw45\brdrcf21\brsp85\brdrb\brdrnone\brdrr\brdrnone\cf0\i\ql\li567\lin567\ri567\rin567\sb113\sa170\brdrt\brdrnone\brdrl\brdrs\brdrw45\brdrcf21\brsp85\brdrb\brdrnone\brdrr\brdrnone\ltrpar{ +\u8220\'93}{\loch +A harbour is a promise that the sea will wait.\u8221\'94 } +\par \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and its draught, and to say how many nights they plan to stay.} +\par \pard\plain \s1\loch\ilvl0\outlinelevel0\cf20\fs34{\listtext\pard\plain 2. \tab}\ls1 \fi-510\li510\lin510\ql\fi0\li0\lin0\ltrpar{\loch +{\*\bkmkstart text}{\*\bkmkend text}Text and styles} +\par \pard\plain \s2\loch\ilvl1\outlinelevel1\cf21\fs26{\listtext\pard\plain 2.1 \tab}\ls1 \fi-680\li680\lin680\ql\fi0\li0\lin0\ltrpar{\loch +Character formatting} +\par \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch\cs15\loch\b\loch +Bold}{\loch +, }{\loch\cs16\loch\i\loch +italic}{\loch +, }{\loch\cs17\loch\ul\ulc0\loch +underlined}{\loch +, }{\loch\cs18\loch\strike\loch +struck through}{\loch +, }{\loch\cs19\loch\cf17\loch +red}{\loch +, }{\loch\cs20\loch\chcbpat23\loch +highlighted}{\loch +, }{\loch\cs21\loch\scaps\loch +small capitals}{\loch +, }{\loch\cs22\loch\fs30\loch +large}{\loch + and }{\loch\cs23\hich\af4\loch\f4\fs20\loch +monospaced}{\loch + text; H}{\loch\cs24\loch\sub\loch +2}{\loch +O and 10}{\loch\cs25\loch\super\loch +3}{\loch +; a }{\loch\cs16\loch\i\loch +mixed}{\loch + style.} +\par \pard\plain \s2\loch\ilvl1\outlinelevel1\cf21\fs26{\listtext\pard\plain 2.2 \tab}\ls1 \fi-680\li680\lin680\ql\fi0\li0\lin0\ltrpar{\loch +Paragraph formatting} +\par \pard\plain \s46\loch\qj\qj\ltrpar{\loch +The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell. Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master.} +\par \pard\plain \s47\loch\qc\qc\ltrpar{\loch +A centred line} +\par \pard\plain \s48\loch\qr\qr\ltrpar{\loch +A line aligned to the right} +\par \pard\plain \s49\loch\li850\lin850\brdrt\brdrnone\brdrl\brdrs\brdrw20\brdrcf0\brsp113\brdrb\brdrnone\brdrr\brdrnone\ql\li850\lin850\brdrt\brdrnone\brdrl\brdrs\brdrw20\brdrcf0\brsp113\brdrb\brdrnone\brdrr\brdrnone\ltrpar{\loch +An indented paragraph with a left border: Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and its draught, and to say how many nights they plan to stay.} +\par \pard\plain \s50\hich\af4\loch\li283\lin283\sb0\sa0\brdrt\brdrnone\brdrl\brdrnone\brdrb\brdrnone\brdrr\brdrnone\cbpat25\f4\fs19\ql\li283\lin283\sb0\sa0\brdrt\brdrnone\brdrl\brdrnone\brdrb\brdrnone\brdrr\brdrnone\ltrpar\cbpat25{\loch +moor --boat Aurora --length 6.5} +\par \pard\plain \s50\hich\af4\loch\li283\lin283\sb0\sa0\brdrt\brdrnone\brdrl\brdrnone\brdrb\brdrnone\brdrr\brdrnone\cbpat25\f4\fs19\ql\li283\lin283\sb0\sa0\brdrt\brdrnone\brdrl\brdrnone\brdrb\brdrnone\brdrr\brdrnone\ltrpar\cbpat25{\loch +berth 14 free until 06:00} +\par \pard\plain \s2\loch\ilvl1\outlinelevel1\cf21\fs26{\listtext\pard\plain 2.3 \tab}\ls1 \fi-680\li680\lin680\ql\fi0\li0\lin0\ltrpar{\loch +A section in two columns} +\par \sect\sectd\sftnnar\saftnnrlc\sectunlocked1\pgwsxn11906\pghsxn16838\marglsxn1247\margrsxn1247\margtsxn1284\headery850\margbsxn1114\footery680\cols2\linebetcol\colsx454\ltrsect\sbknone\pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell. Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master. Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and its draught, and to say how many nights they plan to stay. The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell.} +\par \sect\sectd\sftnnar\saftnnrlc\sectunlocked1\pgwsxn11906\pghsxn16838\marglsxn1247\margrsxn1247\margtsxn1284\headery850\margbsxn1114\footery680\pgndec\sbknone\pard\plain \s1\loch\ilvl0\outlinelevel0\cf20\fs34{\listtext\pard\plain 3. \tab}\ls1 \fi-510\li510\lin510\ql\fi0\li0\lin0\ltrpar{\loch +{\*\bkmkstart lists}{\*\bkmkend lists}Lists} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls2 \fi-283\li510\lin510\ql\ltrpar{\loch +Water at every berth} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain \u9702\'3f\tab}\ilvl1\ls2 \fi-283\li964\lin964\ql\ltrpar{\loch +cold, from the quay} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain \u9702\'3f\tab}\ilvl1\ls2 \fi-283\li964\lin964\ql\ltrpar{\loch +hot, in the shower block} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls2 \fi-283\li510\lin510\ql\ltrpar{\loch +Electricity} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain \u9702\'3f\tab}\ilvl1\ls2 \fi-283\li964\lin964\ql\ltrpar{\loch +16 A at berths 1 to 40} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain \u9642\'3f\tab}\ilvl2\ls2 \fi-283\li1417\lin1417\ql\ltrpar{\loch +with a meter} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain \u9642\'3f\tab}\ilvl2\ls2 \fi-283\li1417\lin1417\ql\ltrpar{\loch +without a meter} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain \u9702\'3f\tab}\ilvl1\ls2 \fi-283\li964\lin964\ql\ltrpar{\loch +32 A at the east pontoon} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain \u8226\'95\tab}\ilvl0\ls2 \fi-283\li510\lin510\ql\ltrpar{\loch +Wi-Fi on the quay} +\par \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +Numbered, with other numbering styles at each level:} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain 1.\tab}\ilvl0\ls3 \fi-340\li510\lin510\ql\ltrpar{\loch +Call the office on channel 9} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain 2.\tab}\ilvl0\ls3 \fi-340\li510\lin510\ql\ltrpar{\loch +Give the length and the draught} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain a)\tab}\ilvl1\ls3 \fi-340\li1020\lin1020\ql\ltrpar{\loch +length over all, in metres} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain b)\tab}\ilvl1\ls3 \fi-340\li1020\lin1020\ql\ltrpar{\loch +draught, in metres} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain 3.\tab}\ilvl0\ls3 \fi-340\li510\lin510\ql\ltrpar{\loch +Take the berth you are given} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain a)\tab}\ilvl1\ls3 \fi-340\li1020\lin1020\ql\ltrpar{\loch +tie up bow first} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain b)\tab}\ilvl1\ls3 \fi-340\li1020\lin1020\ql\ltrpar{\loch +tie up stern first} +\par \pard\plain \s55\loch\sb0\sa45{\listtext\pard\plain 4.\tab}\ilvl0\ls3 \fi-340\li510\lin510\ql\ltrpar{\loch +Pay at the office} +\par \pard\plain \s1\loch\ilvl0\outlinelevel0\cf20\fs34{\listtext\pard\plain 4. \tab}\ls1 \fi-510\li510\lin510\ql\fi0\li0\lin0\ltrpar{\loch +{\*\bkmkstart tables}{\*\bkmkend tables}Tables} +\par \pard\plain \s36\loch\sb57\sa113\cf0\fs18\i\ql\sb57\sa113\ltrpar{\loch +Table }{\loch +{\field{\*\fldinst SEQ Table \\* ARABIC }{\fldrslt 1}}}{\loch +: berths by length, with merged cells and a repeated header row} +\par \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx2551\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx4252\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx6236\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx7937\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Length}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berths}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Let by}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Fee (EUR)}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2551\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4252\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6236\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx7937\pard\plain \s56\intbl\ql\ltrpar{\loch +up to 6 m}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +12}\cell\pard\plain \s56\intbl\ql\ltrpar{\loch +week}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +18.00}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2551\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4252\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6236\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx7937\pard\plain \s56\intbl\ql\ltrpar{\loch +6 to 8 m}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +18}\cell\pard\plain \s56\intbl\ql\ltrpar{\loch +season}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +24.50}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2551\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4252\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6236\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx7937\pard\plain \s56\intbl\ql\ltrpar{\loch +8 to 10 m}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +14}\cell\pard\plain \s56\intbl\ql\ltrpar{\loch +season}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +31.00}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2551\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4252\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6236\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx7937\pard\plain \s56\intbl\ql\ltrpar{\loch +10 to 12 m}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s56\intbl\ql\ltrpar{\loch +year}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +42.00}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2551\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4252\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6236\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx7937\pard\plain \s56\intbl\ql\ltrpar{\loch +over 12 m}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s56\intbl\ql\ltrpar{\loch +year}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +58.50}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx2551\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx4252\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx7937\pard\plain \s56\intbl\ql\ltrpar{\loch\cs15\loch\b\loch +Total}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +60}\cell\pard\plain \s56\intbl\ql\ltrpar\loch +\cell\row\pard \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar\loch + +\par \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx5410\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clvmgf\cellx7937\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Merged across two columns}\cell\pard\plain \s56\intbl\ql\ltrpar{\loch +Merged down two rows}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3246\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5410\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clvmrg\cellx7937\pard\plain \s56\intbl\ql\ltrpar{\loch +a}\cell\pard\plain \s56\intbl\ql\ltrpar{\loch +b}\cell\pard\plain \s0\rtlch\af8\afs24\alang1081 \ltrch\lang1033\langfe2052\hich\af3\loch\widctlpar\hyphpar0\ltrpar\cf0\f3\fs22\lang1033\kerning1\dbch\af7\langfe2052\intbl\ql\ltrpar\loch +\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3246\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5410\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx7937\pard\plain \s56\intbl\ql\ltrpar{\loch +c}\cell\pard\plain \s56\intbl\ql\ltrpar{\loch +d}\cell\pard\plain \s56\intbl\ql\ltrpar{\loch +e}\cell\row\pard \pard\plain \s1\loch\ilvl0\outlinelevel0\cf20\fs34{\listtext\pard\plain 5. \tab}\ls1 \fi-510\li510\lin510\ql\fi0\li0\lin0\ltrpar{\loch +{\*\bkmkstart pictures}{\*\bkmkend pictures}Pictures and shapes} +\par \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +{\shp{\*\shpinst\shpwr2\shpwrk0\shpbypara\shpbyignore\shptop0\shpbottom2551\shpbxcolumn\shpbxignore\shpleft4877\shpright9412{\sp{\sn shapeType}{\sv 75}}{\sp{\sn wzDescription}{\sv A made-up picture of a lake and a hill at dusk.}}{\sp{\sn wzName}{\sv A lake at dusk}}{\sp{\sn pib}{\sv {\pict\picscalex47\picscaley47\piccropl0\piccropr0\piccropt0\piccropb0\picw640\pich360\picwgoal9600\pichgoal5400\pngblip +89504e470d0a1a0a0000000d49484452000002800000016808020000004850dd060000148f4944415478daeddd779c14e5fd07f059ee0e3894a220a0808a2860 +a1890541c11a8c420844c182250a1a1b20410dd198fc624c7b19132362076cb145e9688c880a89421429a241a4183da41d7a280a22dcef8f85f3e8bbc7eeecee +cdfbfdba3fb8636e76e6bbf33c1fbef3ec2eb1035b0e080080705551020010c00020800180f4c80f628a00003a600010c000800006804ac21a3000e88001202a +1db0161800c20f60f10b0099e88025300084ce1a300008600010c000409a580306804c04b0fc0580f0b9050d00021800a2213f88b9090d003a6000884207acff +05001d300044a303f63e6000d0010380000600d2c38bb0002013016c0d1800c2e71634000860008886fc987bd000107e00cb5f00089f5bd000208001201abc0f +18003211c0d68001207c6e41038000068068b0060c009908606bc000103eb7a00120131db006180074c000108d0e3888e9810140070c0051e880f5bf00a00306 +806874c0de070c00190860f90b00e1730b1a000430004443beb70103800e1800043000208001a0d2b0060c003a600010c000409af8242c00c84400fbff800140 +070c0091600d18000430004483f70103800e180004300090265e050d009908e040020340e8dc8206804c74c01a6000d0010340343a606bc00090810096bf0010 +3eb7a00140070c001109606bc000103eb7a00120131db006180074c00010910e580b0c003a60008844071c78193400841fc0e2170032d1014b6000d001034014 +781116000860008806ef0306001d300044a503560300d0010340243a60ef0306800c04b0fc0580f0b9050d003a60008848005b0306001d3000448235600010c0 +00100d3e0b1a0074c00010950e580d0040070c00d1e880b5c000a00306806874c06a00003a6000884607ac0506001d3000086000202dbc080b003211c0fe3f60 +00d001034024580306001d3000442480ad0103800e180022c11a3000e880012022016c0d180074c0001009d6800140070c001109606bc000a003068048b0060c +003a60008848005b0306001d30004482356000d00103404402d81a3000e880012012ac0103800e18002212c0d6800140070c0091600d180074c000109100b606 +0c003a600088046bc000a00306808804b0356000d001034024580306001d3000442480ad0103800e1800043000901e5e84050019e980b5c000a00306806874c0 +6a00003a6000884607ac0506001d300044a3030e4a55010074c00010850ed81a3000642080d5000042e7163400e88001202201ecff0306001d30004482356000 +d0010340540258020340e8dc8206808c74c06a00003a6000884607ac050680f0035809001231f2d17ebbdde6c7173fa45024dc01ab01c0ce8d482077b70fe9cb +24310218a0021e4e26777716db978b617611c03e0b1a60dbf47da45f6af6138fe14bc4303a60805d7a2845d1bb4d9cf793c16cc7db9000d298be657b4edfced1 +0103e4b007d31f900f3dd2afbf5698ef023856aa0a404e983c77d669addaa663cf0f8cea1f5acc5f71e9839e4a02b7a0815c89dec97367c5ff90bbe99b918723 +6bc5ae3877b02a0059ebe59d24eee9296a85efcf501c5ea90fd6012b0190b5d1fbf2cefbdd9753d10adfaf1925737c16349075fe3927a1708d67f019addbe6e2 +39de3faaff4f7eac09d6019335130a447c98c4bfc2195cf78dcc70fb9bf10320d31db01aa4df4b7366c5a789efe5e63fd5219c31b287ffc04d6a7cdd9b1de167 +068eb2d855bdbd082b8dfeb1a369a5ab18865d8e910a4b7c700dcf9aeef36a37a223db01fb07581a6796d9b37636e3746d2383314066a57e9f73660541b0dbf1 +357c4436ddfb35094736803df5e9f0e2ee6696f8d473a618c600494fb4e7d0e032094756ec9a3e6e41a7d80bc94c2edf97c1181d69b3b3f1356c4476bdfae9da +cbdc85cef0659991a9d8aba0333cbf843c1f55d679969cb824c2bf2a76f888d996bec66f365c96193965ef034e9949b366edc9b57e56dbb695ac0ebbbea02bcd +f992bea191c22cc9f2eb2de3f3f0f6cf51259b97123ce590cf3776ed796e4167cb145309aef5544db5e2d9b848b9b28beaee87b3b103beeef207b3f9a9a97c1d +42369ca91761a5c0c414cd329366cd3a3b67aff289299d6a773d48ce16cfe5ca9e9dd598984dd15bfea2cada8b2796ddcf4896572f851508731e8e0dd001ef81 +09e99965bae5d4553e21cba6da6e1188e7446a9e913a4cc8bedcdddec2b7efc9c2a31a98ea0e387dcf45ce0db10a94229c738c0d38ff7a395ac127f59dd9694c +91766d225e8188d430fc9aa7a92c597e31647f0c0fecf740ce3d05d93fc4f6a426219c5d6ca0004edef8b02ef4ee597c7d8fcf9d09b77bee277108d5ae7095c6 +e75af46667060f4a3e80b3a4f25938be525599749f5ac60278fc3bb373745a0cf9a2afc417b79922274abd8ba2e5ee959085319c48006773c1b36470a5bc4469 +3dafd8a04c04f0b87235fa414ecd89e3323100b2a744e32ac5849b13975ce528b50c4edcf53b0ae05cbc0c3235bed257abf49d51ecfa0bc20ee0b133775ca61e +4767f5b4b8b3c30e4d66eb93f1d3af7c258d4e9dc5702206f77fa0325d00610eae108a96a6d309358013295376c670968c8a4c15a7d2a742c6af3ab91bd90c6e +d6fe1a832b27064e3a4e243638ac001e9364a57e9835493c269b26c790cb322662c110fe553746f4462c862b7de28630b232356a523e3fc47e1a4a008fae68bd +7a6634864767ebe418425946473b18d25de1d1723732191ccdc44dd3b0caf8c049edcc10fbe985690fe0d16fa7a0643ddb879dc42939ec345e07692b48969f78 +4ed7596da310c312371dc32a7bc64e0ae784d8907406f0f3a92e59af5062f8f9dc9925535e90e725447a4aadb0953883256e5ac754168e9d544dbc690ce0b456 +2d7d499c731365aa4a2121d2516d55ad94312c71431853593e765232f1c66e484f003f174aed7e94ea187e2e37a7cb3dacc3734222d5355752a8f098ca95e1b3 +e70114bbb16fea03f8ef6f855dbe738e699373c79c0d45a804679d6d65575288ecf0af40000f4a69f4cec9682d5ae7e26167aa0295e6ac01722e77822088dd94 +ba007e366b26f47393a9c8b3952b871239f767452f40261267ab00fe598a02f899ec9bd37befae28cf54de1cdad9b93f237a01428f9b9d04f0452908e067fe93 +d5d37aef635be7dc31a7fcac2bfdf902645bd0ec268087ee59003f9d3bd37a9f2dd5793a4a51143feba7a52f405829936800ff7c0f02f829d33a006c715e3219 +5c45fa02404a24958c5562b1a0025fd217007698c1092669d21df09333e63c3943fa02c04e8332b10e380812ff12bd00904806ef36529308e0bf495f0048ccdf +7697c155825869225f4f485f0048c61333e6ec2258f363bbfbfdc7a7cf554400a848064f9fdbf7f8563bfcabdddc8296be00b0271e9f3e7727b7a077ee31e90b +007b6c8779bad3f7014b5f004861066f93b33b58037ee44dd10b0029f6e89b732fe9f0dd7a7015e90b00e1281fb25bbd084bfa0240ba3378cb07716cb9193d4a +fa0240fa8d7a736e2c16e4074130f20dd10b00e119f9c6dc58e30e7d150200425645090040000380000600043000086000400003800006000430000860004000 +0380000600010c0008600010c00080000600010c0008600010c00080000600010c0002180010c00020800100010c0002180010c00020800100010c0002180004 +30002080014000030002180004300020800140000300021800043000086000400003800006000430000860004000038000060004300008600010c00080000600 +010c0008600010c00080000600010c0008600010c00020800100010c0002180010c00020800100010c0002180010c00020800140000300021800043000208001 +40000300021800043000208001400003800006000430000860004000038000060004300008600040000380000600010c0008600010c00080000600010c000860 +0010c00080000600010c0008600010c00020800100010c0002180010c00020800100010c0002180010c00020800140000300699457ab716b554889f9e36f3ea2 +59c389afbf97dadd3e7dc7a5756a16ce7cff131506d001139eaa057955f3f312dc78f82de7ce1f7fb3a20164bf7c25c872bd063d5c5aaa0c00110ee0c2ea0557 +f739b15be723ebef5b7359f19ae75e9e7dff33ffdab8a9f4ce1b7a9ed9a965cf410fcf5fb222bee55f7fd6eba4f6cdba5ffbe027cb3f0f82e0c0fdf7197cf129 +c71cd164efbdaa7db474f5a8b133464f9e1304c1e8bb2eaf5b7baf2e3ffe6bf980b9eba65e1ddb36ed78d15f367cbb31ad8f1e04c1fcf1374f9efec1fc252bfa +9cd9aeb05ac12b3316fce2ee491ddb36bde1d2531bee57eb83252b7e35fc85790b9795dff889096f0de8dba5f941fb6dd8b071da3b8bef183579e9ca3549956b +9bcd76bbdbff8ebb79f2f40faefecdb39b775bade0aa3e2776eb7264bd3a7b15ad28193765ee8831d3d77ff36d7c5765fb0c82a045f7dbcbf65ff6ebf12ef9b4 +e39b97ffdb6933175d7fd1c923c74e1ffed4b4c48f1c803002b8203f6fe46d1736ae5f7bf8d3d3161715b76dd1e8277d4e6c58b7d6adf74cfaf57d2f1edffaa0 +ffbbe6acf36f1c555a1a746edfac6ba7c36fbd67523cfff6a955e3f1df5db4e6cb75bfbeff1f6bbf5adfb7fbb1bf1fd47de5675f4e9bb968ec2bef0eed777adb +968ddfd9b2c059ad6a7e97630e1df7eabbdba46f3a1e3dbee7a30eddbfb05ac14d7f1edfa1f5c1fd7a75a85e35ffb083eadff9e894ea55f36fb9a2eb7db7f63e +f9b2611b376e8a6fdca4419d3b6fec3972ccf4bf3cf66ab326f506f53df9e8231af718f050c9175f275eaeed6b9bf86eab16e48dbced8296873418f6e4d4791f +7edaae65e36bce3fa965d30603fff07c10047d868c1a72e9a9c71e75609f21a312bf020e6d52af4df303863d35f5d5190b923d7200d21ec097fce0d8d687eddf +63e0430b3e5a1904c11bb3976cd8b869c825a78e18f3e692a2d5bf1afec2b09f9fd3bb6bbbb1afccbdf527674e7d7be1d32fbe13ffc5b34e3aa241dd9ad7dcfe +f7b90b960641f0fee2e56f3c7efd1927b4983673d184d7e7dd78d96967766a5916c0271dddacb07ac1d829734378f4f806fbedbb77cf810f1797ac9dfaf6c2d3 +8f6f7e7a8716b7dc3df1c569ef0741706893fdfa9f73c25187ee3f7b7e517ce3e607d7bff49627de98bd247e0cab3e5f7bd74dbdae3cb7e31f474c4eea80b7d9 +3889ddf638aedde18daffecdb393a77f10df388805032fecd2664ca3d9f38b66cd2f5ab3765d1004b3b61c70229aecbfcf398347cefbf0d30a1c39001596e88b +b0ba75396ace824f177d529c975725fe3565c682582ce8d0eae02008fef9c6fc09afcf1b72c9a943fb9f517befea3fffebc4b25f9cf29f057d868c2a9bdf3f5b +f355bc310d8260d5675ffefb9d455d3b1d1e8b6ddeb86bc796452b4a66beff71088f1eb7e0a395c5256be37ffe78d9679b532d08822088f7d0fb96db7849d1ea +b2bf0d82e0a57ffd77e5ea2f4f3bae79b2e5da4612bbed7ce4d21525f1f48d7bea85997f1c31f9dbad6f182465c1472bcbea93ec910390f60eb869e3bad5abe6 +bf3766e8363fdfb7f6e67cbaedbe7f4c1c7ee579671e7dc39d6357acfea26c83a52b4a1ad4ad79fbc06e47356b586f9fbdab57cb0f82203f6f73f08f7df5dd3b +7edaa375f346b3e71715e4e79d72dc618f4df8cff6af394ad3a30741b0eaf3b5657f8edff72edef293f8b755aac4ca36f8dfb2cfca3ffaa6d2d2c54b57b76dd1 +a802e52a2f89dd36aabbcdfb9156977cf5f0e837937aca63b158f96f97ae2ca9f0910390f600ae128bbdfddec7bf7de89fdbfc7cd9aacd6957a3b06a61b58220 +081ad6ad557e831f9ddee6f601dd5e7bebc33f3ffe5abcc59c30ec8ab2bf7df98df95fadfbe6cc4e87cf9e5f74429b836bee556ddc9477437bf42008d6addf50 +f6e778ee7fbde527a5a5a5dbc455e9f6ff34282d2d5b214eea80b7de47c2bbad12cbcb4bfa9d63b1adbfad59a3da5679bff5abab923a7200d21ec09fae2ca951 +bdeabb0bbebb577970a37dbb77396adcabefaefa2c88c582df0ee8f6c9f2cfa7ce5c74dd059d274fff60e1c7abe29b5d7bfe490b3f5975d56dcf6c2a2d0d8260 +afc2aae577fbf5fa0d2ffd7b7ed74e2dff30e2e5ae9d5abebbe0d3c545c5a13d7ab21a37a8b3752b191c74c0be8b97267dc07bb0db354d1a6eb5719d9a85ed5a +367e6fd1b2e5c53b0ec88d9b4a6b6c7dd68734a95be1271a805449b49d7aedad0f5b366d7068937a653fb9b4c7f157f5ee145f55bde0ac633ab43ef897f74cba +fb89d796177ff1bb81ddf2b6dcb9ad53ab70f5e76b376d69f24e68d3749bb672ec94b98dead76edba2d169c737dffee557e97ef4a4346b52efb85607957dfbfd +138f6850b7e60b53df4ff6802bbcdb69ef2cdabf5ead2ec71c5af693fee774bcf717bdcb6eaac79be9fcfcef9ed615abbf687e50fd822d1fe571e2d187d4adbd +57859f68005225d18fa27c7fd1f26e5d8eec75469bb55fafafbd77e145dd8ebdf0ec63fefcd8abff9eb5f8c0fdf71936f49ce727cf79f28599df6edcb4a8a8f8 +8a733a7eb5ee9bf86a65abc30e38f1e843d67ff36d2c16ebdcbed995e776dcbbb05ac917eb9e7f79767ccf45cb4bcefd5edb7687376ed2709fa1774df8badc3d +e1743ffa7517745e5c545cf6e19167773ef290c675873d3935feede1873438bd438b4953df5bf449717ce365abd6fcf0d4569b3696d6a85e7076e72387f63b63 +de879ffe72f80bf1dbc557f73971e3c64dff9ab5b8e4cb75bb38e06d4e6db7bb2d7f90f397ace8714aabb34e3a62ddfa0d350aabf6eedaaedf8f3a3c3a6ec6a4 +2d69ddb16dd3239b35dcb061635e5e95a2e5254110d4a955a373fb662d9ad6ff7ccdd7ed8f6872cb155d8b5694d4abb357fc34b7a9c0ae4b6db400a450a2b7a0 +577dbeb6cf9051832f3e65f0c5a754ab9abff07fab6ef8d398f1afcdab128bfd7e50f7afd67f73c723af6ceed2662e9a34f5bd011776993c7dc1e2a2e25bee9e +f8c557eb2fefd9a1f0bc824545c57f1a356568ff33f6a95558b6e74da5a5e35f9bd7af5787d7df5e58f682e4d01e3d29f3162e7b73ce477dcf6e7f40fdda2b57 +7ff9c8b819c39f9a16ff108c200846bf32e70727b7baf717bdcfbefafe9d1d7005765bdeb2556b7a0f1939f8e253aebba0734141de474b57fffade17cbde7315 +04c123e366b46dd1e8daf33baffb6643fb3e77044170f713af956e2aed797aeb7b6e3977d127c5b73ff0528f535bb538b87eb24fb4a102905ab1c61dfaaa4222 +b6ff48a96cde2d0059ce7fc600000218000430009026d6800140070c0002180010c00020800100010c0002180010c000907dfe1fe340a7ab4939395c00000000 +49454e44ae426082}}}}} +The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell. Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master. Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and its draught, and to say how many nights they plan to stay. The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell. } +\par \pard\plain \s36\loch\sb57\sa113\cf0\fs18\i\ql\sb57\sa113\ltrpar{\loch +Figure }{\loch +{\field{\*\fldinst SEQ Figure \\* ARABIC }{\fldrslt 1}}}{\loch +: a picture that text flows around} +\par \pard\plain \s47\loch\qc\qc\ltrpar{ +{\pict{\*\picprop{\sp{\sn wzDescription}{\sv }}{\sp{\sn wzName}{\sv The second picture}}}\picscalex47\picscaley47\piccropl0\piccropr0\piccropt0\piccropb0\picw480\pich270\picwgoal7200\pichgoal4050\pngblip +89504e470d0a1a0a0000000d49484452000001e00000010e0802000000def853300000110d4944415478daeddd7b5cd465bec0f167b829984280a0022aa04412 +202a66c542d2b65aa8541aa8a05869a999966bdb69f39c7d6de65e6dddce9abe4a05cc3aa9a5915aea9ab71557f1c6c5d4b888a072511083bca40873fef8d5ec +701b66602ecf0c9ff78b3f0406fcfd9e99f9f0f5616654f50f9e2f0000f2b1630900804003000ce020542c020030410300083400583bb6380040da4053680090 +33d0e41900649da029340048895f12020081060018822d0e009035d0f41900e4c4160700c83a410b1533340030410300f49fa0999f0140d240f3280e0090135b +1c0020eb04cd000d0092069a2d0e0090135b1c0020eb04cd000d0092069a27aa00809cd8e20000592768e667009034d03c8a0300e4c4160700c83a4133400380 +a481668b0300e4c4160700c83a4133400380a481e6892a002027b6380040d6099af91900240d348fe200002668008001d8830600692768466800608206001068 +00b07a6c710000133400c0b0099a3500003903cd6b710000133400c000ec410380b41334233400304103000c99a05903009033d0bcdc2800c8892d0e00907582 +668006004903cd160700304103000cc01e3400483b4133420380a481660d00404a6c710080b41334233400304103000c99a079203400c81968fa0c00d24ed000 +0019b1070d00d24ed08cd000c0040d00306482660d0040ce40f3280e0060820600106800b08140b3c501004cd0000003f0303b00907682668406004903cd1a00 +8094d8e2000069276846680060820600183241b306002069a029340048892d0e00907682660d0040ce40f35a1c00c0040d0020d000600381668b030098a00100 +041a006c20d06c710000133400804003800d049a2d0e00608206001880174b02006927684668009034d0ac010048892d0e00907682668406004903cd1a000081 +06001810689ea802004cd00000020d00361068b6380080091a0040a001c00602cd16070030410300083400d842a0293400488957b30300692768d60000240d34 +85060029b1c50100d24ed0ac0100481a680a0d004cd0000003022d849a5500002927684668009034d0ac0100106800800181e6e5460180091a0040a001c01602 +4da101404abc160700483b41b306002069a029340030410330a7d48f66eabec0f3d3d7b04a52079a2500ba609a9b5d8c52cb3a413342033664edba991df82aa5 +d42fa49069d902cd1a0036614d87d2dc2cee3369348106605cab3b5d674de567d1688902ade2f5a001ebf661fa2ce3b6fec519ab595519f04415803a9be37ba2 +23815609c19b19def69cca61117833fa9be94afa61fa2c96d7e26f3c8ac31c76e7e50821be39952384783c6c280b02abf041faacd9cfb1d76149fc92d0e4fe99 +97d332d6bf22d3265bedaeb3b6abd24cbe11411f2c4b35276121ab6022bb9aa6b9a53164da64abdd15d676659a39768ae732445b7082e647a4a97a919ba37f53 +c684536a232ff5aebc1cdb5ed595a9e6fa3d1e89b0e0043d379109daf876ea51e796c69269132cb5adaeeafba9e67ba0c5cbcf33445b6882e6a7a3d1ede8509d +35ad79824c1b759d77e6e6b0a4ccd0561b68d6de78becec9e9fc3751baf3e4509a62b475b6bd25fdc75ab33e4e994a30415bbdaf8c51e7660d8ab36853becac9 +89932c6a9d59e4afe53b1d2668b4b3f2af4c660f5aba3a3763b6ace8380b19d266ac45b6814cffef5a733fd36ffe0babcd762df373546b82e6b5383a6d7b76ae +19c2342e22dc82476eba6330ff0a5bf65cac75946b1a0a93dee6353f89b98eac6f8b635b76ae1062bc1cd7dc3613a7b9d5bb4467cebd93076c9463906485b767 +e78ea7d1fa07da6a6ff3d6bdecaf4e79cd8a0e776bd31bc7048b5e6d5bcd784b6d49cf7337f5419aee2a30dbf24eb0f49d7f6b76aea1c7b07ccd8b663ec8c0e1 +2fcb5080095dacd4aad7a6ca1ee82f4fb67f478d1f162ee1519941cb13b7d48119f72a30f35998fff6d3d639ea79247f5b6dbe404b926619ae320b047aa1dc81 +ce30e48efa94b9aeb30c39ea2ca1ce5f05165c5b33dc7ef43f3bdd07f3aee9032d67972d78af27d0c6b98b9af40a23cda6bb1624595b53dc7e3a736aad1e8f49 +036d2d69ee0aa556fd3a49ae407f71c268f7d2a787874b7b6c5d873ed7826c0b6bac5b8e71cfabd9512dfbd0c88db6d22e9be18e6fc9402f9220d05b4c79ff7c +c648d7d616ea6c9a2b42da85edd82dc70ca7a31cd85f8d17689b49b389eefb960cf4eb960ef466b3dc3f2776eeaada4c9d4d7045c8bfaafadf6c2c722ee74ebc +4f97cd70f7b764a07f936c81407f7edc32f7cc4923c2ade550210f1d371b496e1e8696baeba4b9f305b070a0df487ed5cc7fe567c7f32c7edacf8e08b3964385 +0cb46f3032df2a7497bacb76b9f329b058a0ffcb2c81de24e56d3a41e775b3893ac36a352b3569365616cc1ee869260ff4a66352972e2132ccea8e1980651361 +a640bf69b2406fb4b6c625468659e36103b0482bcc11e8df9a20d01b681c80ae64b269926dccff51e5d3a374194057a4994aa78c3466a98df372a3ff479a01e0 +e73975aa9132dda9407f429701a0ed9935a973a5eee0ffa8f271d629ae0300d0738a4d7e30b42381a6cb00606a9a781a546a7db738d69366003052a9a7e997e9 +761ec5f1d111ba0c0046a61979a78f0aedc804bd8e340380896986e094d64add3cd0e9741900cc4e3313cfd02af57fb638d20e936600b030654a7eeea1502184 +ca7754322b020012b26309008040030008340010680000810600020d0020d00000020d00041a0040a001804003000834008040030081060010680020d0000002 +0d00041a0040a00100041a000834008040030081060010680000810600020d0020d00040a00100041a000834008040030008340010680000810600020d0020d0 +0000020d00041a0040a001804003000834001068000081060010680020d00000020d00041a0040a00100041a000834008040030081060010680000810600020d +0020d00040a00100041a000834008040030008340010680000810600020d0020d00000020d00041a0040a0018040030008340010680000810600e8c1c1a04b07 +fb7bcf9d1c151ed4cfc3ad47f995da8cbda73edcfcefbb771b253cb1958b9f7decc1a0fbc62f35f40b372e9bb1fb70fe9acd87b97100b09a093a66c4a02dcb9f +77b0b7fbfdaa9db3dfdeb4ef58e1bca9d14be6c5d9d88a3839da3b39d8ebff63207fdb5bdc8c005878827e7d466c69c5b5794b3f6f54ab851099d9c53fdebe3b +3be1918d3b4ee6e497d9cc8a3cf3ea5ab59a1b0600ab0a74ffbef7e61694376ad5eb8bbd79c1fedede1e3d95779dbb3bce4d8c1a171de2e5deb3f26adde66f72 +3fd874a8a1f1a7cb3b77739c9318352e26c4d3ad47d995daadfb4ea56664ddbe7357f96c77278799131f8a8b0ef1f576bb537ff76cf1e5755b8fee3e9caf7c36 +7fdb5b7bb20a3edf9dfb726254a09fe7b5ba9b197b4fadd870b0a1a151fbaf8e8b0ef174eb71e9f2f79fee38d9d65928dfea93edc7e727c7040de85d5fdf9099 +7d7e59fa9ef2aa3ae502df6d7d6b4f56c1dc773e6bf7b035b3b3f207653b45f9fe9a2f174d375b94cf669e2c7e6ddaa3695f66addc90d9eeba0120d0ed2bba50 +352cd8f7a1f08187734b948f9494d5bcf4f646e5cf8e0ef6694b927cbd5c576ecc3c5f7675e87d3eb313a3fa78f4fa9ff7bf56f60dd2964c0d0ef05ef1e9c1d3 +451511c1be2f4ff945b0bff7823f6f11423838d8a52e991a1ee49396917530fb5c8fee4e09632356fc76d25fd3f6acd97244f9fefd7abb2e99f7e47b1f1fb858 +796dc2e8d0b993a38410ef7d724089fbfa3f24070df05ab53133afa0fcfe00ef054931376edd6eeb44fcbcddfef69ba7d332b2febe7e7fa09fe7abc98f0e1be2 +1b3f7f4ded0fb75a6e77e838ecc445e98b66c4463ed03f7151bafecb38c8cf333ca8df8a0d07f71f2d6c77dd001068bd2c5dbd7bedefa7a4bf9374ecdb0b074e +141d387eaea0e48ae6b3291322c306f78d5fb0a6b0b44a087138b7a4bea171514a6c6ac69192b29a94f89111f7fbce7de7b33d5905ca67854a2c488a09cff0c9 +cd2f9b3e3e72f810bf251fecfa78fb71e5bbed3b5698faf6d48529b1bb0fe797565c1342dc1fe03d67c9a6bd470b85105979a5a3c2063e151baa043a257e64e8 +e07e0bfebc6567e65921c4a19cf3df1655ae5b9ad4d689040df49ab1f813e5c7cce1dc92eaef6fbcf7c6332f3dfbf05f52f734bba4eec3cec92fabbbf1a310c2 +a01d1ebfbef74e5a9876baa8429f75e3060a746506fc92f0c4998b6366af4acbc8eae3d96b514aecb67fccfaecdde74207f7533e3b2ee681bcc28ae24b57eded +ed94b77d470b552a312a74a010625c7448f9955a25738a0d3b4efe2575cfddbb0d4288098f86d6d4ded4de9750abc5aa8d99f676aa71310f281fa9a9bdb9ef58 +a1f2e746b5bae84295b7672fe5dd27a2865456d7ed3a7456f3e547f24a8a2f5d6deb444aca6a34ff081042fcf3d0775535d71f1b19d4f292ba0fbb630a4bab34 +756e77dd003041ebebf2d51ffeb4f69b3fadfd6670ffdef1a343a74f885cffc7e489afa59ebb58edefebd1ddc9e14cc69bcdbec4ddd54508e1efe371f2ec25ed +8fd7d4de5cfbc54fdb1701be1e7905e59a0d65457e69951022c0cfe3a70d968bd5dabfbbabbfdb606fa752fe3cd0c73d2fbfbcd96ff62e565e0bf0f568f52c2e +545ed37eb751ad3e5f5e33f43e9f9697d47dd87a52a954daef9657d536f92b74ae1b00026df81878a16ad9babdf92597972d7a6af2d888a5ab77dba95427ce5c +fcc39addcd2e5959fd8310c2ce4e656f6fd89362d46ab5324a2baedf6c734fd9c1deae69038510a29b9383eeefdcec43cd7e3cfcf4ef0bc30f5b08d1ec587aba +746bf2f3a0e96fff74af1b00b638da97383662cbf2e74302fb687f50d911eeedde5308515155ebd2dde9dbc20acddbf59bb747470eeee1ec2484a8a8aaf3ebe3 +a6fdb56e3d9d47470e561e01527ce96aa09fa78343938309f6f716429cff79a742ddf663dfcaabea06fab8376b74ff3ef7b675795f6fb7a613ae18d0cffd7c79 +2b5b22ba0fbb550d8d6a176727ed8f68fe11d02addeb068040b7afa0b42a6450dff94931dac3aad26be55785078e1705fb7b0ff2f3d47c7646fc8373121eb956 +77530891995ddcd7b357cc88419acfce9af4f0aaff4e70b0b713426cdd7fcaddd565f2d861dad19c93f04843a37afb816fdb3db67d470bbcdc7bc66a6d228f8e +1cdccfcbb5adcb07fa798e0c1da079f789a821de1e3d771c3cdbf292ba0f5bf36343fb47cb959a1f82067839fefc5497a861011eae3d741cbcee7503d095d9f7 +f20dd3e77295d5759ef7de333e262432a4ffedfabbee6e2e631e0e5efcd298caeabac52bbeba53df70b6f8f2b89890671e0fbf71ebb6eb3dced3c64526c58d58 +be7effbf73ce0b21f24baec48f0e7df217437ebc5defe2ec94302662e6c4511f6d3dfaf5c1b34288534515a3c2064e7a7ca87377c746b53aa8bfd71b2ffc327a +78e0bbe97bf71d2b1242bc3235fa7cd9d5affe7546733c71d12101be1e2b3e3d28843873ae727ccc034f44dd7ffdd69d7b9cbbc5450ff9754aecc5ca6b1e6e3d +940b687b656a746575dd53b1a18d0d6a97ee8e71d1216fce7cfc7451c5ef56ee507639b4ff2edd872d847878a87f48609ffafa067b7bbbb2cbb54208b75e2ed1 +c303eff3f7fabeeed6f0217e8b5f1c5376a5d6f3e723697922bad70d4057a6f21d95acffa51f7b3068fa84c8405fcf7b7a74bb74f9fb5d87be4bfde2c88d5b77 +94cff6f5ecb570fae8e81181dd9c1cce5da84eff326bdb81d39aaff5ebe3b670fae8a888004747fbd2f29a0d3b4e6edc99ad79da8b7337c799131f8a8b1ee2eb +eda6e43efdcbac664f5469ebd91f42081f2fd7d79f7bec91087f077bbb82d2aabfafdf3f6d7c64abafc5a17cab2379a5c971c3fb79b956d55cdffeafd32b3764 +deba5ddfeadfa5fbb0070fe8bdfcf5a7fd7d3c7ebc533f3c719910c2de4e356f4af4d3bf0c73ebe95c7ce9ea079b0ec5c786367ba28af689b4bb6e00087457d1 +6a22014042bcdc2800106800008106001bd0e5f6a00180091a0040a0018040030008340010680000810600106800b07aff0fc1e0db14f91389b5000000004945 +4e44ae426082} +} +\par \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +{\field{\*\fldinst SHAPE }{\fldrslt{\shp{\*\shpinst\shpleft1838\shptop113\shpright3830\shpbottom394\shpwr3\shpbxignore\shpbyignore\shpz2{\sp{\sn shapeType}{\sv 2}}{\sp{\sn posrelh}{\sv 3}}{\sp{\sn pVerticies}{\sv 8;4;(3600,0);(0,3600);(0,18000);(3600,21600)}}{\sp{\sn pSegmentInfo}{\sv 2;10;16384;42753;1;43009;1;42753;1;43009;24577;32768}}{\sp{\sn lineJoinStyle}{\sv 2}}{\sp{\sn lineColor}{\sv 10773812}}{\sp{\sn lineBackColor}{\sv 6003403}}{\sp{\sn geoTop}{\sv 0}}{\sp{\sn geoRight}{\sv 21600}}{\sp{\sn geoLeft}{\sv 0}}{\sp{\sn geoBottom}{\sv 21600}}{\sp{\sn fshadowObscured}{\sv 1}}{\sp{\sn fillColor}{\sv 13606770}}{\sp{\sn fillBackColor}{\sv 3170445}}{\sp{\sn fNoFillHitTest}{\sv 1}}{\sp{\sn dyTextTop}{\sv 0}}{\sp{\sn dyTextBottom}{\sv 0}}{\sp{\sn dxTextRight}{\sv 0}}{\sp{\sn dxTextLeft}{\sv 0}}{\sp{\sn anchorText}{\sv 0}}{\sp{\sn adjustValue}{\sv 3600}}{\sp{\sn wzDescription}{\sv }}{\sp{\sn wzName}{\sv Callout}}{\sp{\sn gtextUNICODE}{\sv A shape with text in it}}{\sp{\sn gtextFont}{\sv Liberation Serif}}{\sp{\sn gtextSize}{\sv 720896}}{\sp{\sn rotation}{\sv 0}}}}}}A line of text beside a shape.} +\par \pard\plain \s1\loch\ilvl0\outlinelevel0\cf20\fs34{\listtext\pard\plain 6. \tab}\ls1 \fi-510\li510\lin510\ql\fi0\li0\lin0\ltrpar{\loch +{\*\bkmkstart links}{\*\bkmkend links}Links, fields and notes} +\par \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +An external link: }{{\field{\*\fldinst HYPERLINK "https://exav.org/" }{\fldrslt {\loch\loch\cf18\ul\ulc0\loch +exav.org}{}}}\loch +. An internal link: }{{\field{\*\fldinst HYPERLINK "#intro" }{\fldrslt {\loch\loch\cf18\ul\ulc0\loch +back to the introduction}{}}}\loch +. A reference to a bookmark: }{\loch +{\field{\*\fldinst REF tables \\h }{\fldrslt }}}{\loch +.} +\par \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +Fields: this document is titled \u8220\'93}{\loch +{\field{\*\fldinst TITLE }{\fldrslt Harbour handbook}}}{\loch +\u8221\'94, and this line is on page }{\loch +{\field{\*\fldinst PAGE }{\fldrslt 4}}}{\loch +. A fixed date: }{\loch +{\field\fldlock{\*\fldinst DATE \\@"dd/MM/yyyy" }{\fldrslt 01/01/2000}}}{\loch +.} +\par \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +A comment is attached to this word}{ +{\*\atnid }{\*\atnauthor exav}\chatn{\*\annotation{\*\atndate -968816640}\loch\ltrpar\loch{\rtlch\af10\alang1023 \ltrch\lang1023\langfe1023\loch\lang1023\kerning0\fs24\dbch\langfe1023\af10\loch +A comment: the viewer shows or ignores it.}}}{\loch +. A change that was tracked: }{\revised\revauth1\revdttm-968816640 \loch +this text was inserted}{\loch +, }{\deleted\revauthdel1\revdttmdel-968816640 \loch +some deleted words }{\loch +and some text was deleted before this one.} +\par \ql\fi-283\li283\lin283\ltrpar\pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +Citations [1] [2]. }{{\super \loch \chftn{\*\footnote \chftn\pard\plain \s51\loch\fi-283\li283\lin283\fs18{\loch +\tab A second footnote, with }{\loch\cs16\loch\i\loch +emphasis}{\loch +.} +}} +} +\par \pard\plain \s41\ql\ltrpar{\loch +{\*\bkmkstart wide}{\*\bkmkend wide}Wide table (landscape)} +\par \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx12727\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat20\cellx13890\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Month}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 1}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 2}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 3}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 4}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 5}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 6}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 7}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 8}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 9}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Berth 10}\cell\pard\plain \s57\loch\qc\cf8\b\intbl\qc\ltrpar{\loch +Total}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Jan}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +46}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Feb}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +53}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Mar}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +51}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Apr}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +49}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +May}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +47}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Jun}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +54}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Jul}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +52}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Aug}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +50}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Sep}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +48}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Oct}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +46}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Nov}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +53}\cell\row\pard \trowd\trql\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx1157\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx2314\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx3471\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx4628\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx5785\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx6942\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx8099\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx9256\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx10413\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx11570\clbrdrt\brdrs\brdrw10\brdrcf0\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\cellx12727\clbrdrt\brdrs\brdrw30\brdrcf1\clpadfl3\clpadl57\clbrdrl\brdrs\brdrw10\brdrcf0\clpadft3\clpadt57\clbrdrb\brdrs\brdrw10\brdrcf0\clpadfb3\clpadb57\clbrdrr\brdrs\brdrw10\brdrcf0\clpadfr3\clpadr57\clcbpat22\cellx13890\pard\plain \s56\intbl\ql\ltrpar{\loch +Dec}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +2}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +7}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +3}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +8}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +4}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +9}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +5}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +1}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +6}\cell\pard\plain \s58\loch\qr\intbl\qr\ltrpar{\loch +51}\cell\row\pard \pard\plain \s34\loch\sl300\slmult1\sb0\sa125\sl300\slmult1\ql\sb0\sa125\ltrpar{\loch +Nights let at each berth, by month.} +\par } \ No newline at end of file diff --git a/crates/exav-viewer/demo/examples/files/converted/workbook.fods b/crates/exav-viewer/demo/examples/files/converted/workbook.fods new file mode 100644 index 00000000..51e83c59 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/converted/workbook.fods @@ -0,0 +1,62 @@ + + +exav projectHarbour workbookA workbook made for the exav demo (CC0-1.0)2000-01-01T00:00:00 + +view1 + +02 +33 + +02 +11 + + + + +€ +€ +% +// + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Choose one of the five categories. +Harbour budget, first halfAmounts in euros; the totals are formulas.CategoryJanFebMarAprMayJunTotalShareBerths let112001180012100154002180024900972000.572775486152033Visitors31003300480082001560019200542000.319387153800825Shop120011001500260041005200157000.09251620506776664Fuel9008001100190033004400124000.07307012374779022Repairs-2100-900-3300-1500-800-1200-9800-0.05774896876841485Net1430016100162002660044000525001697001Averages and checksAverage a month28283.333333333332Best monthJunMonths in credit6Largest item24900Rounded share of fuel0.073StatussurplusLookup: categoryShopits total15700Linkexav.org/viewerNote on a cellexavThis cell has a comment.hover me +DateRegionProductUnitsUnit priceAmount01/01/2000WestMooring3215.74503.6802/01/2000EastPower448.47193.8803/01/2000NorthFuel347.46253.6404/01/2000SouthShop2327.12623.7606/01/2000WestWater2511.26281.507/01/2000EastMooring1944.61847.5908/01/2000NorthPower224.6949.3809/01/2000SouthFuel531.97159.8511/01/2000WestShop1736.03612.5112/01/2000EastWater1744.79761.4313/01/2000NorthMooring3018.48554.414/01/2000SouthPower3839.621505.5616/01/2000WestFuel2618.98493.4817/01/2000EastShop282.0958.5218/01/2000NorthWater319.3157.9319/01/2000SouthMooring3820.21767.9821/01/2000WestPower235.9471.8822/01/2000EastFuel3529.341026.923/01/2000NorthShop83.7630.0824/01/2000SouthWater3143.01333.026/01/2000WestMooring217.27152.6727/01/2000EastPower1540.31604.6528/01/2000NorthFuel2114.02294.4201/02/2000SouthShop3738.551426.3503/02/2000WestWater1045.02450.204/02/2000EastMooring1243.89526.6805/02/2000NorthPower2650.131303.3806/02/2000SouthFuel940.14361.2608/02/2000WestShop149.5649.5609/02/2000EastWater2726.86725.2210/02/2000NorthMooring3852.952012.111/02/2000SouthPower307.93237.913/02/2000WestFuel2424.85596.414/02/2000EastShop3429.821013.8815/02/2000NorthWater1642.51680.1616/02/2000SouthMooring618.93113.5818/02/2000WestPower2517.97449.2519/02/2000EastFuel3434.581175.7220/02/2000NorthShop1637.55600.821/02/2000SouthWater1613.41214.5623/02/2000WestMooring3245.321450.2424/02/2000EastPower845.63365.0425/02/2000NorthFuel1355.42720.4626/02/2000SouthShop2313.93320.3928/02/2000WestWater1011.8118.001/03/2000EastMooring3457.931969.6202/03/2000NorthPower3837.21413.603/03/2000SouthFuel2558.291457.2505/03/2000WestShop2447.821147.6806/03/2000EastWater315.14159.3407/03/2000NorthMooring216.92145.3208/03/2000SouthPower1513.22198.310/03/2000WestFuel2851.81450.411/03/2000EastShop99.3584.1512/03/2000NorthWater330.5991.7713/03/2000SouthMooring1251.27615.2415/03/2000WestPower2343.07990.6116/03/2000EastFuel3814.39546.8217/03/2000NorthShop3042.951288.518/03/2000SouthWater225.01110.2220/03/2000WestMooring1223.36280.3221/03/2000EastPower2736.2977.422/03/2000NorthFuel440.81163.2423/03/2000SouthShop2732.34873.1825/03/2000WestWater1213.49161.8826/03/2000EastMooring2230.72675.8427/03/2000NorthPower2529.88747.028/03/2000SouthFuel533.1165.502/04/2000WestShop2924.83720.0703/04/2000EastWater313.1497.3404/04/2000NorthMooring2513.9347.505/04/2000SouthPower2144.99944.7907/04/2000WestFuel2724.07649.8908/04/2000EastShop1654.75876.009/04/2000NorthWater422.2388.9210/04/2000SouthMooring1622.19355.0412/04/2000WestPower207.41148.213/04/2000EastFuel1933.71640.4914/04/2000NorthShop334.65103.9515/04/2000SouthWater3645.151625.417/04/2000WestMooring150.8450.8418/04/2000EastPower3745.151670.5519/04/2000NorthFuel649.57297.4220/04/2000SouthShop3216.72535.0422/04/2000WestWater2221.88481.3623/04/2000EastMooring1917.18326.4224/04/2000NorthPower2020.44408.825/04/2000SouthFuel2337.88871.2427/04/2000WestShop78.0656.4228/04/2000EastWater2327.66636.1801/05/2000NorthMooring3742.991590.6302/05/2000SouthPower167.11113.7604/05/2000WestFuel531.73158.6505/05/2000EastShop725.47178.2906/05/2000NorthWater3521.31745.8507/05/2000SouthMooring2613.44349.4409/05/2000WestPower3416.15549.110/05/2000EastFuel3710.53389.6111/05/2000NorthShop621.7130.212/05/2000SouthWater1115.06165.6614/05/2000WestMooring2556.341408.515/05/2000EastPower219.31195.5116/05/2000NorthFuel3140.751263.2517/05/2000SouthShop1753.31906.2719/05/2000WestWater3710.33382.2120/05/2000EastMooring3933.161293.2421/05/2000NorthPower45.0820.3222/05/2000SouthFuel3536.121264.224/05/2000WestShop246.5393.0625/05/2000EastWater756.38394.6626/05/2000NorthMooring122.5230.2427/05/2000SouthPower215.73120.3301/06/2000WestFuel551.14255.702/06/2000EastShop1715.73267.4103/06/2000NorthWater525.51127.5504/06/2000SouthMooring2717.38469.2606/06/2000WestPower3919.88775.3207/06/2000EastFuel2823.57659.9608/06/2000NorthShop1830.72552.9609/06/2000SouthWater3047.471424.1Total247970070.1 + +Spending by category + +Net income by month + +Share of the total + + + + + diff --git a/crates/exav-viewer/demo/examples/files/converted/workbook.ods b/crates/exav-viewer/demo/examples/files/converted/workbook.ods new file mode 100644 index 00000000..7633e613 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/converted/workbook.ods differ diff --git a/crates/exav-viewer/demo/examples/files/converted/workbook.xls b/crates/exav-viewer/demo/examples/files/converted/workbook.xls new file mode 100644 index 00000000..d6792cf7 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/converted/workbook.xls differ diff --git a/crates/exav-viewer/demo/examples/files/ifc/house.ifc b/crates/exav-viewer/demo/examples/files/ifc/house.ifc new file mode 100644 index 00000000..4d68171b --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/ifc/house.ifc @@ -0,0 +1,1438 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); +FILE_NAME('house.ifc','2000-01-01T00:00:00',('exav'),('exav'),'exav make-ifc.py','exav make-ifc.py',''); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCCARTESIANPOINT((0.0,0.0,0.0)); +#2=IFCAXIS2PLACEMENT3D(#1,$,$); +#3=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#2,$); +#4=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#3,$,.MODEL_VIEW.,$); +#5=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#6=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#7=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#8=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#9=IFCUNITASSIGNMENT((#5,#6,#7,#8)); +#10=IFCPROJECT('0iiDjLHJDU6JvG2gAu0Vub',$,'Example house',$,$,$,$,(#3),#9); +#11=IFCAXIS2PLACEMENT3D(#1,$,$); +#12=IFCLOCALPLACEMENT($,#11); +#13=IFCSITE('2KMASZqYy2AlEWEkUMXo6G',$,'Site',$,$,#12,$,$,.ELEMENT.,$,$,$,$,$); +#14=IFCAXIS2PLACEMENT3D(#1,$,$); +#15=IFCLOCALPLACEMENT(#12,#14); +#16=IFCBUILDING('2FwUvlYrJqXdTAReyC5SSO',$,'House',$,$,#15,$,$,.ELEMENT.,$,$,$); +#17=IFCAXIS2PLACEMENT3D(#1,$,$); +#18=IFCLOCALPLACEMENT(#15,#17); +#19=IFCBUILDINGSTOREY('09NtmAGcpl5Gr$RW07XYjX',$,'Ground floor',$,$,#18,$,$,.ELEMENT.,0.0); +#20=IFCCARTESIANPOINT((0.0,0.0,3.0)); +#21=IFCAXIS2PLACEMENT3D(#20,$,$); +#22=IFCLOCALPLACEMENT(#15,#21); +#23=IFCBUILDINGSTOREY('1galn8M2KF8wHmG39RqQr2',$,'First floor',$,$,#22,$,$,.ELEMENT.,3.0); +#24=IFCRELAGGREGATES('2sYclOCbti5hhc7FQxED0l',$,$,$,#10,(#13)); +#25=IFCRELAGGREGATES('2_Pl7nDxC1Hp2Ec1PpnkOB',$,$,$,#13,(#16)); +#26=IFCRELAGGREGATES('3rIlw0cwcrgHJsm1Z0AedM',$,$,$,#16,(#19,#23)); +#27=IFCMATERIAL('Brick, rendered'); +#28=IFCDIRECTION((0.0,0.0,1.0)); +#29=IFCDIRECTION((1.0,0.0,0.0)); +#30=IFCAXIS2PLACEMENT3D(#1,#28,#29); +#31=IFCLOCALPLACEMENT(#18,#30); +#32=IFCCARTESIANPOINT((5.0,0.0)); +#33=IFCAXIS2PLACEMENT2D(#32,$); +#34=IFCRECTANGLEPROFILEDEF(.AREA.,$,#33,10.0,0.3); +#35=IFCAXIS2PLACEMENT3D(#1,$,$); +#36=IFCDIRECTION((0.0,0.0,1.0)); +#37=IFCEXTRUDEDAREASOLID(#34,#35,#36,2.8); +#38=IFCCOLOURRGB($,0.93,0.9,0.82); +#39=IFCSURFACESTYLERENDERING(#38,0.0,$,$,$,$,$,$,.FLAT.); +#40=IFCSURFACESTYLE('Render',.BOTH.,(#39)); +#41=IFCSTYLEDITEM(#37,(#40),$); +#42=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#37)); +#43=IFCPRODUCTDEFINITIONSHAPE($,$,(#42)); +#44=IFCWALL('1jv90fDbLSS0zj$PR7F36D',$,'G-South',$,$,#31,#43,$,.STANDARD.); +#45=IFCCARTESIANPOINT((1.6,0.0,0.9)); +#46=IFCAXIS2PLACEMENT3D(#45,$,$); +#47=IFCLOCALPLACEMENT(#31,#46); +#48=IFCCARTESIANPOINT((0.0,0.0)); +#49=IFCAXIS2PLACEMENT2D(#48,$); +#50=IFCRECTANGLEPROFILEDEF(.AREA.,$,#49,1.2,0.5); +#51=IFCAXIS2PLACEMENT3D(#1,$,$); +#52=IFCDIRECTION((0.0,0.0,1.0)); +#53=IFCEXTRUDEDAREASOLID(#50,#51,#52,1.3); +#54=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#53)); +#55=IFCPRODUCTDEFINITIONSHAPE($,$,(#54)); +#56=IFCOPENINGELEMENT('2tx3XkEeRvnV9Jw3rqVVqa',$,'Opening G-South 1',$,$,#47,#55,$,.OPENING.); +#57=IFCRELVOIDSELEMENT('3EXGoG4S8oR6WagG$aChFm',$,$,$,#44,#56); +#58=IFCAXIS2PLACEMENT3D(#45,$,$); +#59=IFCLOCALPLACEMENT(#31,#58); +#60=IFCAXIS2PLACEMENT2D(#48,$); +#61=IFCRECTANGLEPROFILEDEF(.AREA.,$,#60,1.2,0.1); +#62=IFCAXIS2PLACEMENT3D(#1,$,$); +#63=IFCDIRECTION((0.0,0.0,1.0)); +#64=IFCEXTRUDEDAREASOLID(#61,#62,#63,1.3); +#65=IFCCOLOURRGB($,0.95,0.95,0.95); +#66=IFCSURFACESTYLERENDERING(#65,0.0,$,$,$,$,$,$,.FLAT.); +#67=IFCSURFACESTYLE('Frame',.BOTH.,(#66)); +#68=IFCSTYLEDITEM(#64,(#67),$); +#69=IFCAXIS2PLACEMENT2D(#48,$); +#70=IFCRECTANGLEPROFILEDEF(.AREA.,$,#69,1.08,0.04); +#71=IFCCARTESIANPOINT((0.0,0.0,0.06)); +#72=IFCAXIS2PLACEMENT3D(#71,$,$); +#73=IFCDIRECTION((0.0,0.0,1.0)); +#74=IFCEXTRUDEDAREASOLID(#70,#72,#73,1.1800000000000002); +#75=IFCCOLOURRGB($,0.55,0.78,0.92); +#76=IFCSURFACESTYLERENDERING(#75,0.55,$,$,$,$,$,$,.FLAT.); +#77=IFCSURFACESTYLE('Glass',.BOTH.,(#76)); +#78=IFCSTYLEDITEM(#74,(#77),$); +#79=IFCAXIS2PLACEMENT2D(#48,$); +#80=IFCRECTANGLEPROFILEDEF(.AREA.,$,#79,0.05,0.12); +#81=IFCAXIS2PLACEMENT3D(#1,$,$); +#82=IFCDIRECTION((0.0,0.0,1.0)); +#83=IFCEXTRUDEDAREASOLID(#80,#81,#82,1.3); +#84=IFCSTYLEDITEM(#83,(#67),$); +#85=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#64,#74,#83)); +#86=IFCPRODUCTDEFINITIONSHAPE($,$,(#85)); +#87=IFCWINDOW('0k7vnn7mWSodkjPeo9hnOL',$,'Window G-South 1',$,$,#59,#86,$,1.3,1.2,.WINDOW.,.NOTDEFINED.,$); +#88=IFCRELFILLSELEMENT('2RMS4yJ$3Ov9e_FWsCNrjn',$,$,$,#56,#87); +#89=IFCCARTESIANPOINT((4.2,0.0,0.9)); +#90=IFCAXIS2PLACEMENT3D(#89,$,$); +#91=IFCLOCALPLACEMENT(#31,#90); +#92=IFCAXIS2PLACEMENT2D(#48,$); +#93=IFCRECTANGLEPROFILEDEF(.AREA.,$,#92,1.2,0.5); +#94=IFCAXIS2PLACEMENT3D(#1,$,$); +#95=IFCDIRECTION((0.0,0.0,1.0)); +#96=IFCEXTRUDEDAREASOLID(#93,#94,#95,1.3); +#97=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#96)); +#98=IFCPRODUCTDEFINITIONSHAPE($,$,(#97)); +#99=IFCOPENINGELEMENT('0$chDW1fT87UzE$p4vq45Y',$,'Opening G-South 2',$,$,#91,#98,$,.OPENING.); +#100=IFCRELVOIDSELEMENT('0dTBEaLKSStc9jhDzzvHPK',$,$,$,#44,#99); +#101=IFCAXIS2PLACEMENT3D(#89,$,$); +#102=IFCLOCALPLACEMENT(#31,#101); +#103=IFCAXIS2PLACEMENT2D(#48,$); +#104=IFCRECTANGLEPROFILEDEF(.AREA.,$,#103,1.2,0.1); +#105=IFCAXIS2PLACEMENT3D(#1,$,$); +#106=IFCDIRECTION((0.0,0.0,1.0)); +#107=IFCEXTRUDEDAREASOLID(#104,#105,#106,1.3); +#108=IFCSTYLEDITEM(#107,(#67),$); +#109=IFCAXIS2PLACEMENT2D(#48,$); +#110=IFCRECTANGLEPROFILEDEF(.AREA.,$,#109,1.08,0.04); +#111=IFCAXIS2PLACEMENT3D(#71,$,$); +#112=IFCDIRECTION((0.0,0.0,1.0)); +#113=IFCEXTRUDEDAREASOLID(#110,#111,#112,1.1800000000000002); +#114=IFCSTYLEDITEM(#113,(#77),$); +#115=IFCAXIS2PLACEMENT2D(#48,$); +#116=IFCRECTANGLEPROFILEDEF(.AREA.,$,#115,0.05,0.12); +#117=IFCAXIS2PLACEMENT3D(#1,$,$); +#118=IFCDIRECTION((0.0,0.0,1.0)); +#119=IFCEXTRUDEDAREASOLID(#116,#117,#118,1.3); +#120=IFCSTYLEDITEM(#119,(#67),$); +#121=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#107,#113,#119)); +#122=IFCPRODUCTDEFINITIONSHAPE($,$,(#121)); +#123=IFCWINDOW('1HZ74_qNMwPbIgyj4du78g',$,'Window G-South 2',$,$,#102,#122,$,1.3,1.2,.WINDOW.,.NOTDEFINED.,$); +#124=IFCRELFILLSELEMENT('2hslLgUM3$Y49_YKHOr29Z',$,$,$,#99,#123); +#125=IFCCARTESIANPOINT((7.25,0.0,0.0)); +#126=IFCAXIS2PLACEMENT3D(#125,$,$); +#127=IFCLOCALPLACEMENT(#31,#126); +#128=IFCAXIS2PLACEMENT2D(#48,$); +#129=IFCRECTANGLEPROFILEDEF(.AREA.,$,#128,1.1,0.5); +#130=IFCAXIS2PLACEMENT3D(#1,$,$); +#131=IFCDIRECTION((0.0,0.0,1.0)); +#132=IFCEXTRUDEDAREASOLID(#129,#130,#131,2.1); +#133=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#132)); +#134=IFCPRODUCTDEFINITIONSHAPE($,$,(#133)); +#135=IFCOPENINGELEMENT('0HgAoBlkU8r1t7BofrmrGR',$,'Opening G-South 3',$,$,#127,#134,$,.OPENING.); +#136=IFCRELVOIDSELEMENT('3vD_FU1pSEsJLe2aFg2Brc',$,$,$,#44,#135); +#137=IFCAXIS2PLACEMENT3D(#125,$,$); +#138=IFCLOCALPLACEMENT(#31,#137); +#139=IFCAXIS2PLACEMENT2D(#48,$); +#140=IFCRECTANGLEPROFILEDEF(.AREA.,$,#139,1.0,0.06); +#141=IFCAXIS2PLACEMENT3D(#1,$,$); +#142=IFCDIRECTION((0.0,0.0,1.0)); +#143=IFCEXTRUDEDAREASOLID(#140,#141,#142,2.0500000000000003); +#144=IFCCOLOURRGB($,0.45,0.28,0.14); +#145=IFCSURFACESTYLERENDERING(#144,0.0,$,$,$,$,$,$,.FLAT.); +#146=IFCSURFACESTYLE('Door',.BOTH.,(#145)); +#147=IFCSTYLEDITEM(#143,(#146),$); +#148=IFCAXIS2PLACEMENT2D(#48,$); +#149=IFCRECTANGLEPROFILEDEF(.AREA.,$,#148,0.08,0.14); +#150=IFCCARTESIANPOINT((0.35000000000000003,0.0,1.0)); +#151=IFCAXIS2PLACEMENT3D(#150,$,$); +#152=IFCDIRECTION((0.0,0.0,1.0)); +#153=IFCEXTRUDEDAREASOLID(#149,#151,#152,0.08); +#154=IFCSTYLEDITEM(#153,(#67),$); +#155=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#143,#153)); +#156=IFCPRODUCTDEFINITIONSHAPE($,$,(#155)); +#157=IFCDOOR('127wtWXZdGDL8xBstF2f1M',$,'Door G-South 3',$,$,#138,#156,$,2.1,1.1,.DOOR.,.NOTDEFINED.,$); +#158=IFCRELFILLSELEMENT('0wKT_koxWhGVhAJtZ_mWqI',$,$,$,#135,#157); +#159=IFCCARTESIANPOINT((10.0,0.0,0.0)); +#160=IFCDIRECTION((0.0,0.0,1.0)); +#161=IFCDIRECTION((0.0,1.0,0.0)); +#162=IFCAXIS2PLACEMENT3D(#159,#160,#161); +#163=IFCLOCALPLACEMENT(#18,#162); +#164=IFCCARTESIANPOINT((4.0,0.0)); +#165=IFCAXIS2PLACEMENT2D(#164,$); +#166=IFCRECTANGLEPROFILEDEF(.AREA.,$,#165,8.0,0.3); +#167=IFCAXIS2PLACEMENT3D(#1,$,$); +#168=IFCDIRECTION((0.0,0.0,1.0)); +#169=IFCEXTRUDEDAREASOLID(#166,#167,#168,2.8); +#170=IFCSTYLEDITEM(#169,(#40),$); +#171=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#169)); +#172=IFCPRODUCTDEFINITIONSHAPE($,$,(#171)); +#173=IFCWALL('0qk0kq6a8AV2GIUo7T4G1J',$,'G-East',$,$,#163,#172,$,.STANDARD.); +#174=IFCCARTESIANPOINT((2.05,0.0,0.9)); +#175=IFCAXIS2PLACEMENT3D(#174,$,$); +#176=IFCLOCALPLACEMENT(#163,#175); +#177=IFCAXIS2PLACEMENT2D(#48,$); +#178=IFCRECTANGLEPROFILEDEF(.AREA.,$,#177,1.3,0.5); +#179=IFCAXIS2PLACEMENT3D(#1,$,$); +#180=IFCDIRECTION((0.0,0.0,1.0)); +#181=IFCEXTRUDEDAREASOLID(#178,#179,#180,1.3); +#182=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#181)); +#183=IFCPRODUCTDEFINITIONSHAPE($,$,(#182)); +#184=IFCOPENINGELEMENT('139w4z2NUPM6f02o5RLGf4',$,'Opening G-East 1',$,$,#176,#183,$,.OPENING.); +#185=IFCRELVOIDSELEMENT('0zHoDqcPl8c9r_1YaR3wI5',$,$,$,#173,#184); +#186=IFCAXIS2PLACEMENT3D(#174,$,$); +#187=IFCLOCALPLACEMENT(#163,#186); +#188=IFCAXIS2PLACEMENT2D(#48,$); +#189=IFCRECTANGLEPROFILEDEF(.AREA.,$,#188,1.3,0.1); +#190=IFCAXIS2PLACEMENT3D(#1,$,$); +#191=IFCDIRECTION((0.0,0.0,1.0)); +#192=IFCEXTRUDEDAREASOLID(#189,#190,#191,1.3); +#193=IFCSTYLEDITEM(#192,(#67),$); +#194=IFCAXIS2PLACEMENT2D(#48,$); +#195=IFCRECTANGLEPROFILEDEF(.AREA.,$,#194,1.1800000000000002,0.04); +#196=IFCAXIS2PLACEMENT3D(#71,$,$); +#197=IFCDIRECTION((0.0,0.0,1.0)); +#198=IFCEXTRUDEDAREASOLID(#195,#196,#197,1.1800000000000002); +#199=IFCSTYLEDITEM(#198,(#77),$); +#200=IFCAXIS2PLACEMENT2D(#48,$); +#201=IFCRECTANGLEPROFILEDEF(.AREA.,$,#200,0.05,0.12); +#202=IFCAXIS2PLACEMENT3D(#1,$,$); +#203=IFCDIRECTION((0.0,0.0,1.0)); +#204=IFCEXTRUDEDAREASOLID(#201,#202,#203,1.3); +#205=IFCSTYLEDITEM(#204,(#67),$); +#206=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#192,#198,#204)); +#207=IFCPRODUCTDEFINITIONSHAPE($,$,(#206)); +#208=IFCWINDOW('2bzrq0o36QD$kmJ_0Ouqn1',$,'Window G-East 1',$,$,#187,#207,$,1.3,1.3,.WINDOW.,.NOTDEFINED.,$); +#209=IFCRELFILLSELEMENT('20dGdEg6fNRR1GxAUiq7ZD',$,$,$,#184,#208); +#210=IFCCARTESIANPOINT((5.65,0.0,0.9)); +#211=IFCAXIS2PLACEMENT3D(#210,$,$); +#212=IFCLOCALPLACEMENT(#163,#211); +#213=IFCAXIS2PLACEMENT2D(#48,$); +#214=IFCRECTANGLEPROFILEDEF(.AREA.,$,#213,1.3,0.5); +#215=IFCAXIS2PLACEMENT3D(#1,$,$); +#216=IFCDIRECTION((0.0,0.0,1.0)); +#217=IFCEXTRUDEDAREASOLID(#214,#215,#216,1.3); +#218=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#217)); +#219=IFCPRODUCTDEFINITIONSHAPE($,$,(#218)); +#220=IFCOPENINGELEMENT('3sJK8Ff8kYCctpDoeIXd0e',$,'Opening G-East 2',$,$,#212,#219,$,.OPENING.); +#221=IFCRELVOIDSELEMENT('0V5SO6JX_r3Ehgn1GMrcv5',$,$,$,#173,#220); +#222=IFCAXIS2PLACEMENT3D(#210,$,$); +#223=IFCLOCALPLACEMENT(#163,#222); +#224=IFCAXIS2PLACEMENT2D(#48,$); +#225=IFCRECTANGLEPROFILEDEF(.AREA.,$,#224,1.3,0.1); +#226=IFCAXIS2PLACEMENT3D(#1,$,$); +#227=IFCDIRECTION((0.0,0.0,1.0)); +#228=IFCEXTRUDEDAREASOLID(#225,#226,#227,1.3); +#229=IFCSTYLEDITEM(#228,(#67),$); +#230=IFCAXIS2PLACEMENT2D(#48,$); +#231=IFCRECTANGLEPROFILEDEF(.AREA.,$,#230,1.1800000000000002,0.04); +#232=IFCAXIS2PLACEMENT3D(#71,$,$); +#233=IFCDIRECTION((0.0,0.0,1.0)); +#234=IFCEXTRUDEDAREASOLID(#231,#232,#233,1.1800000000000002); +#235=IFCSTYLEDITEM(#234,(#77),$); +#236=IFCAXIS2PLACEMENT2D(#48,$); +#237=IFCRECTANGLEPROFILEDEF(.AREA.,$,#236,0.05,0.12); +#238=IFCAXIS2PLACEMENT3D(#1,$,$); +#239=IFCDIRECTION((0.0,0.0,1.0)); +#240=IFCEXTRUDEDAREASOLID(#237,#238,#239,1.3); +#241=IFCSTYLEDITEM(#240,(#67),$); +#242=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#228,#234,#240)); +#243=IFCPRODUCTDEFINITIONSHAPE($,$,(#242)); +#244=IFCWINDOW('0Sa7fPbj7b6xbsiUCJrYmA',$,'Window G-East 2',$,$,#223,#243,$,1.3,1.3,.WINDOW.,.NOTDEFINED.,$); +#245=IFCRELFILLSELEMENT('0tVCZn0Q8PPDVhRp9m8tNN',$,$,$,#220,#244); +#246=IFCCARTESIANPOINT((10.0,8.0,0.0)); +#247=IFCDIRECTION((0.0,0.0,1.0)); +#248=IFCDIRECTION((-1.0,0.0,0.0)); +#249=IFCAXIS2PLACEMENT3D(#246,#247,#248); +#250=IFCLOCALPLACEMENT(#18,#249); +#251=IFCAXIS2PLACEMENT2D(#32,$); +#252=IFCRECTANGLEPROFILEDEF(.AREA.,$,#251,10.0,0.3); +#253=IFCAXIS2PLACEMENT3D(#1,$,$); +#254=IFCDIRECTION((0.0,0.0,1.0)); +#255=IFCEXTRUDEDAREASOLID(#252,#253,#254,2.8); +#256=IFCSTYLEDITEM(#255,(#40),$); +#257=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#255)); +#258=IFCPRODUCTDEFINITIONSHAPE($,$,(#257)); +#259=IFCWALL('26TpQmJG3YDhZhOG2PXDnr',$,'G-North',$,$,#250,#258,$,.STANDARD.); +#260=IFCCARTESIANPOINT((1.9,0.0,0.9)); +#261=IFCAXIS2PLACEMENT3D(#260,$,$); +#262=IFCLOCALPLACEMENT(#250,#261); +#263=IFCAXIS2PLACEMENT2D(#48,$); +#264=IFCRECTANGLEPROFILEDEF(.AREA.,$,#263,1.4,0.5); +#265=IFCAXIS2PLACEMENT3D(#1,$,$); +#266=IFCDIRECTION((0.0,0.0,1.0)); +#267=IFCEXTRUDEDAREASOLID(#264,#265,#266,1.3); +#268=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#267)); +#269=IFCPRODUCTDEFINITIONSHAPE($,$,(#268)); +#270=IFCOPENINGELEMENT('3019NCAwz8XX_nFDGXexp5',$,'Opening G-North 1',$,$,#262,#269,$,.OPENING.); +#271=IFCRELVOIDSELEMENT('2_vQbbkkkCFlk5Rrkd3COT',$,$,$,#259,#270); +#272=IFCAXIS2PLACEMENT3D(#260,$,$); +#273=IFCLOCALPLACEMENT(#250,#272); +#274=IFCAXIS2PLACEMENT2D(#48,$); +#275=IFCRECTANGLEPROFILEDEF(.AREA.,$,#274,1.4,0.1); +#276=IFCAXIS2PLACEMENT3D(#1,$,$); +#277=IFCDIRECTION((0.0,0.0,1.0)); +#278=IFCEXTRUDEDAREASOLID(#275,#276,#277,1.3); +#279=IFCSTYLEDITEM(#278,(#67),$); +#280=IFCAXIS2PLACEMENT2D(#48,$); +#281=IFCRECTANGLEPROFILEDEF(.AREA.,$,#280,1.2799999999999998,0.04); +#282=IFCAXIS2PLACEMENT3D(#71,$,$); +#283=IFCDIRECTION((0.0,0.0,1.0)); +#284=IFCEXTRUDEDAREASOLID(#281,#282,#283,1.1800000000000002); +#285=IFCSTYLEDITEM(#284,(#77),$); +#286=IFCAXIS2PLACEMENT2D(#48,$); +#287=IFCRECTANGLEPROFILEDEF(.AREA.,$,#286,0.05,0.12); +#288=IFCAXIS2PLACEMENT3D(#1,$,$); +#289=IFCDIRECTION((0.0,0.0,1.0)); +#290=IFCEXTRUDEDAREASOLID(#287,#288,#289,1.3); +#291=IFCSTYLEDITEM(#290,(#67),$); +#292=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#278,#284,#290)); +#293=IFCPRODUCTDEFINITIONSHAPE($,$,(#292)); +#294=IFCWINDOW('0oS0jKMLo82LoGhKedPaOa',$,'Window G-North 1',$,$,#273,#293,$,1.3,1.4,.WINDOW.,.NOTDEFINED.,$); +#295=IFCRELFILLSELEMENT('1i5hThQM6VBK4BP_6OKO1K',$,$,$,#270,#294); +#296=IFCCARTESIANPOINT((4.8999999999999995,0.0,0.9)); +#297=IFCAXIS2PLACEMENT3D(#296,$,$); +#298=IFCLOCALPLACEMENT(#250,#297); +#299=IFCAXIS2PLACEMENT2D(#48,$); +#300=IFCRECTANGLEPROFILEDEF(.AREA.,$,#299,1.6,0.5); +#301=IFCAXIS2PLACEMENT3D(#1,$,$); +#302=IFCDIRECTION((0.0,0.0,1.0)); +#303=IFCEXTRUDEDAREASOLID(#300,#301,#302,1.3); +#304=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#303)); +#305=IFCPRODUCTDEFINITIONSHAPE($,$,(#304)); +#306=IFCOPENINGELEMENT('3SAsukmp4NAjv2s$PDeNXo',$,'Opening G-North 2',$,$,#298,#305,$,.OPENING.); +#307=IFCRELVOIDSELEMENT('0aT3X54zxxpC6xWPNbWCXJ',$,$,$,#259,#306); +#308=IFCAXIS2PLACEMENT3D(#296,$,$); +#309=IFCLOCALPLACEMENT(#250,#308); +#310=IFCAXIS2PLACEMENT2D(#48,$); +#311=IFCRECTANGLEPROFILEDEF(.AREA.,$,#310,1.6,0.1); +#312=IFCAXIS2PLACEMENT3D(#1,$,$); +#313=IFCDIRECTION((0.0,0.0,1.0)); +#314=IFCEXTRUDEDAREASOLID(#311,#312,#313,1.3); +#315=IFCSTYLEDITEM(#314,(#67),$); +#316=IFCAXIS2PLACEMENT2D(#48,$); +#317=IFCRECTANGLEPROFILEDEF(.AREA.,$,#316,1.48,0.04); +#318=IFCAXIS2PLACEMENT3D(#71,$,$); +#319=IFCDIRECTION((0.0,0.0,1.0)); +#320=IFCEXTRUDEDAREASOLID(#317,#318,#319,1.1800000000000002); +#321=IFCSTYLEDITEM(#320,(#77),$); +#322=IFCAXIS2PLACEMENT2D(#48,$); +#323=IFCRECTANGLEPROFILEDEF(.AREA.,$,#322,0.05,0.12); +#324=IFCAXIS2PLACEMENT3D(#1,$,$); +#325=IFCDIRECTION((0.0,0.0,1.0)); +#326=IFCEXTRUDEDAREASOLID(#323,#324,#325,1.3); +#327=IFCSTYLEDITEM(#326,(#67),$); +#328=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#314,#320,#326)); +#329=IFCPRODUCTDEFINITIONSHAPE($,$,(#328)); +#330=IFCWINDOW('1DN3Cqgy2JDku2yCl19HgH',$,'Window G-North 2',$,$,#309,#329,$,1.3,1.6,.WINDOW.,.NOTDEFINED.,$); +#331=IFCRELFILLSELEMENT('17UEcDlHpXkczj6$lPqKXc',$,$,$,#306,#330); +#332=IFCCARTESIANPOINT((7.8,0.0,0.9)); +#333=IFCAXIS2PLACEMENT3D(#332,$,$); +#334=IFCLOCALPLACEMENT(#250,#333); +#335=IFCAXIS2PLACEMENT2D(#48,$); +#336=IFCRECTANGLEPROFILEDEF(.AREA.,$,#335,1.2,0.5); +#337=IFCAXIS2PLACEMENT3D(#1,$,$); +#338=IFCDIRECTION((0.0,0.0,1.0)); +#339=IFCEXTRUDEDAREASOLID(#336,#337,#338,1.3); +#340=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#339)); +#341=IFCPRODUCTDEFINITIONSHAPE($,$,(#340)); +#342=IFCOPENINGELEMENT('1GvQj4CBkJ4WWgKqT2uSC0',$,'Opening G-North 3',$,$,#334,#341,$,.OPENING.); +#343=IFCRELVOIDSELEMENT('0JU_wg7qwvlp1otF98NblZ',$,$,$,#259,#342); +#344=IFCAXIS2PLACEMENT3D(#332,$,$); +#345=IFCLOCALPLACEMENT(#250,#344); +#346=IFCAXIS2PLACEMENT2D(#48,$); +#347=IFCRECTANGLEPROFILEDEF(.AREA.,$,#346,1.2,0.1); +#348=IFCAXIS2PLACEMENT3D(#1,$,$); +#349=IFCDIRECTION((0.0,0.0,1.0)); +#350=IFCEXTRUDEDAREASOLID(#347,#348,#349,1.3); +#351=IFCSTYLEDITEM(#350,(#67),$); +#352=IFCAXIS2PLACEMENT2D(#48,$); +#353=IFCRECTANGLEPROFILEDEF(.AREA.,$,#352,1.08,0.04); +#354=IFCAXIS2PLACEMENT3D(#71,$,$); +#355=IFCDIRECTION((0.0,0.0,1.0)); +#356=IFCEXTRUDEDAREASOLID(#353,#354,#355,1.1800000000000002); +#357=IFCSTYLEDITEM(#356,(#77),$); +#358=IFCAXIS2PLACEMENT2D(#48,$); +#359=IFCRECTANGLEPROFILEDEF(.AREA.,$,#358,0.05,0.12); +#360=IFCAXIS2PLACEMENT3D(#1,$,$); +#361=IFCDIRECTION((0.0,0.0,1.0)); +#362=IFCEXTRUDEDAREASOLID(#359,#360,#361,1.3); +#363=IFCSTYLEDITEM(#362,(#67),$); +#364=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#350,#356,#362)); +#365=IFCPRODUCTDEFINITIONSHAPE($,$,(#364)); +#366=IFCWINDOW('1l1WYJnbe4sk7iu_FPFvbX',$,'Window G-North 3',$,$,#345,#365,$,1.3,1.2,.WINDOW.,.NOTDEFINED.,$); +#367=IFCRELFILLSELEMENT('1MBhC2ioH0LniEYsYMNQBf',$,$,$,#342,#366); +#368=IFCCARTESIANPOINT((0.0,8.0,0.0)); +#369=IFCDIRECTION((0.0,0.0,1.0)); +#370=IFCDIRECTION((0.0,-1.0,0.0)); +#371=IFCAXIS2PLACEMENT3D(#368,#369,#370); +#372=IFCLOCALPLACEMENT(#18,#371); +#373=IFCAXIS2PLACEMENT2D(#164,$); +#374=IFCRECTANGLEPROFILEDEF(.AREA.,$,#373,8.0,0.3); +#375=IFCAXIS2PLACEMENT3D(#1,$,$); +#376=IFCDIRECTION((0.0,0.0,1.0)); +#377=IFCEXTRUDEDAREASOLID(#374,#375,#376,2.8); +#378=IFCSTYLEDITEM(#377,(#40),$); +#379=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#377)); +#380=IFCPRODUCTDEFINITIONSHAPE($,$,(#379)); +#381=IFCWALL('2$5HCGzNGbNfTCNWZlSpES',$,'G-West',$,$,#372,#380,$,.STANDARD.); +#382=IFCCARTESIANPOINT((2.3,0.0,0.9)); +#383=IFCAXIS2PLACEMENT3D(#382,$,$); +#384=IFCLOCALPLACEMENT(#372,#383); +#385=IFCAXIS2PLACEMENT2D(#48,$); +#386=IFCRECTANGLEPROFILEDEF(.AREA.,$,#385,1.4,0.5); +#387=IFCAXIS2PLACEMENT3D(#1,$,$); +#388=IFCDIRECTION((0.0,0.0,1.0)); +#389=IFCEXTRUDEDAREASOLID(#386,#387,#388,1.3); +#390=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#389)); +#391=IFCPRODUCTDEFINITIONSHAPE($,$,(#390)); +#392=IFCOPENINGELEMENT('0gCNDsyjpqOqEDutl0fz1J',$,'Opening G-West 1',$,$,#384,#391,$,.OPENING.); +#393=IFCRELVOIDSELEMENT('0seu3$M405XcR77mnfTqHU',$,$,$,#381,#392); +#394=IFCAXIS2PLACEMENT3D(#382,$,$); +#395=IFCLOCALPLACEMENT(#372,#394); +#396=IFCAXIS2PLACEMENT2D(#48,$); +#397=IFCRECTANGLEPROFILEDEF(.AREA.,$,#396,1.4,0.1); +#398=IFCAXIS2PLACEMENT3D(#1,$,$); +#399=IFCDIRECTION((0.0,0.0,1.0)); +#400=IFCEXTRUDEDAREASOLID(#397,#398,#399,1.3); +#401=IFCSTYLEDITEM(#400,(#67),$); +#402=IFCAXIS2PLACEMENT2D(#48,$); +#403=IFCRECTANGLEPROFILEDEF(.AREA.,$,#402,1.2799999999999998,0.04); +#404=IFCAXIS2PLACEMENT3D(#71,$,$); +#405=IFCDIRECTION((0.0,0.0,1.0)); +#406=IFCEXTRUDEDAREASOLID(#403,#404,#405,1.1800000000000002); +#407=IFCSTYLEDITEM(#406,(#77),$); +#408=IFCAXIS2PLACEMENT2D(#48,$); +#409=IFCRECTANGLEPROFILEDEF(.AREA.,$,#408,0.05,0.12); +#410=IFCAXIS2PLACEMENT3D(#1,$,$); +#411=IFCDIRECTION((0.0,0.0,1.0)); +#412=IFCEXTRUDEDAREASOLID(#409,#410,#411,1.3); +#413=IFCSTYLEDITEM(#412,(#67),$); +#414=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#400,#406,#412)); +#415=IFCPRODUCTDEFINITIONSHAPE($,$,(#414)); +#416=IFCWINDOW('3n545Kq$Z$fA4BAquBsjMl',$,'Window G-West 1',$,$,#395,#415,$,1.3,1.4,.WINDOW.,.NOTDEFINED.,$); +#417=IFCRELFILLSELEMENT('3_67_H0UzhZqzd1VHkDhHB',$,$,$,#392,#416); +#418=IFCCARTESIANPOINT((5.7,0.0,0.9)); +#419=IFCAXIS2PLACEMENT3D(#418,$,$); +#420=IFCLOCALPLACEMENT(#372,#419); +#421=IFCAXIS2PLACEMENT2D(#48,$); +#422=IFCRECTANGLEPROFILEDEF(.AREA.,$,#421,1.4,0.5); +#423=IFCAXIS2PLACEMENT3D(#1,$,$); +#424=IFCDIRECTION((0.0,0.0,1.0)); +#425=IFCEXTRUDEDAREASOLID(#422,#423,#424,1.3); +#426=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#425)); +#427=IFCPRODUCTDEFINITIONSHAPE($,$,(#426)); +#428=IFCOPENINGELEMENT('23fstwY$XueauzWc36rbIu',$,'Opening G-West 2',$,$,#420,#427,$,.OPENING.); +#429=IFCRELVOIDSELEMENT('3Tp6w8__ErAA6ElBP084AX',$,$,$,#381,#428); +#430=IFCAXIS2PLACEMENT3D(#418,$,$); +#431=IFCLOCALPLACEMENT(#372,#430); +#432=IFCAXIS2PLACEMENT2D(#48,$); +#433=IFCRECTANGLEPROFILEDEF(.AREA.,$,#432,1.4,0.1); +#434=IFCAXIS2PLACEMENT3D(#1,$,$); +#435=IFCDIRECTION((0.0,0.0,1.0)); +#436=IFCEXTRUDEDAREASOLID(#433,#434,#435,1.3); +#437=IFCSTYLEDITEM(#436,(#67),$); +#438=IFCAXIS2PLACEMENT2D(#48,$); +#439=IFCRECTANGLEPROFILEDEF(.AREA.,$,#438,1.2799999999999998,0.04); +#440=IFCAXIS2PLACEMENT3D(#71,$,$); +#441=IFCDIRECTION((0.0,0.0,1.0)); +#442=IFCEXTRUDEDAREASOLID(#439,#440,#441,1.1800000000000002); +#443=IFCSTYLEDITEM(#442,(#77),$); +#444=IFCAXIS2PLACEMENT2D(#48,$); +#445=IFCRECTANGLEPROFILEDEF(.AREA.,$,#444,0.05,0.12); +#446=IFCAXIS2PLACEMENT3D(#1,$,$); +#447=IFCDIRECTION((0.0,0.0,1.0)); +#448=IFCEXTRUDEDAREASOLID(#445,#446,#447,1.3); +#449=IFCSTYLEDITEM(#448,(#67),$); +#450=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#436,#442,#448)); +#451=IFCPRODUCTDEFINITIONSHAPE($,$,(#450)); +#452=IFCWINDOW('063tGBYLFfwrEegUINghwH',$,'Window G-West 2',$,$,#431,#451,$,1.3,1.4,.WINDOW.,.NOTDEFINED.,$); +#453=IFCRELFILLSELEMENT('2gmXv3u1D4lBiK6BofrCBs',$,$,$,#428,#452); +#454=IFCCARTESIANPOINT((6.0,0.15,0.0)); +#455=IFCDIRECTION((0.0,0.0,1.0)); +#456=IFCDIRECTION((0.0,1.0,0.0)); +#457=IFCAXIS2PLACEMENT3D(#454,#455,#456); +#458=IFCLOCALPLACEMENT(#18,#457); +#459=IFCCARTESIANPOINT((3.8499999999999996,0.0)); +#460=IFCAXIS2PLACEMENT2D(#459,$); +#461=IFCRECTANGLEPROFILEDEF(.AREA.,$,#460,7.699999999999999,0.15); +#462=IFCAXIS2PLACEMENT3D(#1,$,$); +#463=IFCDIRECTION((0.0,0.0,1.0)); +#464=IFCEXTRUDEDAREASOLID(#461,#462,#463,2.8); +#465=IFCSTYLEDITEM(#464,(#40),$); +#466=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#464)); +#467=IFCPRODUCTDEFINITIONSHAPE($,$,(#466)); +#468=IFCWALL('1_zKZXP$5GnkYHpaJR3Bok',$,'G-Partition A',$,$,#458,#467,$,.STANDARD.); +#469=IFCCARTESIANPOINT((1.95,0.0,0.0)); +#470=IFCAXIS2PLACEMENT3D(#469,$,$); +#471=IFCLOCALPLACEMENT(#458,#470); +#472=IFCAXIS2PLACEMENT2D(#48,$); +#473=IFCRECTANGLEPROFILEDEF(.AREA.,$,#472,0.9,0.35); +#474=IFCAXIS2PLACEMENT3D(#1,$,$); +#475=IFCDIRECTION((0.0,0.0,1.0)); +#476=IFCEXTRUDEDAREASOLID(#473,#474,#475,2.05); +#477=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#476)); +#478=IFCPRODUCTDEFINITIONSHAPE($,$,(#477)); +#479=IFCOPENINGELEMENT('2Ktw7Uh8$_RS8ZDn3YpkjP',$,'Opening G-Partition A 1',$,$,#471,#478,$,.OPENING.); +#480=IFCRELVOIDSELEMENT('3TtEeNIix$_gqAI9qUH3xu',$,$,$,#468,#479); +#481=IFCAXIS2PLACEMENT3D(#469,$,$); +#482=IFCLOCALPLACEMENT(#458,#481); +#483=IFCAXIS2PLACEMENT2D(#48,$); +#484=IFCRECTANGLEPROFILEDEF(.AREA.,$,#483,0.8,0.06); +#485=IFCAXIS2PLACEMENT3D(#1,$,$); +#486=IFCDIRECTION((0.0,0.0,1.0)); +#487=IFCEXTRUDEDAREASOLID(#484,#485,#486,1.9999999999999998); +#488=IFCSTYLEDITEM(#487,(#146),$); +#489=IFCAXIS2PLACEMENT2D(#48,$); +#490=IFCRECTANGLEPROFILEDEF(.AREA.,$,#489,0.08,0.14); +#491=IFCCARTESIANPOINT((0.25,0.0,1.0)); +#492=IFCAXIS2PLACEMENT3D(#491,$,$); +#493=IFCDIRECTION((0.0,0.0,1.0)); +#494=IFCEXTRUDEDAREASOLID(#490,#492,#493,0.08); +#495=IFCSTYLEDITEM(#494,(#67),$); +#496=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#487,#494)); +#497=IFCPRODUCTDEFINITIONSHAPE($,$,(#496)); +#498=IFCDOOR('2LRwR_QGebxnxP4w0ovXsU',$,'Door G-Partition A 1',$,$,#482,#497,$,2.05,0.9,.DOOR.,.NOTDEFINED.,$); +#499=IFCRELFILLSELEMENT('0L07NwfSRO86g_eFux3$Io',$,$,$,#479,#498); +#500=IFCCARTESIANPOINT((5.45,0.0,0.0)); +#501=IFCAXIS2PLACEMENT3D(#500,$,$); +#502=IFCLOCALPLACEMENT(#458,#501); +#503=IFCAXIS2PLACEMENT2D(#48,$); +#504=IFCRECTANGLEPROFILEDEF(.AREA.,$,#503,0.9,0.35); +#505=IFCAXIS2PLACEMENT3D(#1,$,$); +#506=IFCDIRECTION((0.0,0.0,1.0)); +#507=IFCEXTRUDEDAREASOLID(#504,#505,#506,2.05); +#508=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#507)); +#509=IFCPRODUCTDEFINITIONSHAPE($,$,(#508)); +#510=IFCOPENINGELEMENT('3_sf3G4$baqZDY1cO4hHVC',$,'Opening G-Partition A 2',$,$,#502,#509,$,.OPENING.); +#511=IFCRELVOIDSELEMENT('3gRHN6Sk3cWrbFbUBIYwwT',$,$,$,#468,#510); +#512=IFCAXIS2PLACEMENT3D(#500,$,$); +#513=IFCLOCALPLACEMENT(#458,#512); +#514=IFCAXIS2PLACEMENT2D(#48,$); +#515=IFCRECTANGLEPROFILEDEF(.AREA.,$,#514,0.8,0.06); +#516=IFCAXIS2PLACEMENT3D(#1,$,$); +#517=IFCDIRECTION((0.0,0.0,1.0)); +#518=IFCEXTRUDEDAREASOLID(#515,#516,#517,1.9999999999999998); +#519=IFCSTYLEDITEM(#518,(#146),$); +#520=IFCAXIS2PLACEMENT2D(#48,$); +#521=IFCRECTANGLEPROFILEDEF(.AREA.,$,#520,0.08,0.14); +#522=IFCAXIS2PLACEMENT3D(#491,$,$); +#523=IFCDIRECTION((0.0,0.0,1.0)); +#524=IFCEXTRUDEDAREASOLID(#521,#522,#523,0.08); +#525=IFCSTYLEDITEM(#524,(#67),$); +#526=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#518,#524)); +#527=IFCPRODUCTDEFINITIONSHAPE($,$,(#526)); +#528=IFCDOOR('2Ir$8YdjkSgzAJtMARCZiF',$,'Door G-Partition A 2',$,$,#513,#527,$,2.05,0.9,.DOOR.,.NOTDEFINED.,$); +#529=IFCRELFILLSELEMENT('1RQEGsUydtIqbrsa0HlRw7',$,$,$,#510,#528); +#530=IFCCARTESIANPOINT((0.15,4.0,0.0)); +#531=IFCDIRECTION((0.0,0.0,1.0)); +#532=IFCDIRECTION((1.0,0.0,0.0)); +#533=IFCAXIS2PLACEMENT3D(#530,#531,#532); +#534=IFCLOCALPLACEMENT(#18,#533); +#535=IFCCARTESIANPOINT((2.925,0.0)); +#536=IFCAXIS2PLACEMENT2D(#535,$); +#537=IFCRECTANGLEPROFILEDEF(.AREA.,$,#536,5.85,0.15); +#538=IFCAXIS2PLACEMENT3D(#1,$,$); +#539=IFCDIRECTION((0.0,0.0,1.0)); +#540=IFCEXTRUDEDAREASOLID(#537,#538,#539,2.8); +#541=IFCSTYLEDITEM(#540,(#40),$); +#542=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#540)); +#543=IFCPRODUCTDEFINITIONSHAPE($,$,(#542)); +#544=IFCWALL('2ovn4oyBVFdbrWEG1M3Q88',$,'G-Partition B',$,$,#534,#543,$,.STANDARD.); +#545=IFCCARTESIANPOINT((1.45,0.0,0.0)); +#546=IFCAXIS2PLACEMENT3D(#545,$,$); +#547=IFCLOCALPLACEMENT(#534,#546); +#548=IFCAXIS2PLACEMENT2D(#48,$); +#549=IFCRECTANGLEPROFILEDEF(.AREA.,$,#548,0.9,0.35); +#550=IFCAXIS2PLACEMENT3D(#1,$,$); +#551=IFCDIRECTION((0.0,0.0,1.0)); +#552=IFCEXTRUDEDAREASOLID(#549,#550,#551,2.05); +#553=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#552)); +#554=IFCPRODUCTDEFINITIONSHAPE($,$,(#553)); +#555=IFCOPENINGELEMENT('0XfmV4cd$rC2fZoPux72SI',$,'Opening G-Partition B 1',$,$,#547,#554,$,.OPENING.); +#556=IFCRELVOIDSELEMENT('2GvAGiwPzlvFeOlNOhu1ng',$,$,$,#544,#555); +#557=IFCAXIS2PLACEMENT3D(#545,$,$); +#558=IFCLOCALPLACEMENT(#534,#557); +#559=IFCAXIS2PLACEMENT2D(#48,$); +#560=IFCRECTANGLEPROFILEDEF(.AREA.,$,#559,0.8,0.06); +#561=IFCAXIS2PLACEMENT3D(#1,$,$); +#562=IFCDIRECTION((0.0,0.0,1.0)); +#563=IFCEXTRUDEDAREASOLID(#560,#561,#562,1.9999999999999998); +#564=IFCSTYLEDITEM(#563,(#146),$); +#565=IFCAXIS2PLACEMENT2D(#48,$); +#566=IFCRECTANGLEPROFILEDEF(.AREA.,$,#565,0.08,0.14); +#567=IFCAXIS2PLACEMENT3D(#491,$,$); +#568=IFCDIRECTION((0.0,0.0,1.0)); +#569=IFCEXTRUDEDAREASOLID(#566,#567,#568,0.08); +#570=IFCSTYLEDITEM(#569,(#67),$); +#571=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#563,#569)); +#572=IFCPRODUCTDEFINITIONSHAPE($,$,(#571)); +#573=IFCDOOR('0p1yVrvamivbkLPsXW5OIR',$,'Door G-Partition B 1',$,$,#558,#572,$,2.05,0.9,.DOOR.,.NOTDEFINED.,$); +#574=IFCRELFILLSELEMENT('1x_8sow6ezjuOZk2uzYlWD',$,$,$,#555,#573); +#575=IFCDIRECTION((0.0,0.0,1.0)); +#576=IFCDIRECTION((1.0,0.0,0.0)); +#577=IFCAXIS2PLACEMENT3D(#1,#575,#576); +#578=IFCLOCALPLACEMENT(#22,#577); +#579=IFCAXIS2PLACEMENT2D(#32,$); +#580=IFCRECTANGLEPROFILEDEF(.AREA.,$,#579,10.0,0.3); +#581=IFCAXIS2PLACEMENT3D(#1,$,$); +#582=IFCDIRECTION((0.0,0.0,1.0)); +#583=IFCEXTRUDEDAREASOLID(#580,#581,#582,2.8); +#584=IFCSTYLEDITEM(#583,(#40),$); +#585=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#583)); +#586=IFCPRODUCTDEFINITIONSHAPE($,$,(#585)); +#587=IFCWALL('2$pTCPsWilX768UT$2Dprk',$,'F-South',$,$,#578,#586,$,.STANDARD.); +#588=IFCAXIS2PLACEMENT3D(#45,$,$); +#589=IFCLOCALPLACEMENT(#578,#588); +#590=IFCAXIS2PLACEMENT2D(#48,$); +#591=IFCRECTANGLEPROFILEDEF(.AREA.,$,#590,1.2,0.5); +#592=IFCAXIS2PLACEMENT3D(#1,$,$); +#593=IFCDIRECTION((0.0,0.0,1.0)); +#594=IFCEXTRUDEDAREASOLID(#591,#592,#593,1.3); +#595=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#594)); +#596=IFCPRODUCTDEFINITIONSHAPE($,$,(#595)); +#597=IFCOPENINGELEMENT('0KOk7mETOuhrXWQzkve9wf',$,'Opening F-South 1',$,$,#589,#596,$,.OPENING.); +#598=IFCRELVOIDSELEMENT('0d84PoY2VZwasiBw9lrcIq',$,$,$,#587,#597); +#599=IFCAXIS2PLACEMENT3D(#45,$,$); +#600=IFCLOCALPLACEMENT(#578,#599); +#601=IFCAXIS2PLACEMENT2D(#48,$); +#602=IFCRECTANGLEPROFILEDEF(.AREA.,$,#601,1.2,0.1); +#603=IFCAXIS2PLACEMENT3D(#1,$,$); +#604=IFCDIRECTION((0.0,0.0,1.0)); +#605=IFCEXTRUDEDAREASOLID(#602,#603,#604,1.3); +#606=IFCSTYLEDITEM(#605,(#67),$); +#607=IFCAXIS2PLACEMENT2D(#48,$); +#608=IFCRECTANGLEPROFILEDEF(.AREA.,$,#607,1.08,0.04); +#609=IFCAXIS2PLACEMENT3D(#71,$,$); +#610=IFCDIRECTION((0.0,0.0,1.0)); +#611=IFCEXTRUDEDAREASOLID(#608,#609,#610,1.1800000000000002); +#612=IFCSTYLEDITEM(#611,(#77),$); +#613=IFCAXIS2PLACEMENT2D(#48,$); +#614=IFCRECTANGLEPROFILEDEF(.AREA.,$,#613,0.05,0.12); +#615=IFCAXIS2PLACEMENT3D(#1,$,$); +#616=IFCDIRECTION((0.0,0.0,1.0)); +#617=IFCEXTRUDEDAREASOLID(#614,#615,#616,1.3); +#618=IFCSTYLEDITEM(#617,(#67),$); +#619=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#605,#611,#617)); +#620=IFCPRODUCTDEFINITIONSHAPE($,$,(#619)); +#621=IFCWINDOW('2zTLDJyCWWLb0bQ3sPyhWg',$,'Window F-South 1',$,$,#600,#620,$,1.3,1.2,.WINDOW.,.NOTDEFINED.,$); +#622=IFCRELFILLSELEMENT('1a0CL_q8ENRUQ3TtF2D6LH',$,$,$,#597,#621); +#623=IFCAXIS2PLACEMENT3D(#89,$,$); +#624=IFCLOCALPLACEMENT(#578,#623); +#625=IFCAXIS2PLACEMENT2D(#48,$); +#626=IFCRECTANGLEPROFILEDEF(.AREA.,$,#625,1.2,0.5); +#627=IFCAXIS2PLACEMENT3D(#1,$,$); +#628=IFCDIRECTION((0.0,0.0,1.0)); +#629=IFCEXTRUDEDAREASOLID(#626,#627,#628,1.3); +#630=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#629)); +#631=IFCPRODUCTDEFINITIONSHAPE($,$,(#630)); +#632=IFCOPENINGELEMENT('1yGAU7ZsBkP37y10YsugjN',$,'Opening F-South 2',$,$,#624,#631,$,.OPENING.); +#633=IFCRELVOIDSELEMENT('1XZmCcTzggoekzw8fxlwFn',$,$,$,#587,#632); +#634=IFCAXIS2PLACEMENT3D(#89,$,$); +#635=IFCLOCALPLACEMENT(#578,#634); +#636=IFCAXIS2PLACEMENT2D(#48,$); +#637=IFCRECTANGLEPROFILEDEF(.AREA.,$,#636,1.2,0.1); +#638=IFCAXIS2PLACEMENT3D(#1,$,$); +#639=IFCDIRECTION((0.0,0.0,1.0)); +#640=IFCEXTRUDEDAREASOLID(#637,#638,#639,1.3); +#641=IFCSTYLEDITEM(#640,(#67),$); +#642=IFCAXIS2PLACEMENT2D(#48,$); +#643=IFCRECTANGLEPROFILEDEF(.AREA.,$,#642,1.08,0.04); +#644=IFCAXIS2PLACEMENT3D(#71,$,$); +#645=IFCDIRECTION((0.0,0.0,1.0)); +#646=IFCEXTRUDEDAREASOLID(#643,#644,#645,1.1800000000000002); +#647=IFCSTYLEDITEM(#646,(#77),$); +#648=IFCAXIS2PLACEMENT2D(#48,$); +#649=IFCRECTANGLEPROFILEDEF(.AREA.,$,#648,0.05,0.12); +#650=IFCAXIS2PLACEMENT3D(#1,$,$); +#651=IFCDIRECTION((0.0,0.0,1.0)); +#652=IFCEXTRUDEDAREASOLID(#649,#650,#651,1.3); +#653=IFCSTYLEDITEM(#652,(#67),$); +#654=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#640,#646,#652)); +#655=IFCPRODUCTDEFINITIONSHAPE($,$,(#654)); +#656=IFCWINDOW('1iqUJP_dvw5DyvqFy4O8d0',$,'Window F-South 2',$,$,#635,#655,$,1.3,1.2,.WINDOW.,.NOTDEFINED.,$); +#657=IFCRELFILLSELEMENT('05EN1L4FtKBS$kHcPAJpFH',$,$,$,#632,#656); +#658=IFCCARTESIANPOINT((7.5,0.0,0.9)); +#659=IFCAXIS2PLACEMENT3D(#658,$,$); +#660=IFCLOCALPLACEMENT(#578,#659); +#661=IFCAXIS2PLACEMENT2D(#48,$); +#662=IFCRECTANGLEPROFILEDEF(.AREA.,$,#661,1.2,0.5); +#663=IFCAXIS2PLACEMENT3D(#1,$,$); +#664=IFCDIRECTION((0.0,0.0,1.0)); +#665=IFCEXTRUDEDAREASOLID(#662,#663,#664,1.3); +#666=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#665)); +#667=IFCPRODUCTDEFINITIONSHAPE($,$,(#666)); +#668=IFCOPENINGELEMENT('37bkjf70edmKDECiviecVM',$,'Opening F-South 3',$,$,#660,#667,$,.OPENING.); +#669=IFCRELVOIDSELEMENT('16u2Awk_33CIXbMdgrkw5z',$,$,$,#587,#668); +#670=IFCAXIS2PLACEMENT3D(#658,$,$); +#671=IFCLOCALPLACEMENT(#578,#670); +#672=IFCAXIS2PLACEMENT2D(#48,$); +#673=IFCRECTANGLEPROFILEDEF(.AREA.,$,#672,1.2,0.1); +#674=IFCAXIS2PLACEMENT3D(#1,$,$); +#675=IFCDIRECTION((0.0,0.0,1.0)); +#676=IFCEXTRUDEDAREASOLID(#673,#674,#675,1.3); +#677=IFCSTYLEDITEM(#676,(#67),$); +#678=IFCAXIS2PLACEMENT2D(#48,$); +#679=IFCRECTANGLEPROFILEDEF(.AREA.,$,#678,1.08,0.04); +#680=IFCAXIS2PLACEMENT3D(#71,$,$); +#681=IFCDIRECTION((0.0,0.0,1.0)); +#682=IFCEXTRUDEDAREASOLID(#679,#680,#681,1.1800000000000002); +#683=IFCSTYLEDITEM(#682,(#77),$); +#684=IFCAXIS2PLACEMENT2D(#48,$); +#685=IFCRECTANGLEPROFILEDEF(.AREA.,$,#684,0.05,0.12); +#686=IFCAXIS2PLACEMENT3D(#1,$,$); +#687=IFCDIRECTION((0.0,0.0,1.0)); +#688=IFCEXTRUDEDAREASOLID(#685,#686,#687,1.3); +#689=IFCSTYLEDITEM(#688,(#67),$); +#690=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#676,#682,#688)); +#691=IFCPRODUCTDEFINITIONSHAPE($,$,(#690)); +#692=IFCWINDOW('331kvpvGfQe36OyJxNqwg6',$,'Window F-South 3',$,$,#671,#691,$,1.3,1.2,.WINDOW.,.NOTDEFINED.,$); +#693=IFCRELFILLSELEMENT('1FR1EDrBglHPU4dC53_BZl',$,$,$,#668,#692); +#694=IFCDIRECTION((0.0,0.0,1.0)); +#695=IFCDIRECTION((0.0,1.0,0.0)); +#696=IFCAXIS2PLACEMENT3D(#159,#694,#695); +#697=IFCLOCALPLACEMENT(#22,#696); +#698=IFCAXIS2PLACEMENT2D(#164,$); +#699=IFCRECTANGLEPROFILEDEF(.AREA.,$,#698,8.0,0.3); +#700=IFCAXIS2PLACEMENT3D(#1,$,$); +#701=IFCDIRECTION((0.0,0.0,1.0)); +#702=IFCEXTRUDEDAREASOLID(#699,#700,#701,2.8); +#703=IFCSTYLEDITEM(#702,(#40),$); +#704=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#702)); +#705=IFCPRODUCTDEFINITIONSHAPE($,$,(#704)); +#706=IFCWALL('0qgiqCUxAtkftKs1Y$lDcW',$,'F-East',$,$,#697,#705,$,.STANDARD.); +#707=IFCAXIS2PLACEMENT3D(#174,$,$); +#708=IFCLOCALPLACEMENT(#697,#707); +#709=IFCAXIS2PLACEMENT2D(#48,$); +#710=IFCRECTANGLEPROFILEDEF(.AREA.,$,#709,1.3,0.5); +#711=IFCAXIS2PLACEMENT3D(#1,$,$); +#712=IFCDIRECTION((0.0,0.0,1.0)); +#713=IFCEXTRUDEDAREASOLID(#710,#711,#712,1.3); +#714=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#713)); +#715=IFCPRODUCTDEFINITIONSHAPE($,$,(#714)); +#716=IFCOPENINGELEMENT('3D0nqdnuRXaOio90htvOZ9',$,'Opening F-East 1',$,$,#708,#715,$,.OPENING.); +#717=IFCRELVOIDSELEMENT('0c3ZX0SPCtvrrGQt6voxwR',$,$,$,#706,#716); +#718=IFCAXIS2PLACEMENT3D(#174,$,$); +#719=IFCLOCALPLACEMENT(#697,#718); +#720=IFCAXIS2PLACEMENT2D(#48,$); +#721=IFCRECTANGLEPROFILEDEF(.AREA.,$,#720,1.3,0.1); +#722=IFCAXIS2PLACEMENT3D(#1,$,$); +#723=IFCDIRECTION((0.0,0.0,1.0)); +#724=IFCEXTRUDEDAREASOLID(#721,#722,#723,1.3); +#725=IFCSTYLEDITEM(#724,(#67),$); +#726=IFCAXIS2PLACEMENT2D(#48,$); +#727=IFCRECTANGLEPROFILEDEF(.AREA.,$,#726,1.1800000000000002,0.04); +#728=IFCAXIS2PLACEMENT3D(#71,$,$); +#729=IFCDIRECTION((0.0,0.0,1.0)); +#730=IFCEXTRUDEDAREASOLID(#727,#728,#729,1.1800000000000002); +#731=IFCSTYLEDITEM(#730,(#77),$); +#732=IFCAXIS2PLACEMENT2D(#48,$); +#733=IFCRECTANGLEPROFILEDEF(.AREA.,$,#732,0.05,0.12); +#734=IFCAXIS2PLACEMENT3D(#1,$,$); +#735=IFCDIRECTION((0.0,0.0,1.0)); +#736=IFCEXTRUDEDAREASOLID(#733,#734,#735,1.3); +#737=IFCSTYLEDITEM(#736,(#67),$); +#738=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#724,#730,#736)); +#739=IFCPRODUCTDEFINITIONSHAPE($,$,(#738)); +#740=IFCWINDOW('002llom_twcEPYR5Y4kBNH',$,'Window F-East 1',$,$,#719,#739,$,1.3,1.3,.WINDOW.,.NOTDEFINED.,$); +#741=IFCRELFILLSELEMENT('2Z0gnMHzIq$phuTBrzhDie',$,$,$,#716,#740); +#742=IFCAXIS2PLACEMENT3D(#210,$,$); +#743=IFCLOCALPLACEMENT(#697,#742); +#744=IFCAXIS2PLACEMENT2D(#48,$); +#745=IFCRECTANGLEPROFILEDEF(.AREA.,$,#744,1.3,0.5); +#746=IFCAXIS2PLACEMENT3D(#1,$,$); +#747=IFCDIRECTION((0.0,0.0,1.0)); +#748=IFCEXTRUDEDAREASOLID(#745,#746,#747,1.3); +#749=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#748)); +#750=IFCPRODUCTDEFINITIONSHAPE($,$,(#749)); +#751=IFCOPENINGELEMENT('0c4NidDPHU72Kza7upjM_d',$,'Opening F-East 2',$,$,#743,#750,$,.OPENING.); +#752=IFCRELVOIDSELEMENT('0FODem1ph_HwU64RfJ0hZD',$,$,$,#706,#751); +#753=IFCAXIS2PLACEMENT3D(#210,$,$); +#754=IFCLOCALPLACEMENT(#697,#753); +#755=IFCAXIS2PLACEMENT2D(#48,$); +#756=IFCRECTANGLEPROFILEDEF(.AREA.,$,#755,1.3,0.1); +#757=IFCAXIS2PLACEMENT3D(#1,$,$); +#758=IFCDIRECTION((0.0,0.0,1.0)); +#759=IFCEXTRUDEDAREASOLID(#756,#757,#758,1.3); +#760=IFCSTYLEDITEM(#759,(#67),$); +#761=IFCAXIS2PLACEMENT2D(#48,$); +#762=IFCRECTANGLEPROFILEDEF(.AREA.,$,#761,1.1800000000000002,0.04); +#763=IFCAXIS2PLACEMENT3D(#71,$,$); +#764=IFCDIRECTION((0.0,0.0,1.0)); +#765=IFCEXTRUDEDAREASOLID(#762,#763,#764,1.1800000000000002); +#766=IFCSTYLEDITEM(#765,(#77),$); +#767=IFCAXIS2PLACEMENT2D(#48,$); +#768=IFCRECTANGLEPROFILEDEF(.AREA.,$,#767,0.05,0.12); +#769=IFCAXIS2PLACEMENT3D(#1,$,$); +#770=IFCDIRECTION((0.0,0.0,1.0)); +#771=IFCEXTRUDEDAREASOLID(#768,#769,#770,1.3); +#772=IFCSTYLEDITEM(#771,(#67),$); +#773=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#759,#765,#771)); +#774=IFCPRODUCTDEFINITIONSHAPE($,$,(#773)); +#775=IFCWINDOW('23m5s1YUU3_tH_BBzjajvY',$,'Window F-East 2',$,$,#754,#774,$,1.3,1.3,.WINDOW.,.NOTDEFINED.,$); +#776=IFCRELFILLSELEMENT('2ES9o5HtjbyIN5hvgYPcy1',$,$,$,#751,#775); +#777=IFCDIRECTION((0.0,0.0,1.0)); +#778=IFCDIRECTION((-1.0,0.0,0.0)); +#779=IFCAXIS2PLACEMENT3D(#246,#777,#778); +#780=IFCLOCALPLACEMENT(#22,#779); +#781=IFCAXIS2PLACEMENT2D(#32,$); +#782=IFCRECTANGLEPROFILEDEF(.AREA.,$,#781,10.0,0.3); +#783=IFCAXIS2PLACEMENT3D(#1,$,$); +#784=IFCDIRECTION((0.0,0.0,1.0)); +#785=IFCEXTRUDEDAREASOLID(#782,#783,#784,2.8); +#786=IFCSTYLEDITEM(#785,(#40),$); +#787=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#785)); +#788=IFCPRODUCTDEFINITIONSHAPE($,$,(#787)); +#789=IFCWALL('3zgU70GqTpMKnrvDjPqJnZ',$,'F-North',$,$,#780,#788,$,.STANDARD.); +#790=IFCAXIS2PLACEMENT3D(#260,$,$); +#791=IFCLOCALPLACEMENT(#780,#790); +#792=IFCAXIS2PLACEMENT2D(#48,$); +#793=IFCRECTANGLEPROFILEDEF(.AREA.,$,#792,1.4,0.5); +#794=IFCAXIS2PLACEMENT3D(#1,$,$); +#795=IFCDIRECTION((0.0,0.0,1.0)); +#796=IFCEXTRUDEDAREASOLID(#793,#794,#795,1.3); +#797=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#796)); +#798=IFCPRODUCTDEFINITIONSHAPE($,$,(#797)); +#799=IFCOPENINGELEMENT('0WZrhOah6iFLrcoVGqx4ms',$,'Opening F-North 1',$,$,#791,#798,$,.OPENING.); +#800=IFCRELVOIDSELEMENT('3KqU$zffwZS1Kx$H_h2cGd',$,$,$,#789,#799); +#801=IFCAXIS2PLACEMENT3D(#260,$,$); +#802=IFCLOCALPLACEMENT(#780,#801); +#803=IFCAXIS2PLACEMENT2D(#48,$); +#804=IFCRECTANGLEPROFILEDEF(.AREA.,$,#803,1.4,0.1); +#805=IFCAXIS2PLACEMENT3D(#1,$,$); +#806=IFCDIRECTION((0.0,0.0,1.0)); +#807=IFCEXTRUDEDAREASOLID(#804,#805,#806,1.3); +#808=IFCSTYLEDITEM(#807,(#67),$); +#809=IFCAXIS2PLACEMENT2D(#48,$); +#810=IFCRECTANGLEPROFILEDEF(.AREA.,$,#809,1.2799999999999998,0.04); +#811=IFCAXIS2PLACEMENT3D(#71,$,$); +#812=IFCDIRECTION((0.0,0.0,1.0)); +#813=IFCEXTRUDEDAREASOLID(#810,#811,#812,1.1800000000000002); +#814=IFCSTYLEDITEM(#813,(#77),$); +#815=IFCAXIS2PLACEMENT2D(#48,$); +#816=IFCRECTANGLEPROFILEDEF(.AREA.,$,#815,0.05,0.12); +#817=IFCAXIS2PLACEMENT3D(#1,$,$); +#818=IFCDIRECTION((0.0,0.0,1.0)); +#819=IFCEXTRUDEDAREASOLID(#816,#817,#818,1.3); +#820=IFCSTYLEDITEM(#819,(#67),$); +#821=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#807,#813,#819)); +#822=IFCPRODUCTDEFINITIONSHAPE($,$,(#821)); +#823=IFCWINDOW('2wOOChS6nnhKUrpK6Dq1Gh',$,'Window F-North 1',$,$,#802,#822,$,1.3,1.4,.WINDOW.,.NOTDEFINED.,$); +#824=IFCRELFILLSELEMENT('3U6OjNOYKjtM12uu4t0Wxf',$,$,$,#799,#823); +#825=IFCAXIS2PLACEMENT3D(#296,$,$); +#826=IFCLOCALPLACEMENT(#780,#825); +#827=IFCAXIS2PLACEMENT2D(#48,$); +#828=IFCRECTANGLEPROFILEDEF(.AREA.,$,#827,1.6,0.5); +#829=IFCAXIS2PLACEMENT3D(#1,$,$); +#830=IFCDIRECTION((0.0,0.0,1.0)); +#831=IFCEXTRUDEDAREASOLID(#828,#829,#830,1.3); +#832=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#831)); +#833=IFCPRODUCTDEFINITIONSHAPE($,$,(#832)); +#834=IFCOPENINGELEMENT('2BM$FRnGUjT0nR8MOFUwgr',$,'Opening F-North 2',$,$,#826,#833,$,.OPENING.); +#835=IFCRELVOIDSELEMENT('2t_bTVgk_QcDDZyGonp6s4',$,$,$,#789,#834); +#836=IFCAXIS2PLACEMENT3D(#296,$,$); +#837=IFCLOCALPLACEMENT(#780,#836); +#838=IFCAXIS2PLACEMENT2D(#48,$); +#839=IFCRECTANGLEPROFILEDEF(.AREA.,$,#838,1.6,0.1); +#840=IFCAXIS2PLACEMENT3D(#1,$,$); +#841=IFCDIRECTION((0.0,0.0,1.0)); +#842=IFCEXTRUDEDAREASOLID(#839,#840,#841,1.3); +#843=IFCSTYLEDITEM(#842,(#67),$); +#844=IFCAXIS2PLACEMENT2D(#48,$); +#845=IFCRECTANGLEPROFILEDEF(.AREA.,$,#844,1.48,0.04); +#846=IFCAXIS2PLACEMENT3D(#71,$,$); +#847=IFCDIRECTION((0.0,0.0,1.0)); +#848=IFCEXTRUDEDAREASOLID(#845,#846,#847,1.1800000000000002); +#849=IFCSTYLEDITEM(#848,(#77),$); +#850=IFCAXIS2PLACEMENT2D(#48,$); +#851=IFCRECTANGLEPROFILEDEF(.AREA.,$,#850,0.05,0.12); +#852=IFCAXIS2PLACEMENT3D(#1,$,$); +#853=IFCDIRECTION((0.0,0.0,1.0)); +#854=IFCEXTRUDEDAREASOLID(#851,#852,#853,1.3); +#855=IFCSTYLEDITEM(#854,(#67),$); +#856=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#842,#848,#854)); +#857=IFCPRODUCTDEFINITIONSHAPE($,$,(#856)); +#858=IFCWINDOW('1ljo0GQZqFlIj4dXF6QxiE',$,'Window F-North 2',$,$,#837,#857,$,1.3,1.6,.WINDOW.,.NOTDEFINED.,$); +#859=IFCRELFILLSELEMENT('371qgXisk_XHZSavQa9hM$',$,$,$,#834,#858); +#860=IFCAXIS2PLACEMENT3D(#332,$,$); +#861=IFCLOCALPLACEMENT(#780,#860); +#862=IFCAXIS2PLACEMENT2D(#48,$); +#863=IFCRECTANGLEPROFILEDEF(.AREA.,$,#862,1.2,0.5); +#864=IFCAXIS2PLACEMENT3D(#1,$,$); +#865=IFCDIRECTION((0.0,0.0,1.0)); +#866=IFCEXTRUDEDAREASOLID(#863,#864,#865,1.3); +#867=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#866)); +#868=IFCPRODUCTDEFINITIONSHAPE($,$,(#867)); +#869=IFCOPENINGELEMENT('3y2jAgq7jtBgZm_SAx8LgS',$,'Opening F-North 3',$,$,#861,#868,$,.OPENING.); +#870=IFCRELVOIDSELEMENT('0gkGeOwDFEXZehxMk1jwOQ',$,$,$,#789,#869); +#871=IFCAXIS2PLACEMENT3D(#332,$,$); +#872=IFCLOCALPLACEMENT(#780,#871); +#873=IFCAXIS2PLACEMENT2D(#48,$); +#874=IFCRECTANGLEPROFILEDEF(.AREA.,$,#873,1.2,0.1); +#875=IFCAXIS2PLACEMENT3D(#1,$,$); +#876=IFCDIRECTION((0.0,0.0,1.0)); +#877=IFCEXTRUDEDAREASOLID(#874,#875,#876,1.3); +#878=IFCSTYLEDITEM(#877,(#67),$); +#879=IFCAXIS2PLACEMENT2D(#48,$); +#880=IFCRECTANGLEPROFILEDEF(.AREA.,$,#879,1.08,0.04); +#881=IFCAXIS2PLACEMENT3D(#71,$,$); +#882=IFCDIRECTION((0.0,0.0,1.0)); +#883=IFCEXTRUDEDAREASOLID(#880,#881,#882,1.1800000000000002); +#884=IFCSTYLEDITEM(#883,(#77),$); +#885=IFCAXIS2PLACEMENT2D(#48,$); +#886=IFCRECTANGLEPROFILEDEF(.AREA.,$,#885,0.05,0.12); +#887=IFCAXIS2PLACEMENT3D(#1,$,$); +#888=IFCDIRECTION((0.0,0.0,1.0)); +#889=IFCEXTRUDEDAREASOLID(#886,#887,#888,1.3); +#890=IFCSTYLEDITEM(#889,(#67),$); +#891=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#877,#883,#889)); +#892=IFCPRODUCTDEFINITIONSHAPE($,$,(#891)); +#893=IFCWINDOW('0dO3MxU3q2fqdzvRsS0QhI',$,'Window F-North 3',$,$,#872,#892,$,1.3,1.2,.WINDOW.,.NOTDEFINED.,$); +#894=IFCRELFILLSELEMENT('3AukMzNDA5myYT7ws$MPel',$,$,$,#869,#893); +#895=IFCDIRECTION((0.0,0.0,1.0)); +#896=IFCDIRECTION((0.0,-1.0,0.0)); +#897=IFCAXIS2PLACEMENT3D(#368,#895,#896); +#898=IFCLOCALPLACEMENT(#22,#897); +#899=IFCAXIS2PLACEMENT2D(#164,$); +#900=IFCRECTANGLEPROFILEDEF(.AREA.,$,#899,8.0,0.3); +#901=IFCAXIS2PLACEMENT3D(#1,$,$); +#902=IFCDIRECTION((0.0,0.0,1.0)); +#903=IFCEXTRUDEDAREASOLID(#900,#901,#902,2.8); +#904=IFCSTYLEDITEM(#903,(#40),$); +#905=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#903)); +#906=IFCPRODUCTDEFINITIONSHAPE($,$,(#905)); +#907=IFCWALL('3RKLb7TIZW5$F3WSTt6aUT',$,'F-West',$,$,#898,#906,$,.STANDARD.); +#908=IFCAXIS2PLACEMENT3D(#382,$,$); +#909=IFCLOCALPLACEMENT(#898,#908); +#910=IFCAXIS2PLACEMENT2D(#48,$); +#911=IFCRECTANGLEPROFILEDEF(.AREA.,$,#910,1.4,0.5); +#912=IFCAXIS2PLACEMENT3D(#1,$,$); +#913=IFCDIRECTION((0.0,0.0,1.0)); +#914=IFCEXTRUDEDAREASOLID(#911,#912,#913,1.3); +#915=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#914)); +#916=IFCPRODUCTDEFINITIONSHAPE($,$,(#915)); +#917=IFCOPENINGELEMENT('2hD0dFWUVDlBvttDgrqeNj',$,'Opening F-West 1',$,$,#909,#916,$,.OPENING.); +#918=IFCRELVOIDSELEMENT('1qkV45hHoA2KsX2TkNDyWj',$,$,$,#907,#917); +#919=IFCAXIS2PLACEMENT3D(#382,$,$); +#920=IFCLOCALPLACEMENT(#898,#919); +#921=IFCAXIS2PLACEMENT2D(#48,$); +#922=IFCRECTANGLEPROFILEDEF(.AREA.,$,#921,1.4,0.1); +#923=IFCAXIS2PLACEMENT3D(#1,$,$); +#924=IFCDIRECTION((0.0,0.0,1.0)); +#925=IFCEXTRUDEDAREASOLID(#922,#923,#924,1.3); +#926=IFCSTYLEDITEM(#925,(#67),$); +#927=IFCAXIS2PLACEMENT2D(#48,$); +#928=IFCRECTANGLEPROFILEDEF(.AREA.,$,#927,1.2799999999999998,0.04); +#929=IFCAXIS2PLACEMENT3D(#71,$,$); +#930=IFCDIRECTION((0.0,0.0,1.0)); +#931=IFCEXTRUDEDAREASOLID(#928,#929,#930,1.1800000000000002); +#932=IFCSTYLEDITEM(#931,(#77),$); +#933=IFCAXIS2PLACEMENT2D(#48,$); +#934=IFCRECTANGLEPROFILEDEF(.AREA.,$,#933,0.05,0.12); +#935=IFCAXIS2PLACEMENT3D(#1,$,$); +#936=IFCDIRECTION((0.0,0.0,1.0)); +#937=IFCEXTRUDEDAREASOLID(#934,#935,#936,1.3); +#938=IFCSTYLEDITEM(#937,(#67),$); +#939=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#925,#931,#937)); +#940=IFCPRODUCTDEFINITIONSHAPE($,$,(#939)); +#941=IFCWINDOW('0jFVZnl4$Tgb5qecjO$9JC',$,'Window F-West 1',$,$,#920,#940,$,1.3,1.4,.WINDOW.,.NOTDEFINED.,$); +#942=IFCRELFILLSELEMENT('3l_P2i$TyMZZqei1lgnmpZ',$,$,$,#917,#941); +#943=IFCAXIS2PLACEMENT3D(#418,$,$); +#944=IFCLOCALPLACEMENT(#898,#943); +#945=IFCAXIS2PLACEMENT2D(#48,$); +#946=IFCRECTANGLEPROFILEDEF(.AREA.,$,#945,1.4,0.5); +#947=IFCAXIS2PLACEMENT3D(#1,$,$); +#948=IFCDIRECTION((0.0,0.0,1.0)); +#949=IFCEXTRUDEDAREASOLID(#946,#947,#948,1.3); +#950=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#949)); +#951=IFCPRODUCTDEFINITIONSHAPE($,$,(#950)); +#952=IFCOPENINGELEMENT('0kawJDw0PEeeccgkLgP$M4',$,'Opening F-West 2',$,$,#944,#951,$,.OPENING.); +#953=IFCRELVOIDSELEMENT('1AVhqB0s0HSb1L74eOpLcr',$,$,$,#907,#952); +#954=IFCAXIS2PLACEMENT3D(#418,$,$); +#955=IFCLOCALPLACEMENT(#898,#954); +#956=IFCAXIS2PLACEMENT2D(#48,$); +#957=IFCRECTANGLEPROFILEDEF(.AREA.,$,#956,1.4,0.1); +#958=IFCAXIS2PLACEMENT3D(#1,$,$); +#959=IFCDIRECTION((0.0,0.0,1.0)); +#960=IFCEXTRUDEDAREASOLID(#957,#958,#959,1.3); +#961=IFCSTYLEDITEM(#960,(#67),$); +#962=IFCAXIS2PLACEMENT2D(#48,$); +#963=IFCRECTANGLEPROFILEDEF(.AREA.,$,#962,1.2799999999999998,0.04); +#964=IFCAXIS2PLACEMENT3D(#71,$,$); +#965=IFCDIRECTION((0.0,0.0,1.0)); +#966=IFCEXTRUDEDAREASOLID(#963,#964,#965,1.1800000000000002); +#967=IFCSTYLEDITEM(#966,(#77),$); +#968=IFCAXIS2PLACEMENT2D(#48,$); +#969=IFCRECTANGLEPROFILEDEF(.AREA.,$,#968,0.05,0.12); +#970=IFCAXIS2PLACEMENT3D(#1,$,$); +#971=IFCDIRECTION((0.0,0.0,1.0)); +#972=IFCEXTRUDEDAREASOLID(#969,#970,#971,1.3); +#973=IFCSTYLEDITEM(#972,(#67),$); +#974=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#960,#966,#972)); +#975=IFCPRODUCTDEFINITIONSHAPE($,$,(#974)); +#976=IFCWINDOW('0Tftab_icdnHrieaUXNTy6',$,'Window F-West 2',$,$,#955,#975,$,1.3,1.4,.WINDOW.,.NOTDEFINED.,$); +#977=IFCRELFILLSELEMENT('387Uk216V_6AQDuTnSrlTs',$,$,$,#952,#976); +#978=IFCDIRECTION((0.0,0.0,1.0)); +#979=IFCDIRECTION((0.0,1.0,0.0)); +#980=IFCAXIS2PLACEMENT3D(#454,#978,#979); +#981=IFCLOCALPLACEMENT(#22,#980); +#982=IFCAXIS2PLACEMENT2D(#459,$); +#983=IFCRECTANGLEPROFILEDEF(.AREA.,$,#982,7.699999999999999,0.15); +#984=IFCAXIS2PLACEMENT3D(#1,$,$); +#985=IFCDIRECTION((0.0,0.0,1.0)); +#986=IFCEXTRUDEDAREASOLID(#983,#984,#985,2.8); +#987=IFCSTYLEDITEM(#986,(#40),$); +#988=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#986)); +#989=IFCPRODUCTDEFINITIONSHAPE($,$,(#988)); +#990=IFCWALL('1cqN1nF4rR22Cfk9xtU0Ed',$,'F-Partition A',$,$,#981,#989,$,.STANDARD.); +#991=IFCAXIS2PLACEMENT3D(#469,$,$); +#992=IFCLOCALPLACEMENT(#981,#991); +#993=IFCAXIS2PLACEMENT2D(#48,$); +#994=IFCRECTANGLEPROFILEDEF(.AREA.,$,#993,0.9,0.35); +#995=IFCAXIS2PLACEMENT3D(#1,$,$); +#996=IFCDIRECTION((0.0,0.0,1.0)); +#997=IFCEXTRUDEDAREASOLID(#994,#995,#996,2.05); +#998=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#997)); +#999=IFCPRODUCTDEFINITIONSHAPE($,$,(#998)); +#1000=IFCOPENINGELEMENT('1A7Jx9Rc_bCKL8WXfiMKGo',$,'Opening F-Partition A 1',$,$,#992,#999,$,.OPENING.); +#1001=IFCRELVOIDSELEMENT('33SN7gElzYmGXOKqISIqCA',$,$,$,#990,#1000); +#1002=IFCAXIS2PLACEMENT3D(#469,$,$); +#1003=IFCLOCALPLACEMENT(#981,#1002); +#1004=IFCAXIS2PLACEMENT2D(#48,$); +#1005=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1004,0.8,0.06); +#1006=IFCAXIS2PLACEMENT3D(#1,$,$); +#1007=IFCDIRECTION((0.0,0.0,1.0)); +#1008=IFCEXTRUDEDAREASOLID(#1005,#1006,#1007,1.9999999999999998); +#1009=IFCSTYLEDITEM(#1008,(#146),$); +#1010=IFCAXIS2PLACEMENT2D(#48,$); +#1011=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1010,0.08,0.14); +#1012=IFCAXIS2PLACEMENT3D(#491,$,$); +#1013=IFCDIRECTION((0.0,0.0,1.0)); +#1014=IFCEXTRUDEDAREASOLID(#1011,#1012,#1013,0.08); +#1015=IFCSTYLEDITEM(#1014,(#67),$); +#1016=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1008,#1014)); +#1017=IFCPRODUCTDEFINITIONSHAPE($,$,(#1016)); +#1018=IFCDOOR('0fef_ZKXAjXWfcIzwdMFtE',$,'Door F-Partition A 1',$,$,#1003,#1017,$,2.05,0.9,.DOOR.,.NOTDEFINED.,$); +#1019=IFCRELFILLSELEMENT('24avWnKV6QfEdOyOlVrIVb',$,$,$,#1000,#1018); +#1020=IFCAXIS2PLACEMENT3D(#500,$,$); +#1021=IFCLOCALPLACEMENT(#981,#1020); +#1022=IFCAXIS2PLACEMENT2D(#48,$); +#1023=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1022,0.9,0.35); +#1024=IFCAXIS2PLACEMENT3D(#1,$,$); +#1025=IFCDIRECTION((0.0,0.0,1.0)); +#1026=IFCEXTRUDEDAREASOLID(#1023,#1024,#1025,2.05); +#1027=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1026)); +#1028=IFCPRODUCTDEFINITIONSHAPE($,$,(#1027)); +#1029=IFCOPENINGELEMENT('2Ia3e1oW2LLwQLqE9RhVwf',$,'Opening F-Partition A 2',$,$,#1021,#1028,$,.OPENING.); +#1030=IFCRELVOIDSELEMENT('3ifvMT1yUoC7ZDWDbtOB8v',$,$,$,#990,#1029); +#1031=IFCAXIS2PLACEMENT3D(#500,$,$); +#1032=IFCLOCALPLACEMENT(#981,#1031); +#1033=IFCAXIS2PLACEMENT2D(#48,$); +#1034=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1033,0.8,0.06); +#1035=IFCAXIS2PLACEMENT3D(#1,$,$); +#1036=IFCDIRECTION((0.0,0.0,1.0)); +#1037=IFCEXTRUDEDAREASOLID(#1034,#1035,#1036,1.9999999999999998); +#1038=IFCSTYLEDITEM(#1037,(#146),$); +#1039=IFCAXIS2PLACEMENT2D(#48,$); +#1040=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1039,0.08,0.14); +#1041=IFCAXIS2PLACEMENT3D(#491,$,$); +#1042=IFCDIRECTION((0.0,0.0,1.0)); +#1043=IFCEXTRUDEDAREASOLID(#1040,#1041,#1042,0.08); +#1044=IFCSTYLEDITEM(#1043,(#67),$); +#1045=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1037,#1043)); +#1046=IFCPRODUCTDEFINITIONSHAPE($,$,(#1045)); +#1047=IFCDOOR('3dTXGyheoV$V3_9RdivWVr',$,'Door F-Partition A 2',$,$,#1032,#1046,$,2.05,0.9,.DOOR.,.NOTDEFINED.,$); +#1048=IFCRELFILLSELEMENT('0Cov0_Zpt9Rs9u4dv7fsf2',$,$,$,#1029,#1047); +#1049=IFCDIRECTION((0.0,0.0,1.0)); +#1050=IFCDIRECTION((1.0,0.0,0.0)); +#1051=IFCAXIS2PLACEMENT3D(#530,#1049,#1050); +#1052=IFCLOCALPLACEMENT(#22,#1051); +#1053=IFCAXIS2PLACEMENT2D(#535,$); +#1054=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1053,5.85,0.15); +#1055=IFCAXIS2PLACEMENT3D(#1,$,$); +#1056=IFCDIRECTION((0.0,0.0,1.0)); +#1057=IFCEXTRUDEDAREASOLID(#1054,#1055,#1056,2.8); +#1058=IFCSTYLEDITEM(#1057,(#40),$); +#1059=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1057)); +#1060=IFCPRODUCTDEFINITIONSHAPE($,$,(#1059)); +#1061=IFCWALL('1hq$nFp8YYtC3UTELD_Uv7',$,'F-Partition B',$,$,#1052,#1060,$,.STANDARD.); +#1062=IFCAXIS2PLACEMENT3D(#545,$,$); +#1063=IFCLOCALPLACEMENT(#1052,#1062); +#1064=IFCAXIS2PLACEMENT2D(#48,$); +#1065=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1064,0.9,0.35); +#1066=IFCAXIS2PLACEMENT3D(#1,$,$); +#1067=IFCDIRECTION((0.0,0.0,1.0)); +#1068=IFCEXTRUDEDAREASOLID(#1065,#1066,#1067,2.05); +#1069=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1068)); +#1070=IFCPRODUCTDEFINITIONSHAPE($,$,(#1069)); +#1071=IFCOPENINGELEMENT('2c83NhPnPXLIbej7lXvPXw',$,'Opening F-Partition B 1',$,$,#1063,#1070,$,.OPENING.); +#1072=IFCRELVOIDSELEMENT('0W72NcfsqpExiWuS17yp5l',$,$,$,#1061,#1071); +#1073=IFCAXIS2PLACEMENT3D(#545,$,$); +#1074=IFCLOCALPLACEMENT(#1052,#1073); +#1075=IFCAXIS2PLACEMENT2D(#48,$); +#1076=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1075,0.8,0.06); +#1077=IFCAXIS2PLACEMENT3D(#1,$,$); +#1078=IFCDIRECTION((0.0,0.0,1.0)); +#1079=IFCEXTRUDEDAREASOLID(#1076,#1077,#1078,1.9999999999999998); +#1080=IFCSTYLEDITEM(#1079,(#146),$); +#1081=IFCAXIS2PLACEMENT2D(#48,$); +#1082=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1081,0.08,0.14); +#1083=IFCAXIS2PLACEMENT3D(#491,$,$); +#1084=IFCDIRECTION((0.0,0.0,1.0)); +#1085=IFCEXTRUDEDAREASOLID(#1082,#1083,#1084,0.08); +#1086=IFCSTYLEDITEM(#1085,(#67),$); +#1087=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1079,#1085)); +#1088=IFCPRODUCTDEFINITIONSHAPE($,$,(#1087)); +#1089=IFCDOOR('1dNPk$DGVG$_TfbF1v2zh9',$,'Door F-Partition B 1',$,$,#1074,#1088,$,2.05,0.9,.DOOR.,.NOTDEFINED.,$); +#1090=IFCRELFILLSELEMENT('1H$h4uimJ_fnSqpROw3nhT',$,$,$,#1071,#1089); +#1091=IFCCARTESIANPOINT((5.0,4.0)); +#1092=IFCAXIS2PLACEMENT2D(#1091,$); +#1093=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1092,10.3,8.3); +#1094=IFCCARTESIANPOINT((0.0,0.0,-0.2)); +#1095=IFCAXIS2PLACEMENT3D(#1094,$,$); +#1096=IFCDIRECTION((0.0,0.0,1.0)); +#1097=IFCEXTRUDEDAREASOLID(#1093,#1095,#1096,0.2); +#1098=IFCCOLOURRGB($,0.62,0.62,0.6); +#1099=IFCSURFACESTYLERENDERING(#1098,0.0,$,$,$,$,$,$,.FLAT.); +#1100=IFCSURFACESTYLE('Concrete',.BOTH.,(#1099)); +#1101=IFCSTYLEDITEM(#1097,(#1100),$); +#1102=IFCAXIS2PLACEMENT3D(#1,$,$); +#1103=IFCLOCALPLACEMENT(#18,#1102); +#1104=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1097)); +#1105=IFCPRODUCTDEFINITIONSHAPE($,$,(#1104)); +#1106=IFCSLAB('3uW8_5KuqrHFDQqcV_sUwS',$,'Ground slab',$,$,#1103,#1105,$,.BASESLAB.); +#1107=IFCCARTESIANPOINT((-0.15,-0.15)); +#1108=IFCCARTESIANPOINT((10.15,-0.15)); +#1109=IFCCARTESIANPOINT((10.15,8.15)); +#1110=IFCCARTESIANPOINT((-0.15,8.15)); +#1111=IFCPOLYLINE((#1107,#1108,#1109,#1110,#1107)); +#1112=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#1111); +#1113=IFCCARTESIANPOINT((0.0,0.0,2.8)); +#1114=IFCAXIS2PLACEMENT3D(#1113,$,$); +#1115=IFCDIRECTION((0.0,0.0,1.0)); +#1116=IFCEXTRUDEDAREASOLID(#1112,#1114,#1115,0.2); +#1117=IFCSTYLEDITEM(#1116,(#1100),$); +#1118=IFCAXIS2PLACEMENT3D(#1,$,$); +#1119=IFCLOCALPLACEMENT(#18,#1118); +#1120=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1116)); +#1121=IFCPRODUCTDEFINITIONSHAPE($,$,(#1120)); +#1122=IFCSLAB('1JroyyVJjHzn1vqdj4iASO',$,'Floor slab',$,$,#1119,#1121,$,.FLOOR.); +#1123=IFCCARTESIANPOINT((8.2,-0.8)); +#1124=IFCAXIS2PLACEMENT2D(#1123,$); +#1125=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1124,4.0,1.4); +#1126=IFCAXIS2PLACEMENT3D(#1113,$,$); +#1127=IFCDIRECTION((0.0,0.0,1.0)); +#1128=IFCEXTRUDEDAREASOLID(#1125,#1126,#1127,0.15); +#1129=IFCSTYLEDITEM(#1128,(#1100),$); +#1130=IFCAXIS2PLACEMENT3D(#1,$,$); +#1131=IFCLOCALPLACEMENT(#18,#1130); +#1132=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1128)); +#1133=IFCPRODUCTDEFINITIONSHAPE($,$,(#1132)); +#1134=IFCSLAB('0_KD9qIz8ZVasBE2JIVZf7',$,'Porch roof',$,$,#1131,#1133,$,.ROOF.); +#1135=IFCAXIS2PLACEMENT2D(#48,$); +#1136=IFCCIRCLEPROFILEDEF(.AREA.,$,#1135,0.15); +#1137=IFCAXIS2PLACEMENT3D(#1,$,$); +#1138=IFCDIRECTION((0.0,0.0,1.0)); +#1139=IFCEXTRUDEDAREASOLID(#1136,#1137,#1138,2.8); +#1140=IFCCOLOURRGB($,0.9,0.9,0.9); +#1141=IFCSURFACESTYLERENDERING(#1140,0.0,$,$,$,$,$,$,.FLAT.); +#1142=IFCSURFACESTYLE('Column',.BOTH.,(#1141)); +#1143=IFCSTYLEDITEM(#1139,(#1142),$); +#1144=IFCCARTESIANPOINT((6.4,-1.3,0.0)); +#1145=IFCAXIS2PLACEMENT3D(#1144,$,$); +#1146=IFCLOCALPLACEMENT(#18,#1145); +#1147=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1139)); +#1148=IFCPRODUCTDEFINITIONSHAPE($,$,(#1147)); +#1149=IFCCOLUMN('0jBmRafpDIHsHe86WbER21',$,'Porch column 1',$,$,#1146,#1148,$,.COLUMN.); +#1150=IFCAXIS2PLACEMENT2D(#48,$); +#1151=IFCCIRCLEPROFILEDEF(.AREA.,$,#1150,0.15); +#1152=IFCAXIS2PLACEMENT3D(#1,$,$); +#1153=IFCDIRECTION((0.0,0.0,1.0)); +#1154=IFCEXTRUDEDAREASOLID(#1151,#1152,#1153,2.8); +#1155=IFCSTYLEDITEM(#1154,(#1142),$); +#1156=IFCCARTESIANPOINT((10.0,-1.3,0.0)); +#1157=IFCAXIS2PLACEMENT3D(#1156,$,$); +#1158=IFCLOCALPLACEMENT(#18,#1157); +#1159=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1154)); +#1160=IFCPRODUCTDEFINITIONSHAPE($,$,(#1159)); +#1161=IFCCOLUMN('0oREbHPhoml9VoeBJ0D39C',$,'Porch column 2',$,$,#1158,#1160,$,.COLUMN.); +#1162=IFCCARTESIANPOINT((-0.6,0.0)); +#1163=IFCCARTESIANPOINT((8.6,0.0)); +#1164=IFCCARTESIANPOINT((4.0,2.7)); +#1165=IFCPOLYLINE((#1162,#1163,#1164,#1162)); +#1166=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#1165); +#1167=IFCDIRECTION((1.0,0.0,0.0)); +#1168=IFCDIRECTION((0.0,1.0,0.0)); +#1169=IFCAXIS2PLACEMENT3D(#1,#1167,#1168); +#1170=IFCDIRECTION((0.0,0.0,1.0)); +#1171=IFCEXTRUDEDAREASOLID(#1166,#1169,#1170,11.2); +#1172=IFCCOLOURRGB($,0.7,0.28,0.2); +#1173=IFCSURFACESTYLERENDERING(#1172,0.0,$,$,$,$,$,$,.FLAT.); +#1174=IFCSURFACESTYLE('Tiles',.BOTH.,(#1173)); +#1175=IFCSTYLEDITEM(#1171,(#1174),$); +#1176=IFCCARTESIANPOINT((-0.6,0.0,3.0)); +#1177=IFCAXIS2PLACEMENT3D(#1176,$,$); +#1178=IFCLOCALPLACEMENT(#22,#1177); +#1179=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1171)); +#1180=IFCPRODUCTDEFINITIONSHAPE($,$,(#1179)); +#1181=IFCROOF('1BQq88wgrCSySlkWj6H5xP',$,'Gable roof',$,$,#1178,#1180,$,.GABLE_ROOF.); +#1182=IFCCARTESIANPOINT((0.0,0.14)); +#1183=IFCAXIS2PLACEMENT2D(#1182,$); +#1184=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1183,1.0,0.28); +#1185=IFCAXIS2PLACEMENT3D(#1,$,$); +#1186=IFCDIRECTION((0.0,0.0,1.0)); +#1187=IFCEXTRUDEDAREASOLID(#1184,#1185,#1186,0.25); +#1188=IFCCOLOURRGB($,0.6,0.42,0.25); +#1189=IFCSURFACESTYLERENDERING(#1188,0.0,$,$,$,$,$,$,.FLAT.); +#1190=IFCSURFACESTYLE('Wood',.BOTH.,(#1189)); +#1191=IFCSTYLEDITEM(#1187,(#1190),$); +#1192=IFCCARTESIANPOINT((0.0,0.42000000000000004)); +#1193=IFCAXIS2PLACEMENT2D(#1192,$); +#1194=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1193,1.0,0.28); +#1195=IFCAXIS2PLACEMENT3D(#1,$,$); +#1196=IFCDIRECTION((0.0,0.0,1.0)); +#1197=IFCEXTRUDEDAREASOLID(#1194,#1195,#1196,0.5); +#1198=IFCSTYLEDITEM(#1197,(#1190),$); +#1199=IFCCARTESIANPOINT((0.0,0.7000000000000001)); +#1200=IFCAXIS2PLACEMENT2D(#1199,$); +#1201=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1200,1.0,0.28); +#1202=IFCAXIS2PLACEMENT3D(#1,$,$); +#1203=IFCDIRECTION((0.0,0.0,1.0)); +#1204=IFCEXTRUDEDAREASOLID(#1201,#1202,#1203,0.75); +#1205=IFCSTYLEDITEM(#1204,(#1190),$); +#1206=IFCCARTESIANPOINT((0.0,0.9800000000000001)); +#1207=IFCAXIS2PLACEMENT2D(#1206,$); +#1208=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1207,1.0,0.28); +#1209=IFCAXIS2PLACEMENT3D(#1,$,$); +#1210=IFCDIRECTION((0.0,0.0,1.0)); +#1211=IFCEXTRUDEDAREASOLID(#1208,#1209,#1210,1.0); +#1212=IFCSTYLEDITEM(#1211,(#1190),$); +#1213=IFCCARTESIANPOINT((0.0,1.2600000000000002)); +#1214=IFCAXIS2PLACEMENT2D(#1213,$); +#1215=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1214,1.0,0.28); +#1216=IFCAXIS2PLACEMENT3D(#1,$,$); +#1217=IFCDIRECTION((0.0,0.0,1.0)); +#1218=IFCEXTRUDEDAREASOLID(#1215,#1216,#1217,1.25); +#1219=IFCSTYLEDITEM(#1218,(#1190),$); +#1220=IFCCARTESIANPOINT((0.0,1.54)); +#1221=IFCAXIS2PLACEMENT2D(#1220,$); +#1222=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1221,1.0,0.28); +#1223=IFCAXIS2PLACEMENT3D(#1,$,$); +#1224=IFCDIRECTION((0.0,0.0,1.0)); +#1225=IFCEXTRUDEDAREASOLID(#1222,#1223,#1224,1.5); +#1226=IFCSTYLEDITEM(#1225,(#1190),$); +#1227=IFCCARTESIANPOINT((0.0,1.8200000000000003)); +#1228=IFCAXIS2PLACEMENT2D(#1227,$); +#1229=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1228,1.0,0.28); +#1230=IFCAXIS2PLACEMENT3D(#1,$,$); +#1231=IFCDIRECTION((0.0,0.0,1.0)); +#1232=IFCEXTRUDEDAREASOLID(#1229,#1230,#1231,1.75); +#1233=IFCSTYLEDITEM(#1232,(#1190),$); +#1234=IFCCARTESIANPOINT((0.0,2.1)); +#1235=IFCAXIS2PLACEMENT2D(#1234,$); +#1236=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1235,1.0,0.28); +#1237=IFCAXIS2PLACEMENT3D(#1,$,$); +#1238=IFCDIRECTION((0.0,0.0,1.0)); +#1239=IFCEXTRUDEDAREASOLID(#1236,#1237,#1238,2.0); +#1240=IFCSTYLEDITEM(#1239,(#1190),$); +#1241=IFCCARTESIANPOINT((0.0,2.3800000000000003)); +#1242=IFCAXIS2PLACEMENT2D(#1241,$); +#1243=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1242,1.0,0.28); +#1244=IFCAXIS2PLACEMENT3D(#1,$,$); +#1245=IFCDIRECTION((0.0,0.0,1.0)); +#1246=IFCEXTRUDEDAREASOLID(#1243,#1244,#1245,2.25); +#1247=IFCSTYLEDITEM(#1246,(#1190),$); +#1248=IFCCARTESIANPOINT((0.0,2.6600000000000006)); +#1249=IFCAXIS2PLACEMENT2D(#1248,$); +#1250=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1249,1.0,0.28); +#1251=IFCAXIS2PLACEMENT3D(#1,$,$); +#1252=IFCDIRECTION((0.0,0.0,1.0)); +#1253=IFCEXTRUDEDAREASOLID(#1250,#1251,#1252,2.5); +#1254=IFCSTYLEDITEM(#1253,(#1190),$); +#1255=IFCCARTESIANPOINT((0.0,2.9400000000000004)); +#1256=IFCAXIS2PLACEMENT2D(#1255,$); +#1257=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1256,1.0,0.28); +#1258=IFCAXIS2PLACEMENT3D(#1,$,$); +#1259=IFCDIRECTION((0.0,0.0,1.0)); +#1260=IFCEXTRUDEDAREASOLID(#1257,#1258,#1259,2.75); +#1261=IFCSTYLEDITEM(#1260,(#1190),$); +#1262=IFCCARTESIANPOINT((6.9,4.3,0.0)); +#1263=IFCAXIS2PLACEMENT3D(#1262,$,$); +#1264=IFCLOCALPLACEMENT(#18,#1263); +#1265=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1187,#1197,#1204,#1211,#1218,#1225,#1232,#1239,#1246,#1253,#1260)); +#1266=IFCPRODUCTDEFINITIONSHAPE($,$,(#1265)); +#1267=IFCSTAIR('2S8F5STq7u_$iRCyHmIalm',$,'Stair',$,$,#1264,#1266,$,.STRAIGHT_RUN_STAIR.); +#1268=IFCAXIS2PLACEMENT2D(#48,$); +#1269=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1268,0.45,0.45); +#1270=IFCCARTESIANPOINT((0.0,0.0,0.45)); +#1271=IFCAXIS2PLACEMENT3D(#1270,$,$); +#1272=IFCDIRECTION((0.0,0.0,1.0)); +#1273=IFCEXTRUDEDAREASOLID(#1269,#1271,#1272,0.05); +#1274=IFCCOLOURRGB($,0.75,0.2,0.2); +#1275=IFCSURFACESTYLERENDERING(#1274,0.0,$,$,$,$,$,$,.FLAT.); +#1276=IFCSURFACESTYLE('Chair',.BOTH.,(#1275)); +#1277=IFCSTYLEDITEM(#1273,(#1276),$); +#1278=IFCCARTESIANPOINT((0.0,-0.2)); +#1279=IFCAXIS2PLACEMENT2D(#1278,$); +#1280=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1279,0.45,0.05); +#1281=IFCCARTESIANPOINT((0.0,0.0,0.5)); +#1282=IFCAXIS2PLACEMENT3D(#1281,$,$); +#1283=IFCDIRECTION((0.0,0.0,1.0)); +#1284=IFCEXTRUDEDAREASOLID(#1280,#1282,#1283,0.5); +#1285=IFCSTYLEDITEM(#1284,(#1276),$); +#1286=IFCCARTESIANPOINT((-0.19,-0.19)); +#1287=IFCAXIS2PLACEMENT2D(#1286,$); +#1288=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1287,0.05,0.05); +#1289=IFCAXIS2PLACEMENT3D(#1,$,$); +#1290=IFCDIRECTION((0.0,0.0,1.0)); +#1291=IFCEXTRUDEDAREASOLID(#1288,#1289,#1290,0.45); +#1292=IFCCOLOURRGB($,0.3,0.2,0.15); +#1293=IFCSURFACESTYLERENDERING(#1292,0.0,$,$,$,$,$,$,.FLAT.); +#1294=IFCSURFACESTYLE('Chair legs',.BOTH.,(#1293)); +#1295=IFCSTYLEDITEM(#1291,(#1294),$); +#1296=IFCCARTESIANPOINT((-0.19,0.19)); +#1297=IFCAXIS2PLACEMENT2D(#1296,$); +#1298=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1297,0.05,0.05); +#1299=IFCAXIS2PLACEMENT3D(#1,$,$); +#1300=IFCDIRECTION((0.0,0.0,1.0)); +#1301=IFCEXTRUDEDAREASOLID(#1298,#1299,#1300,0.45); +#1302=IFCSTYLEDITEM(#1301,(#1294),$); +#1303=IFCCARTESIANPOINT((0.19,-0.19)); +#1304=IFCAXIS2PLACEMENT2D(#1303,$); +#1305=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1304,0.05,0.05); +#1306=IFCAXIS2PLACEMENT3D(#1,$,$); +#1307=IFCDIRECTION((0.0,0.0,1.0)); +#1308=IFCEXTRUDEDAREASOLID(#1305,#1306,#1307,0.45); +#1309=IFCSTYLEDITEM(#1308,(#1294),$); +#1310=IFCCARTESIANPOINT((0.19,0.19)); +#1311=IFCAXIS2PLACEMENT2D(#1310,$); +#1312=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1311,0.05,0.05); +#1313=IFCAXIS2PLACEMENT3D(#1,$,$); +#1314=IFCDIRECTION((0.0,0.0,1.0)); +#1315=IFCEXTRUDEDAREASOLID(#1312,#1313,#1314,0.45); +#1316=IFCSTYLEDITEM(#1315,(#1294),$); +#1317=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1273,#1284,#1291,#1301,#1308,#1315)); +#1318=IFCAXIS2PLACEMENT3D(#1,$,$); +#1319=IFCREPRESENTATIONMAP(#1318,#1317); +#1320=IFCAXIS2PLACEMENT2D(#48,$); +#1321=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1320,1.6,0.9); +#1322=IFCCARTESIANPOINT((0.0,0.0,0.72)); +#1323=IFCAXIS2PLACEMENT3D(#1322,$,$); +#1324=IFCDIRECTION((0.0,0.0,1.0)); +#1325=IFCEXTRUDEDAREASOLID(#1321,#1323,#1324,0.06); +#1326=IFCSTYLEDITEM(#1325,(#1190),$); +#1327=IFCCARTESIANPOINT((-0.72,-0.36)); +#1328=IFCAXIS2PLACEMENT2D(#1327,$); +#1329=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1328,0.07,0.07); +#1330=IFCAXIS2PLACEMENT3D(#1,$,$); +#1331=IFCDIRECTION((0.0,0.0,1.0)); +#1332=IFCEXTRUDEDAREASOLID(#1329,#1330,#1331,0.72); +#1333=IFCSTYLEDITEM(#1332,(#1190),$); +#1334=IFCCARTESIANPOINT((-0.72,0.36)); +#1335=IFCAXIS2PLACEMENT2D(#1334,$); +#1336=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1335,0.07,0.07); +#1337=IFCAXIS2PLACEMENT3D(#1,$,$); +#1338=IFCDIRECTION((0.0,0.0,1.0)); +#1339=IFCEXTRUDEDAREASOLID(#1336,#1337,#1338,0.72); +#1340=IFCSTYLEDITEM(#1339,(#1190),$); +#1341=IFCCARTESIANPOINT((0.72,-0.36)); +#1342=IFCAXIS2PLACEMENT2D(#1341,$); +#1343=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1342,0.07,0.07); +#1344=IFCAXIS2PLACEMENT3D(#1,$,$); +#1345=IFCDIRECTION((0.0,0.0,1.0)); +#1346=IFCEXTRUDEDAREASOLID(#1343,#1344,#1345,0.72); +#1347=IFCSTYLEDITEM(#1346,(#1190),$); +#1348=IFCCARTESIANPOINT((0.72,0.36)); +#1349=IFCAXIS2PLACEMENT2D(#1348,$); +#1350=IFCRECTANGLEPROFILEDEF(.AREA.,$,#1349,0.07,0.07); +#1351=IFCAXIS2PLACEMENT3D(#1,$,$); +#1352=IFCDIRECTION((0.0,0.0,1.0)); +#1353=IFCEXTRUDEDAREASOLID(#1350,#1351,#1352,0.72); +#1354=IFCSTYLEDITEM(#1353,(#1190),$); +#1355=IFCSHAPEREPRESENTATION(#4,'Body','SweptSolid',(#1325,#1332,#1339,#1346,#1353)); +#1356=IFCAXIS2PLACEMENT3D(#1,$,$); +#1357=IFCREPRESENTATIONMAP(#1356,#1355); +#1358=IFCCARTESIANPOINT((3.0,6.0,0.0)); +#1359=IFCAXIS2PLACEMENT3D(#1358,$,$); +#1360=IFCLOCALPLACEMENT(#18,#1359); +#1361=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#1,1.0,$); +#1362=IFCMAPPEDITEM(#1357,#1361); +#1363=IFCSHAPEREPRESENTATION(#4,'Body','MappedRepresentation',(#1362)); +#1364=IFCPRODUCTDEFINITIONSHAPE($,$,(#1363)); +#1365=IFCFURNISHINGELEMENT('23emMOrFixFBz6b1LSbkTz',$,'Table',$,$,#1360,#1364,$); +#1366=IFCCARTESIANPOINT((2.4,6.0,0.0)); +#1367=IFCDIRECTION((0.0,0.0,1.0)); +#1368=IFCDIRECTION((6.123233995736766e-17,1.0,0.0)); +#1369=IFCAXIS2PLACEMENT3D(#1366,#1367,#1368); +#1370=IFCLOCALPLACEMENT(#18,#1369); +#1371=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#1,1.0,$); +#1372=IFCMAPPEDITEM(#1319,#1371); +#1373=IFCSHAPEREPRESENTATION(#4,'Body','MappedRepresentation',(#1372)); +#1374=IFCPRODUCTDEFINITIONSHAPE($,$,(#1373)); +#1375=IFCFURNISHINGELEMENT('3G$SrKgftfRZnuLGl2A24u',$,'Chair 1',$,$,#1370,#1374,$); +#1376=IFCCARTESIANPOINT((3.6,6.0,0.0)); +#1377=IFCDIRECTION((0.0,0.0,1.0)); +#1378=IFCDIRECTION((6.123233995736766e-17,-1.0,0.0)); +#1379=IFCAXIS2PLACEMENT3D(#1376,#1377,#1378); +#1380=IFCLOCALPLACEMENT(#18,#1379); +#1381=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#1,1.0,$); +#1382=IFCMAPPEDITEM(#1319,#1381); +#1383=IFCSHAPEREPRESENTATION(#4,'Body','MappedRepresentation',(#1382)); +#1384=IFCPRODUCTDEFINITIONSHAPE($,$,(#1383)); +#1385=IFCFURNISHINGELEMENT('3eSe6_fq7qr1vguEGIUDoM',$,'Chair 2',$,$,#1380,#1384,$); +#1386=IFCCARTESIANPOINT((3.0,5.2,0.0)); +#1387=IFCDIRECTION((0.0,0.0,1.0)); +#1388=IFCDIRECTION((-1.0,1.2246467991473532e-16,0.0)); +#1389=IFCAXIS2PLACEMENT3D(#1386,#1387,#1388); +#1390=IFCLOCALPLACEMENT(#18,#1389); +#1391=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#1,1.0,$); +#1392=IFCMAPPEDITEM(#1319,#1391); +#1393=IFCSHAPEREPRESENTATION(#4,'Body','MappedRepresentation',(#1392)); +#1394=IFCPRODUCTDEFINITIONSHAPE($,$,(#1393)); +#1395=IFCFURNISHINGELEMENT('0d$KI4yAjD7xYvVgT8q4zG',$,'Chair 3',$,$,#1390,#1394,$); +#1396=IFCCARTESIANPOINT((3.0,6.8,0.0)); +#1397=IFCDIRECTION((0.0,0.0,1.0)); +#1398=IFCDIRECTION((1.0,0.0,0.0)); +#1399=IFCAXIS2PLACEMENT3D(#1396,#1397,#1398); +#1400=IFCLOCALPLACEMENT(#18,#1399); +#1401=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#1,1.0,$); +#1402=IFCMAPPEDITEM(#1319,#1401); +#1403=IFCSHAPEREPRESENTATION(#4,'Body','MappedRepresentation',(#1402)); +#1404=IFCPRODUCTDEFINITIONSHAPE($,$,(#1403)); +#1405=IFCFURNISHINGELEMENT('0KudeHLcXeHkPVKDQ4sVh1',$,'Chair 4',$,$,#1400,#1404,$); +#1406=IFCAXIS2PLACEMENT3D(#1,$,$); +#1407=IFCLOCALPLACEMENT(#18,#1406); +#1408=IFCSPACE('2KNKWx9yNmqWh_dBhsSwYr',$,'Living room',$,$,#1407,$,$,.ELEMENT.,.INTERNAL.,$); +#1409=IFCAXIS2PLACEMENT3D(#1,$,$); +#1410=IFCLOCALPLACEMENT(#18,#1409); +#1411=IFCSPACE('0f2Avw7pTSvALFiOeiSXIX',$,'Kitchen',$,$,#1410,$,$,.ELEMENT.,.INTERNAL.,$); +#1412=IFCAXIS2PLACEMENT3D(#1,$,$); +#1413=IFCLOCALPLACEMENT(#18,#1412); +#1414=IFCSPACE('0EOHNmkQ4Z8RvOIEJDWAaL',$,'Hall',$,$,#1413,$,$,.ELEMENT.,.INTERNAL.,$); +#1415=IFCAXIS2PLACEMENT3D(#1,$,$); +#1416=IFCLOCALPLACEMENT(#22,#1415); +#1417=IFCSPACE('1x4u81T7gJE8bMsUhdF3CW',$,'Bedroom',$,$,#1416,$,$,.ELEMENT.,.INTERNAL.,$); +#1418=IFCAXIS2PLACEMENT3D(#1,$,$); +#1419=IFCLOCALPLACEMENT(#22,#1418); +#1420=IFCSPACE('1ayJFSQ8Utto_pkXiX9oEX',$,'Bath',$,$,#1419,$,$,.ELEMENT.,.INTERNAL.,$); +#1421=IFCRELAGGREGATES('2szUB7fVbu2CEN4ckI58jC',$,$,$,#19,(#1408,#1411,#1414)); +#1422=IFCRELAGGREGATES('1yGO1uoLb4mNBKRmU$T6EQ',$,$,$,#23,(#1417,#1420)); +#1423=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$); +#1424=IFCPROPERTYSINGLEVALUE('FireRating',$,IFCLABEL('REI 60'),$); +#1425=IFCPROPERTYSET('0RRZV4WWZWebmvRwxlAQ_q',$,'Pset_WallCommon',$,(#1423,#1424)); +#1426=IFCRELDEFINESBYPROPERTIES('1YEys40ud$n1qd689ytAX4',$,$,$,(#44,#173,#259,#381),#1425); +#1427=IFCRELASSOCIATESMATERIAL('068JfRdA0RlTPo3hAQRmU6',$,$,$,(#44,#173,#259,#381,#468,#544,#587,#706,#789,#907,#990,#1061),#27); +#1428=IFCRELCONTAINEDINSPATIALSTRUCTURE('3WgMEO8b_SdrznzovE6AH2',$,$,$,(#44,#87,#123,#157,#173,#208,#244,#259,#294,#330,#366,#381,#416,#452,#468,#498,#528,#544,#573,#1106,#1122,#1134,#1149,#1161,#1267,#1365,#1375,#1385,#1395,#1405),#19); +#1429=IFCRELCONTAINEDINSPATIALSTRUCTURE('2YZ2VvhXdDGsswuOVwGmcq',$,$,$,(#587,#621,#656,#692,#706,#740,#775,#789,#823,#858,#893,#907,#941,#976,#990,#1018,#1047,#1061,#1089,#1181),#23); +ENDSEC; +END-ISO-10303-21; diff --git a/crates/exav-viewer/demo/examples/files/images/bounce.gif b/crates/exav-viewer/demo/examples/files/images/bounce.gif new file mode 100644 index 00000000..30fd84be Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/bounce.gif differ diff --git a/crates/exav-viewer/demo/examples/files/images/bounce.jxl b/crates/exav-viewer/demo/examples/files/images/bounce.jxl new file mode 100644 index 00000000..0bbdca14 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/bounce.jxl differ diff --git a/crates/exav-viewer/demo/examples/files/images/bounce.png b/crates/exav-viewer/demo/examples/files/images/bounce.png new file mode 100644 index 00000000..01c991ab Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/bounce.png differ diff --git a/crates/exav-viewer/demo/examples/files/images/bounce.webp b/crates/exav-viewer/demo/examples/files/images/bounce.webp new file mode 100644 index 00000000..b5ece6eb Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/bounce.webp differ diff --git a/crates/exav-viewer/demo/examples/files/images/fax-g3-1d.tif b/crates/exav-viewer/demo/examples/files/images/fax-g3-1d.tif new file mode 100644 index 00000000..49466f81 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/fax-g3-1d.tif differ diff --git a/crates/exav-viewer/demo/examples/files/images/fax-g4-3pages.tif b/crates/exav-viewer/demo/examples/files/images/fax-g4-3pages.tif new file mode 100644 index 00000000..7dedd655 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/fax-g4-3pages.tif differ diff --git a/crates/exav-viewer/demo/examples/files/images/fax-g4.tif b/crates/exav-viewer/demo/examples/files/images/fax-g4.tif new file mode 100644 index 00000000..6a78d9e4 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/fax-g4.tif differ diff --git a/crates/exav-viewer/demo/examples/files/images/gradient-grey16.png b/crates/exav-viewer/demo/examples/files/images/gradient-grey16.png new file mode 100644 index 00000000..98a80f4e Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/gradient-grey16.png differ diff --git a/crates/exav-viewer/demo/examples/files/images/gradient-grey16.tif b/crates/exav-viewer/demo/examples/files/images/gradient-grey16.tif new file mode 100644 index 00000000..a2813949 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/gradient-grey16.tif differ diff --git a/crates/exav-viewer/demo/examples/files/images/icon-legacy.ico b/crates/exav-viewer/demo/examples/files/images/icon-legacy.ico new file mode 100644 index 00000000..896bbce5 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/icon-legacy.ico differ diff --git a/crates/exav-viewer/demo/examples/files/images/icon.ico b/crates/exav-viewer/demo/examples/files/images/icon.ico new file mode 100644 index 00000000..9501b503 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/icon.ico differ diff --git a/crates/exav-viewer/demo/examples/files/images/page-generic.jb2 b/crates/exav-viewer/demo/examples/files/images/page-generic.jb2 new file mode 100644 index 00000000..1fc9b92f Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/page-generic.jb2 differ diff --git a/crates/exav-viewer/demo/examples/files/images/page-symbol.jb2 b/crates/exav-viewer/demo/examples/files/images/page-symbol.jb2 new file mode 100644 index 00000000..37998881 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/page-symbol.jb2 differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-1bit.bmp b/crates/exav-viewer/demo/examples/files/images/scene-1bit.bmp new file mode 100644 index 00000000..0043fc2d Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-1bit.bmp differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-24.bmp b/crates/exav-viewer/demo/examples/files/images/scene-24.bmp new file mode 100644 index 00000000..0ea356df Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-24.bmp differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-32.bmp b/crates/exav-viewer/demo/examples/files/images/scene-32.bmp new file mode 100644 index 00000000..02109d35 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-32.bmp differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-8.bmp b/crates/exav-viewer/demo/examples/files/images/scene-8.bmp new file mode 100644 index 00000000..78da7617 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-8.bmp differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-alpha.jp2 b/crates/exav-viewer/demo/examples/files/images/scene-alpha.jp2 new file mode 100644 index 00000000..f2144a68 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-alpha.jp2 differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-alpha.jxl b/crates/exav-viewer/demo/examples/files/images/scene-alpha.jxl new file mode 100644 index 00000000..2742bd2d Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-alpha.jxl differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-alpha.pam b/crates/exav-viewer/demo/examples/files/images/scene-alpha.pam new file mode 100644 index 00000000..66f2ce5a Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-alpha.pam differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-alpha.png b/crates/exav-viewer/demo/examples/files/images/scene-alpha.png new file mode 100644 index 00000000..d009fdfd Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-alpha.png differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-alpha.qoi b/crates/exav-viewer/demo/examples/files/images/scene-alpha.qoi new file mode 100644 index 00000000..ed184913 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-alpha.qoi differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-alpha.webp b/crates/exav-viewer/demo/examples/files/images/scene-alpha.webp new file mode 100644 index 00000000..3aa27ad5 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-alpha.webp differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-ascii.ppm b/crates/exav-viewer/demo/examples/files/images/scene-ascii.ppm new file mode 100644 index 00000000..3e0f6c0c --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/images/scene-ascii.ppm @@ -0,0 +1,40 @@ +P3 +# an ASCII pixmap +48 36 +255 +29 31 92 30 32 93 30 31 92 30 31 92 30 31 92 29 31 92 29 31 92 29 31 92 29 31 92 32 34 95 30 32 93 29 31 92 30 31 92 29 31 92 29 31 92 29 31 92 30 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 30 32 92 29 31 92 30 32 93 30 32 93 30 32 93 30 32 93 29 31 92 29 31 92 29 31 92 30 32 93 30 32 93 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 29 31 92 +42 35 93 43 36 94 42 35 94 42 35 93 42 35 93 42 35 93 42 35 93 42 35 93 42 35 93 42 35 93 42 35 94 43 36 94 42 35 93 42 35 94 43 36 94 42 35 93 42 35 93 43 36 95 43 37 95 42 35 93 42 35 93 42 35 93 42 35 93 42 35 93 42 35 93 42 35 94 42 35 93 42 36 94 43 36 94 43 37 94 42 36 94 42 35 94 42 35 94 42 35 93 42 35 94 43 36 94 42 36 94 42 35 93 43 36 94 42 35 94 42 35 93 42 35 93 42 35 93 42 35 93 42 35 93 42 35 93 42 35 93 42 35 93 +55 40 95 56 40 96 55 39 95 56 40 96 56 40 96 55 39 95 55 39 95 55 39 95 55 39 95 55 39 95 55 39 95 56 40 96 55 40 95 55 39 95 55 39 95 55 39 95 55 39 95 56 40 95 56 40 95 55 39 95 55 39 95 55 39 95 55 39 95 56 40 95 55 39 95 55 39 95 55 39 95 56 40 96 57 41 96 55 39 94 55 40 95 56 41 96 55 39 95 55 40 95 56 41 96 56 41 96 56 40 95 55 39 95 56 40 95 55 39 95 55 39 95 55 39 95 55 39 95 55 39 95 55 39 95 55 39 95 55 39 95 55 39 95 +68 44 96 68 44 97 68 44 96 69 45 97 71 46 99 68 43 96 68 43 96 68 43 96 68 43 96 68 43 96 68 43 96 69 44 97 68 44 97 69 45 97 68 44 96 68 43 96 68 43 96 68 44 96 70 45 97 68 43 96 68 43 96 68 44 96 68 43 96 69 45 97 69 44 97 69 44 97 68 43 96 68 43 96 68 44 96 68 43 96 68 43 96 68 43 96 68 44 97 68 44 96 68 43 96 69 45 97 68 44 96 68 43 96 68 43 96 68 44 96 68 43 96 68 44 96 68 44 96 68 43 96 68 43 96 68 44 96 68 43 96 68 43 96 +81 47 97 81 47 97 81 47 97 81 48 98 81 48 98 81 47 97 81 48 98 81 47 97 81 47 97 81 48 98 81 48 98 81 47 97 81 47 97 81 47 97 81 47 97 80 47 97 82 48 98 81 47 97 81 48 98 82 49 99 82 49 99 81 47 97 81 47 97 81 47 97 81 48 98 82 48 98 81 48 98 81 47 97 81 48 97 81 48 98 81 48 98 81 47 97 82 48 98 81 48 98 81 47 97 80 47 97 81 47 97 81 48 98 81 48 98 81 47 97 81 47 97 81 48 98 81 48 98 81 48 98 81 48 98 81 47 97 81 48 98 81 48 98 +94 51 99 94 52 99 94 52 99 94 51 99 94 51 99 94 51 99 94 51 99 94 52 99 94 52 99 94 51 99 94 51 99 94 51 99 94 52 99 95 52 100 95 52 100 95 53 100 94 52 99 94 51 99 94 51 99 94 51 99 94 52 99 94 51 99 94 51 99 95 53 100 94 52 99 93 51 99 95 53 100 94 52 99 94 52 99 96 54 101 94 52 99 94 52 99 94 51 99 94 51 99 94 51 99 94 52 100 94 52 99 94 51 99 94 52 99 94 51 99 94 52 99 95 52 100 94 51 99 94 52 99 94 52 99 94 51 99 94 51 99 94 52 99 +107 56 100 107 56 100 107 55 100 107 56 100 109 58 103 107 56 101 106 55 100 107 56 101 107 56 101 107 56 101 107 56 101 106 55 100 107 56 100 107 56 100 108 57 101 108 57 102 107 56 100 106 55 100 107 56 100 107 56 100 107 56 100 107 56 100 107 56 100 107 56 101 107 56 100 107 56 100 106 55 100 106 55 100 107 56 100 107 56 100 107 56 100 107 56 100 107 56 100 107 56 100 107 56 100 107 56 101 107 56 101 107 56 100 107 56 100 107 56 100 107 56 100 107 56 101 107 56 100 106 56 100 106 56 100 107 56 100 108 57 101 107 56 100 +120 61 102 119 60 101 119 59 101 120 60 102 120 61 103 119 60 101 119 59 101 120 61 103 120 60 102 120 60 102 119 60 101 119 60 101 119 59 101 120 60 102 120 61 102 119 59 101 120 60 102 120 60 102 120 60 102 120 60 102 119 60 101 119 60 101 119 60 101 119 59 101 119 60 102 119 60 101 120 60 102 121 61 102 121 60 102 121 61 102 122 62 102 123 63 103 123 61 102 122 61 102 122 61 102 122 61 102 121 60 102 120 60 102 120 60 102 120 60 102 119 59 101 119 60 101 119 60 101 119 60 102 119 60 101 120 60 102 120 60 102 119 60 101 +132 64 103 132 64 103 132 64 103 132 63 103 132 63 103 132 64 103 133 65 104 132 63 103 133 64 103 133 65 104 132 63 103 133 64 103 133 64 103 132 64 103 132 64 103 132 64 103 133 64 103 133 64 103 132 63 103 132 64 103 132 64 103 132 64 103 132 64 103 132 64 103 132 64 103 132 64 103 134 65 103 135 65 104 137 67 105 138 67 104 138 67 104 139 68 104 140 68 104 141 69 105 140 68 105 138 68 104 136 66 103 135 65 103 134 64 103 133 65 103 133 65 104 132 64 103 132 64 103 132 64 103 132 64 103 133 64 104 133 64 103 132 64 103 +145 68 104 146 68 105 146 69 105 146 69 105 145 68 104 145 68 104 145 68 104 146 69 105 146 69 105 145 67 104 145 68 104 146 68 105 146 68 105 145 68 104 145 68 105 145 68 104 145 68 104 145 67 104 147 70 106 146 69 105 145 68 104 145 68 104 145 68 104 145 68 104 146 68 105 147 69 105 148 69 105 150 71 105 153 73 106 155 74 107 157 75 107 157 75 106 158 75 106 158 75 107 157 74 106 155 74 106 153 73 106 151 71 105 149 70 105 148 71 106 147 69 105 145 68 104 145 68 104 145 68 104 145 68 104 145 68 104 145 68 104 145 68 104 +159 72 106 158 72 106 158 72 106 159 74 107 158 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 105 159 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 105 159 72 106 159 73 106 160 73 106 162 75 107 165 76 107 168 79 108 170 79 107 174 81 109 175 82 108 177 83 109 177 83 109 177 83 109 176 82 108 174 81 108 172 80 109 169 78 108 166 76 107 163 74 107 160 73 106 159 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 106 158 72 106 +171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 172 77 107 174 78 107 177 80 108 181 82 109 185 84 109 189 86 110 193 88 111 196 90 111 198 92 111 199 92 112 198 92 111 196 91 111 194 89 111 190 87 110 186 85 109 182 82 109 178 80 108 175 78 108 173 77 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 171 76 107 +184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 185 80 108 187 81 109 190 83 109 194 85 110 198 88 111 203 91 112 209 94 112 214 97 113 218 99 114 220 101 114 222 102 115 221 102 115 218 100 114 215 98 114 210 95 113 205 92 112 200 89 111 195 86 110 191 83 109 187 82 109 185 80 109 184 80 108 184 80 108 184 80 108 184 80 108 184 80 108 +196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 195 85 110 196 85 110 196 85 110 195 85 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 197 86 110 200 88 110 204 90 111 209 93 112 215 96 113 221 100 114 227 104 115 234 107 116 240 111 117 244 113 117 246 114 118 245 114 117 241 111 117 235 108 116 229 104 115 222 101 114 216 97 113 210 93 112 205 90 111 201 88 110 198 86 110 196 85 110 196 85 110 196 85 110 196 85 110 196 85 110 +203 96 110 203 96 110 203 96 110 203 96 110 203 96 110 203 96 110 203 96 110 203 96 110 203 96 110 205 96 108 203 97 110 204 97 109 205 96 108 203 96 110 202 96 111 203 96 110 203 96 110 203 96 110 203 96 110 203 96 110 203 96 110 203 97 110 205 98 110 209 100 110 213 103 111 219 106 112 226 110 113 233 114 115 242 119 116 250 124 117 254 128 119 255 132 120 255 134 122 255 133 121 255 129 119 251 125 118 244 120 116 235 116 115 228 111 114 220 107 113 214 103 112 210 100 111 206 98 110 203 97 111 203 96 110 203 96 110 203 96 110 203 96 110 +209 109 110 209 109 110 209 109 110 209 109 110 209 109 110 209 109 110 209 108 110 210 108 110 211 109 109 194 111 126 181 115 141 183 114 140 196 110 123 210 108 108 211 109 108 209 108 110 208 108 110 208 108 110 209 108 110 209 109 110 209 109 110 210 109 110 213 111 110 217 113 111 222 116 112 228 120 113 236 124 114 246 129 116 254 135 117 255 141 119 255 147 121 255 149 118 255 148 115 254 147 116 255 148 121 255 143 119 254 136 118 247 130 116 237 125 115 233 121 112 227 118 110 218 114 111 216 112 108 213 110 108 209 108 110 209 108 111 209 108 111 209 108 110 +216 120 110 216 120 110 216 120 110 216 120 110 216 120 110 216 120 110 216 120 110 217 121 109 189 117 134 178 120 153 179 119 150 177 118 150 180 121 153 188 118 138 206 118 117 216 120 109 220 122 108 221 123 109 217 121 109 215 120 110 215 120 111 217 121 110 220 123 110 224 125 111 230 129 112 237 133 113 246 137 115 253 143 116 254 149 118 255 157 121 255 162 119 255 185 143 254 217 187 255 203 161 255 167 121 252 158 122 255 154 117 255 148 114 253 142 113 227 130 119 194 120 135 183 118 142 189 118 135 205 119 119 218 121 109 219 122 107 219 122 108 217 121 109 +222 132 110 223 132 110 223 132 110 223 132 110 222 132 110 223 133 110 224 133 108 191 121 135 178 120 153 159 105 133 124 82 109 119 79 106 139 92 119 169 113 145 184 121 153 190 122 143 189 119 129 195 119 117 217 130 115 228 136 111 226 134 107 227 135 108 228 135 110 231 137 111 237 140 112 245 145 113 255 151 114 255 160 115 255 167 117 255 172 120 254 180 124 255 243 213 255 255 243 235 218 206 192 129 143 184 122 144 198 129 140 211 134 133 201 127 137 177 117 147 173 116 149 173 115 147 175 117 150 179 119 151 183 118 138 196 122 130 205 125 123 216 130 114 +229 144 110 228 143 110 227 143 110 227 142 110 227 143 108 229 144 107 194 126 136 178 119 153 144 95 122 102 69 94 101 68 94 107 71 97 108 71 98 80 57 79 59 47 66 103 71 98 118 79 110 106 71 101 119 79 104 174 113 128 207 132 132 216 136 118 231 146 112 241 151 109 248 156 110 254 159 112 248 158 118 235 155 124 238 162 125 252 181 120 255 198 123 253 244 209 218 190 191 173 111 143 171 112 146 173 115 146 171 113 147 170 113 148 172 115 148 176 117 147 176 116 145 187 123 154 174 115 144 161 108 136 187 124 157 175 117 151 175 117 150 180 119 147 +238 158 110 242 160 111 244 162 110 245 163 110 248 164 116 205 134 141 171 114 149 134 89 115 100 67 93 105 71 96 110 73 100 92 63 88 43 38 56 10 20 34 4 16 29 12 20 34 52 42 60 99 67 91 104 70 96 103 69 98 156 104 137 181 120 152 181 119 142 193 127 137 199 131 135 194 128 137 179 119 144 173 115 148 173 115 147 183 124 143 198 140 138 191 135 150 178 114 148 185 122 152 187 124 154 187 123 153 187 123 153 187 123 152 183 120 150 179 118 148 185 122 152 139 95 121 43 39 56 20 26 40 107 76 99 173 114 143 175 115 145 170 112 143 +130 87 99 139 94 100 167 113 106 197 135 107 139 99 95 120 82 114 126 83 109 105 70 96 112 74 101 113 75 102 82 58 81 42 37 54 23 27 42 14 22 36 17 24 38 14 22 36 27 29 45 47 40 58 93 64 88 107 71 97 106 71 96 156 103 132 181 120 152 177 117 151 177 117 152 182 120 155 183 121 153 181 119 149 184 122 152 184 121 154 177 115 152 168 109 142 153 102 130 129 87 113 108 73 98 104 70 95 103 69 95 83 58 81 57 45 64 66 47 69 62 49 68 13 21 35 7 18 31 11 20 34 6 17 31 41 37 54 89 62 86 111 74 100 +104 70 99 102 68 99 76 54 83 29 28 51 1 14 31 9 19 31 56 44 64 90 63 86 47 39 58 74 54 76 108 72 99 84 59 82 28 29 45 13 22 35 15 22 37 13 21 35 40 36 53 95 65 90 107 72 98 108 72 99 108 72 98 109 73 99 139 92 120 159 105 133 151 100 128 124 83 109 91 62 86 73 51 73 88 60 84 100 68 92 88 60 83 67 47 69 46 33 54 38 29 49 37 28 48 38 28 48 25 24 41 7 17 31 5 17 30 11 19 34 21 23 39 21 25 40 15 23 37 14 22 36 20 25 40 61 47 67 92 63 88 104 70 96 +87 59 82 85 58 81 52 41 59 20 26 39 15 23 36 15 23 37 15 22 36 17 23 37 7 18 31 11 20 34 53 41 60 71 51 73 53 43 62 14 22 36 13 21 35 20 25 40 52 41 60 56 41 62 65 45 67 60 42 64 65 45 67 77 53 76 82 56 80 77 53 76 56 39 61 39 29 49 40 29 49 42 30 51 40 29 49 39 28 49 41 30 50 44 32 52 48 34 55 50 35 56 50 36 57 50 36 57 38 31 50 19 24 39 15 22 36 22 25 40 35 30 48 25 26 42 13 22 36 13 22 36 18 23 38 59 45 65 109 73 99 111 74 100 +40 31 53 41 31 53 37 32 52 19 24 40 13 22 35 14 22 36 25 27 44 24 26 43 14 22 36 19 24 39 34 29 49 44 34 55 27 28 46 15 22 37 14 22 35 16 23 38 28 28 46 43 33 54 41 32 53 42 32 54 41 32 53 39 31 52 40 31 53 40 31 53 43 33 55 46 35 57 45 35 57 45 34 56 45 35 57 46 35 57 45 34 56 45 34 56 44 34 55 44 33 55 44 33 55 44 34 56 42 33 54 21 25 41 12 21 35 26 27 44 42 33 55 24 26 44 14 22 37 14 22 35 20 25 41 37 31 52 58 42 66 85 59 85 +41 36 61 41 36 61 42 37 61 30 31 49 13 21 35 15 22 37 28 28 49 25 27 47 13 22 36 19 24 41 35 32 56 45 38 64 41 37 59 14 22 36 14 22 36 17 23 38 36 33 56 34 32 56 32 31 55 32 30 54 32 31 55 36 33 57 40 35 60 36 33 57 32 31 55 34 32 56 36 33 57 36 33 57 36 33 57 36 33 57 35 33 57 35 33 57 35 32 56 34 32 56 34 32 56 34 32 56 35 33 57 25 27 46 13 21 34 29 29 50 36 33 57 29 29 50 17 23 38 14 22 37 23 26 44 42 37 62 63 49 76 69 53 81 +76 57 85 78 58 85 82 61 89 51 43 65 13 22 36 21 26 42 60 48 73 60 48 74 11 17 28 39 36 56 74 56 84 79 59 87 68 53 79 32 32 51 9 19 32 42 38 58 71 54 81 70 54 81 62 49 75 60 47 73 62 49 75 71 54 81 73 55 83 73 56 84 60 48 74 36 33 57 30 28 52 28 28 51 29 28 52 30 29 52 30 29 53 31 29 53 33 31 54 36 32 56 36 33 57 36 33 57 38 33 58 33 30 52 17 20 32 36 32 57 37 33 57 32 31 54 14 21 36 7 17 29 50 42 65 81 60 88 80 60 88 78 59 87 +73 55 86 71 54 85 72 54 86 67 53 80 12 19 33 29 30 46 77 58 86 80 60 89 45 37 56 70 53 79 80 60 89 77 58 86 78 58 87 34 33 51 9 16 26 46 40 62 81 60 89 78 59 87 80 60 88 80 60 88 78 59 87 82 61 89 110 80 109 122 88 117 123 88 117 114 82 111 90 67 95 77 59 86 91 68 95 96 71 98 85 64 91 66 52 78 44 38 63 33 30 54 29 28 52 29 28 52 29 28 52 28 27 51 25 25 45 30 29 53 29 28 52 38 34 59 55 45 65 41 35 51 73 56 83 75 57 85 75 57 84 75 57 85 +101 74 88 107 79 89 134 98 91 160 117 96 88 67 65 84 62 91 95 70 98 73 55 83 81 60 89 78 58 87 76 58 86 77 58 86 76 58 86 74 56 83 59 46 69 78 58 87 76 58 85 77 58 86 77 58 86 75 57 84 80 60 88 119 85 114 129 92 123 124 89 122 124 89 122 126 90 123 131 93 125 133 94 125 133 94 124 129 92 124 125 90 125 126 89 122 122 88 117 106 78 106 93 69 97 92 68 96 90 67 95 80 61 88 64 51 77 56 45 70 75 58 84 117 84 114 126 90 120 123 88 117 112 81 111 83 62 90 82 61 89 83 62 89 +170 119 97 174 122 95 174 122 96 173 121 95 171 120 96 145 102 114 125 89 123 98 72 100 73 56 83 77 58 86 78 58 86 77 58 86 78 58 87 78 58 87 78 59 88 76 57 85 77 58 85 78 58 86 75 57 85 76 58 88 114 82 115 127 91 123 128 92 117 139 98 113 142 100 112 137 97 115 129 92 118 123 88 120 121 87 120 135 95 117 154 106 113 139 101 121 124 88 120 130 93 123 133 94 125 132 94 124 133 94 124 133 95 124 131 93 123 130 92 123 132 94 124 129 92 122 126 90 120 127 91 121 128 91 121 126 90 120 124 89 119 127 91 117 +161 109 96 163 109 95 162 109 95 162 109 95 164 110 95 163 110 94 136 95 113 126 91 124 101 73 102 73 55 83 76 57 85 76 57 85 74 56 84 77 58 85 82 61 89 83 62 91 77 58 88 73 57 87 82 61 89 119 85 109 145 100 111 154 104 101 164 111 97 173 116 94 177 118 95 181 120 96 182 121 99 177 118 104 183 122 106 207 135 103 225 148 105 191 179 162 152 138 149 123 84 115 123 88 119 125 89 119 122 88 120 121 87 120 123 89 120 126 90 119 125 89 119 125 90 120 126 90 120 126 90 119 125 89 120 124 89 121 124 89 122 128 91 119 +159 103 95 159 103 95 159 103 95 159 103 95 159 103 95 160 103 94 158 103 95 132 92 115 127 91 123 107 77 106 81 61 89 81 60 89 90 67 95 115 83 113 123 89 121 128 91 118 127 88 110 129 88 103 151 99 98 161 104 97 161 104 93 163 105 94 163 105 95 165 106 96 169 108 96 175 112 97 182 116 98 192 122 98 201 127 100 208 130 101 214 138 107 185 181 169 182 194 184 160 151 156 130 92 120 126 90 120 136 95 117 143 98 115 133 93 117 124 89 121 125 90 121 126 90 120 125 90 120 124 90 122 129 91 118 134 93 114 140 95 109 153 100 99 +155 95 95 156 96 95 156 96 96 156 96 96 156 96 96 155 96 96 157 96 95 153 96 97 130 91 117 128 93 125 125 89 119 124 89 118 127 93 124 130 93 121 142 93 108 154 96 98 159 98 96 160 98 95 158 97 95 155 95 95 155 95 95 156 95 95 158 97 95 161 99 95 165 101 96 170 104 97 176 107 98 183 111 99 191 116 101 199 122 103 211 125 101 203 151 131 184 178 168 201 165 146 204 125 106 191 117 105 193 118 102 188 115 99 178 109 98 153 98 106 130 91 118 126 90 121 128 91 118 140 92 108 153 95 97 157 96 94 158 96 93 157 96 94 +151 88 95 149 85 93 147 82 91 148 84 92 148 83 92 149 85 93 147 82 91 149 84 91 145 83 94 127 83 110 122 87 119 123 87 118 129 84 110 145 84 95 150 83 89 149 84 91 148 83 92 147 83 92 147 82 91 150 86 94 151 88 95 152 88 95 154 89 95 157 91 95 160 93 96 165 96 97 171 99 98 177 103 99 184 107 100 192 112 101 199 116 103 207 118 101 209 121 104 208 117 100 203 117 102 197 114 101 186 108 100 178 104 99 173 100 98 169 97 96 161 94 97 151 91 100 152 90 97 155 89 93 152 88 94 151 88 95 150 87 95 151 88 95 +145 78 93 155 93 104 166 109 116 161 102 111 163 104 112 156 94 105 165 107 115 161 102 111 165 106 113 167 111 117 154 107 121 164 116 126 165 106 113 159 98 107 166 111 117 164 106 114 165 108 116 160 101 110 167 110 117 153 89 101 146 79 94 148 81 95 149 82 95 152 83 95 155 85 96 159 88 97 164 91 97 170 94 98 176 98 99 183 101 100 189 105 101 193 107 103 195 108 103 193 108 103 189 105 102 184 102 101 178 98 100 172 95 99 166 91 98 160 88 97 156 86 96 154 84 94 150 82 94 147 81 95 147 80 95 147 80 95 147 80 95 147 80 95 +140 68 92 159 97 112 176 122 131 171 116 126 172 117 127 159 97 113 174 120 129 171 115 125 176 123 131 169 112 123 180 126 132 175 119 128 171 115 126 171 115 125 176 123 131 167 109 121 170 113 124 172 116 126 174 119 129 154 88 107 141 69 93 143 73 96 144 73 95 146 75 95 149 76 96 153 79 96 157 81 97 162 84 98 168 87 99 173 90 100 178 93 100 181 95 101 182 95 101 181 95 101 178 93 100 174 91 100 169 88 99 164 85 98 159 82 97 154 79 96 150 77 96 147 75 95 145 73 95 143 73 95 143 72 95 143 72 95 143 72 95 143 72 95 +138 67 95 136 64 92 134 61 91 135 63 92 135 62 91 135 64 92 134 62 91 135 63 91 135 62 91 134 61 91 138 68 95 135 63 92 134 62 91 139 69 96 134 62 91 135 63 92 135 63 92 135 63 92 134 61 90 136 65 93 138 67 95 137 67 94 139 67 94 140 68 95 142 70 95 146 71 96 150 74 96 154 76 97 158 79 98 162 81 98 165 83 99 168 84 99 169 85 100 169 85 99 166 83 99 163 81 99 159 79 98 155 77 97 150 74 96 146 72 96 143 70 95 141 68 95 139 67 94 138 67 94 137 67 94 137 67 94 137 67 94 137 67 94 diff --git a/crates/exav-viewer/demo/examples/files/images/scene-bc1.dds b/crates/exav-viewer/demo/examples/files/images/scene-bc1.dds new file mode 100644 index 00000000..82a08efc Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-bc1.dds differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-bc3.dds b/crates/exav-viewer/demo/examples/files/images/scene-bc3.dds new file mode 100644 index 00000000..cbf0d71d Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-bc3.dds differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-cmyk-packbits.tif b/crates/exav-viewer/demo/examples/files/images/scene-cmyk-packbits.tif new file mode 100644 index 00000000..c8994cbe Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-cmyk-packbits.tif differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-cmyk.jpg b/crates/exav-viewer/demo/examples/files/images/scene-cmyk.jpg new file mode 100644 index 00000000..7bf40493 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-cmyk.jpg differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-grey.jp2 b/crates/exav-viewer/demo/examples/files/images/scene-grey.jp2 new file mode 100644 index 00000000..ddfcf039 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-grey.jp2 differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-grey.jpg b/crates/exav-viewer/demo/examples/files/images/scene-grey.jpg new file mode 100644 index 00000000..d3cc8ac9 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-grey.jpg differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-grey16.pgm b/crates/exav-viewer/demo/examples/files/images/scene-grey16.pgm new file mode 100644 index 00000000..cb72f392 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/images/scene-grey16.pgm @@ -0,0 +1,4 @@ +P5 +128 96 +65535 +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$((,,$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$))$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&''--**&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&%%--''&&&&&&))&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&))))))))))(())))))))))))))))))))))))))))))))))))(((((())))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))(())))))))((,,))))))))(())))))))))))))))))))))((..++(())))))))))))))))))))))))))))))))))))))))))))++++++..11**++++++++++++++++++++++++++++++++++++++++++++++++**++++++++++**,,--**++++++++++++****++--**++++++++++++++++++++++++++++++++++++++++++++++****++++,,66**++++++++**++++++++++++++++++**++++++++++******++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------..11------------//11--------------1144--22------------------------------------------------0033----....------------------------------11..--------..//--------------------------------------------------00000000000000000000000000000000000000000000000000000000000077//0000000000////000000000000000000////0000000000000000000000000000000000000000000000000022000000//0000000000000000000000330000//7700000000//1133//0000000000000000000000000000000000000000000000002222223333222222222222222222222222222222222222222222222222221122222222222222222222222222222222222222222222222222222222222222222222222222222222222222443322222222222222::3322222222222299332222112222222222222222222222222222222222222222222222222222222222222222444444;;884444444444997744444444444444444444444444444444444444444444444444444444444444444444444433444444444444444444444444444444444444444444444444449977444444444444444444444444444444444444555544444444444444444444444444444444444444444444444444444444444444447777777777777766>>7766==::777777777777777777777777777777777777??77777777::6677777777777777777777??77777777777777777777777766::==667777777777777777776666777777777777777777777777777777777766>>;;6677777777777777777777777777777777777777777777777777777777777777999999::::999999::9999==@@999999999999999999999999999999999999::999999::AA9999999999999999999999>>::999999999999999999999999;;<<99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<;;<<<<<<<<<<<<;;;;<<<<<<;;;;<<<<<<;;<<<<<<<<<<<<<<<<;;;;<<;;@@<>;;<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>CC??>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>BB@@>>>>>>>>>>CC@@==????>>>>>>>>>>>>>>>>>>>>>>AA>>??>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>==DDAA>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>CC>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>AAAAAAAAAAAAAAAAAAAA@@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@@CCAA@@AAAAAAAA@@@@@@GGDD@@AAAAAAAAAAAA@@@@AAAA@@@@AAAA@@@@AAAAAAAAAA@@AA@@@@AAAAAAAAAAAA@@@@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@@@@AA@@AAAAAAAA@@@@AAAAAAAAAAAAAAAAAACCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCBBCCCCCCCCCCCCCCCCDDFFCCCCCCCCCCCCIIFFCCCCCCCCCCDDCCGGCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCDDDDCCCCCCCCCCCCGGEECCCCCCCCCCCCCCCCCCEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEGGHHEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEFFGGEEEEEEEEEEDDHHGGEEEEEEEEEEPPGGIIEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEHHGGDDEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEGGGGGGGGGGGGGGGGGGGGGGGGGGHHGGGGGGGGGGGGGGGGHHGGGGGGGGGGGGGGGGGGGGGGJJMMGGGGMMJJNNHHHHGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGHHGGGGGGGGGGGGHHGGGGGGGGGGGGHHGGIIKKGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGOOLLGGGGGGGGGGGGGGGGGGGGGGHHGGGGGGHHGGGGGGGGHHHHGGGGGGGGGGGGGGGGGGJJJJJJJJJJJJJJJJJJJJJJKKJJJJJJJJJJJJJJJJKKSSJJJJJJJJKKJJJJJJJJJJJJJJJJJJJJJJLLLLMMKKTTIIJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJRRKKJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJKKJJJJJJJJJJJJJJJJJJJJJJJJJJJJKKOOJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJLLLLLLLLLLLLLLLLLLLLLLUUNNRRLLLLLLLLLLLLLLMMLLLLLLLLUUMMLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLMMMMMMMMMMMMMMMMMMMMMMMMMMMMLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLMMLLLLLLLLLLLLLLLLLLLLLLOORRKKLLLLOOOOOOOOOOOOOOOOOOOOPPQQOORROOOOOOOOOOOONNOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOONNOOOOOOOOOOOOOOOOOOOOOOOOQQOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOPPPPPPPPPPPPPPPPOOOOOOOOOOOOOOOOOOOONNNNOONNOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOQQRRNNOOOOXXQQQQQQQQQQQQQQQQPPTTWWPPQQQQQQQQQQQQRRXXQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQPPXXUUPPQQQQQQQQPPQQQQQQQQRRUUQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQRRRRRRRRRRRRRRRRTTUURRSSSSSSSSSSSSSSRRRRRRRRRRRRRRRRQQQQQQQQUUSSPPQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQTTTTSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSTTWWTTSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSTTTTSSSSSSSSSSYYTTSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSUUWWTTTTTTTTUUUUUUVVVVXX[[UUVVVVVVVVVVVVVVUUUUUUUUUUTTTTTTTTTTTTSSVVTTSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSUUVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUUVVVVVVVVVVVVYYUUVVVVVVVVVVVVVVVVVVVVVVUUVVVVVVVVVVUUYYVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVYY\\WWWWXXWWWWYYYYYYYYXXXXYYYYZZZZYYYYYYYYYYYYXXXXWWWWXXWWWWWWWWVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV[[UUVVVVVVVVXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYXXXXXXXXXXYY^^XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYXXYYYYYYZZZZZZ[[cc[[[[\\\\\\]]]]]]]]]]]]bb\\\\]]dd[[[[[[[[[[ZZZZYYYYYYXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYY[[XXXXXXXXXXZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ__aaZZZZZZZZZZZZZZ[[ZZZZZZZZZZ\\\\ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ[[[[[[[[\\[[\\\\\\]]^^^^________````````aaaaaaaaddee``__________^^]]^^\\\\\\[[[[]]]]__]]ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ]]]]]]]]]]\\]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]^^]]]]]]]]]]]]]]]]]]aacc]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]\\\\]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]^^^^__````````hheeaabbcceeccddddddddddddddddddccccccccbbaaaaaa``__````__^^aabb__^^]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]````````__hh````````__``````````````````__ii``````````````````____``````____````````````````````__cckk^^````````````````````````````__``aaaabbccccccddddeeeeeeffkkgggggghhhhhhhhhhhhgghhggggffffeeeeeeddccccccaaaaaa````````````````````````````````````````````ccddbbbbbbccbbbbeehhaabbbbbbbbbbbbbbbbbbbbaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbffbbbbbbbbbbbbbbbbbbbbbbccffbbbbbbbbbbbbbbbbbbbbaabbbbbbbbjjddddddeeffeeffgghhiiiinnjjjjkkllkkllllllllllllkkllkkjjjjiiiiiiggggffffffddjjggccccccbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbffhhddddddddddddddiiggddddddddddddddddddddddddddddddddddddddddddddddddddeeggddddddddddddddddddddddddddddddddddddddddddddddjjeeeeeeeeiigggggggghhppjjjjkkllmmppnnnnooooppppppqqqqppppppoooonnnnmmssookkjjkkllhhggggffffeeeeeeeeeeddddddddddddddddddddddddddddddddffffggffffffffffffiihhffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffggjjgggghhhhhhiijjkkkkllppnnnnooppqqqqrrssssssttuuuuuuuuuuuuttssssssrrqqppppoonnnnnnllkkkkjjiihhhhhhggggggggffffffffffffffffffffffffffffiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiijjjjkkllllllnnooooppqqrrssssttuuvvwwwwxxxxyyyyyyyyyyyyxxxxwwwwvvuuttssssrrppppoooonnllllllkkjjjjiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiillllllllllllllllllkkkkllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllkkllllmmmmmmooooooqqrrrrttuuvvwwwwyyzzzz{{{{||||}}}}~~~~}}}}||||{{{{zzzzyywwwwvvuuttrrqqqqppoooommmmmmllllkkllllllllllllllllllllllllllllnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnooooooppqqqqrrssttuuvvwwyyzz{{||}}~~€€��‚‚‚‚‚‚‚‚‚‚‚‚‚‚����€€~~}}||{{zzxxwwvvuuttssrrqqqqppoooooonnnnnnnnnnnnnnnnnnnnnnnnnnnnnnppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppqqqqrrrrssttuuuuwwxxyyzz{{||}}~~€€€€‚‚ƒƒ„„……††††‡‡‡‡‡‡‡‡‡‡‡‡††††……„„ƒƒ‚‚€€€€~~}}||zzzzyyxxwwuuuuttssrrrrqqqqpppppppppppppppppppppppppppprrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrssssssssttuuuuvvvvxxxxzz{{||}}~~€€��ƒƒ„„……††‡‡‰‰ŠŠŠŠŠŠ‹‹ŒŒŒŒŒŒŒŒ‹‹ŠŠŠŠŠŠ‰‰‡‡††……„„ƒƒ��€€~~}}||{{zzxxxxvvvvuuuuttssssssssrrrrrrrrrrrrrrrrrrrrrruuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuvvvvwwxxxxzzzz||}}~~��‚‚„„……‡‡ˆˆŠŠ‹‹ŒŒ��������‘‘‘‘‘‘‘‘‘‘‘‘��������ŒŒ‹‹ŠŠˆˆ‡‡……„„‚‚��~~}}||zzzzxxxxwwvvvvuuuuuuuuuuuuuuuuuuuuuuuuuuuuwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwxxxxxxxxyyzzzz{{||}}~~€€��ƒƒ„„††ˆˆ‰‰ŠŠŒŒŽŽ��‘‘’’““””••––––————––––••””““’’‘‘��ŽŽŒŒŠŠ‰‰ˆˆ††„„ƒƒ‚‚€€€€~~}}||{{zzzzyyxxxxxxxxwwwwwwwwwwwwwwwwwwwwzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz{{{{||||}}}}~~€€‚‚ƒƒ„„……‡‡ˆˆŠŠ������’’““””––——™™ššœœœœœœ����œœœœœœšš™™——––””““‘‘��ŽŽŒŒŠŠˆˆ‡‡……„„ƒƒ‚‚€€~~}}}}||||{{{{zzzzzzzzzzzzzzzzzzzzzz}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}~~~~€€€€��‚‚ƒƒ„„††‡‡‰‰ŠŠ‹‹����‘‘““••——˜˜ššœœžžŸŸ¡¡¡¡¢¢££££££££¢¢¡¡¡¡ŸŸžžœœšš˜˜––••““‘‘����‹‹ŠŠˆˆ‡‡††„„ƒƒ‚‚��€€€€~~~~}}}}}}}}}}}}}}}}}}}}������������������������������������������������������������������������������������������������������������������‚‚ƒƒƒƒƒƒ„„……††‡‡ˆˆ‰‰‹‹��ŽŽ��’’””––——™™œœžž  ¢¢££¤¤¦¦¦¦§§§§§§§§§§§§¦¦¦¦¤¤££¢¢  žž››™™——––””‘‘��ŽŽ��‹‹‰‰ˆˆ‡‡††……„„ƒƒƒƒƒƒ‚‚����������������������………………………………………………………………………………………………………………………………………………„„…………„„……………………………………………………………………………………………………………………………………„„……††††‡‡ˆˆ‰‰ŠŠ‹‹ŒŒ����‘‘’’••––˜˜ššœœžž¡¡££¥¥§§¨¨©©ªª««««¬¬¬¬¬¬¬¬««««ªª¨¨¨¨¦¦¥¥££¡¡žž��››˜˜––••’’‘‘����ŒŒ‹‹ŠŠ‰‰ˆˆ‡‡††††……„„……………………………………………………ˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆŠŠŠŠˆˆ‰‰ˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆ‰‰‰‰ŠŠ‹‹ŒŒŽŽ����’’““••——™™››��ŸŸ¢¢££¦¦¨¨©©ªª¬¬¬¬®®¯¯°°°°±±±±°°°°¯¯®®­­¬¬ªª©©¨¨¦¦££¡¡ŸŸœœ››™™––••““‘‘����ŽŽŒŒ‹‹ŠŠ‰‰‰‰ˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆ‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹ŠŠŠŠŠŠ‹‹‹‹ŠŠ‹‹ŠŠ‹‹ŒŒ‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹ŒŒ����ŽŽ����‘‘““””••——™™››��ŸŸ¡¡¤¤¦¦¨¨ªª««­­®®°°±±²²³³µµ´´µµµµ´´´´³³²²±±°°®®­­««ªª¨¨¦¦££¡¡ŸŸ��››™™——––””““’’����ŽŽ����ŒŒ‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹����������������������������������������������ŽŽ‹‹ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŒŒŽŽ����ŽŽŽŽ��������������������������������������‘‘’’““““••––——™™››��ŸŸ¡¡££¥¥¨¨ªª¬¬®®¯¯±±²²´´µµ¶¶··¹¹¸¸¸¸¸¸¸¸¹¹··¶¶µµ´´²²±±¯¯®®¬¬ªª¨¨¦¦££¡¡ŸŸ��››šš˜˜––””““’’’’‘‘��������������������������’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’��‹‹ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‹‹��’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’““““””••••––——™™šš››��ŸŸ  ¢¢¥¥§§ªª¬¬®®¯¯±±³³´´¶¶¸¸¹¹»»¼¼»»ÅÅÎÎÎÎÅÅ»»»»»»¹¹¸¸¶¶´´³³±±¯¯®®¬¬ªª§§¥¥¢¢¢¢ŸŸ˜˜��ŒŒ‹‹‹‹����““””””““’’’’’’’’’’’’’’’’’’’’••••••••••••••••••••••••••••••••••••••••––��ŠŠŠŠŠŠŠŠŠŠŠŠŒŒŒŒŒŒ‹‹ŠŠŠŠŠŠŠŠŠŠ��’’––––––••••••••••••••••••••••••••––––————˜˜™™šš››œœžžŸŸ¡¡££¥¥¦¦©©««®®¯¯±±³³µµ¶¶··¹¹»»½½¾¾ÁÁÝÝööúúúúööÝÝÀÀ¿¿¾¾¾¾¼¼¹¹··µµ³³°°°°®®««ªª¨¨ŸŸ��ŠŠ‰‰ŠŠŠŠŠŠŠŠŠŠ‹‹��””——––••••••••••••••••••™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™““ŠŠŠŠŠŠŠŠ‹‹ŒŒ‰‰��||}}††‹‹ŒŒ‹‹ŠŠŠŠŠŠŠŠ��’’••˜˜™™™™™™™™™™™™™™™™™™™™™™™™™™šššššš››œœ��žž  ¡¡¢¢¥¥§§©©ªª¬¬¯¯±±²²³³¶¶¸¸¹¹»»½½¿¿ÀÀÁÁÞÞüüúúùùùùúúüüáá»»««¤¤££¥¥®®´´¶¶¶¶´´³³¯¯§§——‹‹‰‰ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ��••˜˜™™™™™™™™™™™™™™™™œœœœœœœœœœœœœœœœœœœœœœœœœœœœœœœœœœœœ��••ŠŠŠŠŠŠŠŠŒŒ‰‰vv^^UUSSTTXXeeww‰‰��‹‹ŠŠŠŠŠŠŠŠŠŠŒŒ��””˜˜œœžž������œœœœœœœœœœ������ŸŸŸŸ¡¡¢¢££¥¥¦¦¨¨ªª¬¬®®°°³³´´µµ··¹¹ºº¼¼¾¾ÀÀÂÂÃÃÌÌööúúùùùùùùûûøøÆÆ��ˆˆ‡‡ˆˆ‰‰ŠŠ��™™  ŸŸžž““‹‹‰‰ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‰‰ŠŠŒŒ��””——ššœœ������                                    ——ŠŠŠŠŠŠŠŠ��€€``RRRRTTTTTTSSRRRR]]pp‚‚ŒŒ��������‹‹‰‰††}}„„’’œœ  ¡¡¡¡        ¡¡¡¡¢¢££¤¤¥¥§§©©ªª¬¬­­¯¯²²´´µµ··¸¸ºº¼¼½½¾¾ÁÁÃÃÅÅÆÆÖÖúúùùùùùùüüíí¨¨††‰‰ŠŠŠŠŠŠŠŠŠŠ‰‰‰‰ˆˆˆˆˆˆ‰‰ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‰‰ŠŠŠŠ‹‹ŽŽ’’ššŸŸ££££££££££££££££££££££££££££££££¤¤™™ŠŠŠŠŠŠŠŠ��vvWWRRTTTTTTTTTTTTTTTTSSRRYY^^ZZoorrppii__XXTTRRTTZZooŠŠ––››¡¡¤¤¤¤¤¤¤¤¥¥¥¥§§§§©©ªª¬¬®®¯¯±±³³µµ¶¶¸¸¹¹»»½½¾¾ÀÀÁÁÃÃÆÆÇÇÈÈØØúúùùúúýýàà™™††ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‰‰ŠŠŽŽ¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦§§››ŠŠŠŠŠŠŠŠ‹‹rrSSSSTTTTTTTTTTTTTTTTTTVVUU>>""EETTSSSSSSTTTTTTSSRRZZzzŒŒ��’’šš  ¦¦©©ªªªªªª¬¬­­®®±±²²´´¶¶¹¹»»»»»»¹¹ºº½½ÂÂÆÆÈÈÈÈÊÊËËÓÓ÷÷üüûûÌÌ��‡‡ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŒŒ��‹‹��ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‰‰©©©©©©©©©©©©©©©©©©©©©©©©©©ªª««��ŠŠŠŠŠŠ‹‹‹‹jjRRTTTTTTTTTTTTTTTTTTUUWWII''00PPWWTTTTTTTTTTTTSSTTuuŒŒ‰‰‰‰‹‹ŽŽ––ŸŸ§§­­°°²²´´¶¶··¹¹¸¸´´©©��••ŽŽŽŽ��™™¨¨¹¹ÆÆÍÍÑÑÐÐååææ¯¯ˆˆ‰‰ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‹‹����EE66ww��ŒŒŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ­­­­­­­­­­­­­­­­­­­­­­­­­­¯¯ŸŸŠŠŠŠŠŠŒŒ‡‡ffRRTTTTTTTTTTTTTTTTTTWWPP44==TTVVTTTTTTTTTTSSTTuu��ŠŠŠŠŠŠ‰‰‰‰‹‹��——ŸŸŸŸ  ¢¢��””ŒŒˆˆˆˆ‰‰‰‰‰‰‰‰ˆˆ‡‡‰‰’’œœ¬¬©©šš��‡‡ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŽŽ‹‹^^""NNƒƒ��‹‹ŠŠŠŠŠŠŠŠŠŠŠŠŠŠ´´´´³³²²²²±±°°±±±±±±±±±±³³¡¡ŠŠŠŠ‹‹��€€^^RRTTTTTTTTTTTTTTTTVVVV??%%GGWWUUTTTTTTTTSSTTuu��ŠŠŠŠŠŠŠŠŠŠ‰‰‰‰‰‰ˆˆˆˆˆˆˆˆ‰‰ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‰‰ˆˆ‡‡‡‡ˆˆ‰‰ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‹‹‹‹����������‹‹ŒŒ��rr33&&bb����‹‹‹‹‹‹‹‹‹‹‹‹››ššŸŸ¬¬­­µµ¸¸¸¸¶¶µµ´´¶¶§§ŒŒ‹‹��ŠŠssWWRRTTTTTTTTTTTTTTUUWWII''00PPWWTTTTTTTTSSTTuu��ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŒŒ��������������ŽŽ����ˆˆ„„vvqqqqppuuˆˆ��GG77[[ddccbbbbbbbbRRRRUU\\]]ll’’££¯¯¸¸¨¨lluu††rr^^RRTTTTTTTTTTTTTTTTUUNN11::QQUUTTTTTTSSTTuu��‹‹ŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‹‹ŒŒŒŒŒŒŽŽ‹‹��ssjjYYYYYYYYZZXXDDCC::++$$''&&))..!!22OOUUSSRRRRTTTTTTSSSSQQPPQQUU__aa;;88PPUUTTTTTTTTTTVVVVTTTTSS??224455557711556655554433EEUUTTTTTTTTSSSSll‹‹ŒŒŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠŠ‹‹ŒŒŽŽ����ŽŽ��ŒŒ‹‹‹‹ŒŒŒŒŒŒ‰‰‚‚wwoo__NN??//''&&$$$$$$$$$$$$&&$$!!$$==TTTTTTTTTTTTTTTTTTTTWWSS88 BBVVVVTTUUWWJJDDWWUUTTVVWWWWWWZZII!!**QQYYWWWWWWUUTTTTTTTTTTTTTTRRcc��ŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒ��‰‰~~oo[[XX``qq~~„„††ttgg]]PPBB44))%%%%''(((((((((((())((!!$$$$$$!!22FFEEDDCCCCRRTTTTTTTTTTTTTTUUWWDD##++KKXXSS66--NNWWUUTTUUUU99CCVVUUTTTTTTTTTTTTTTTTTTTTRRVVeevv}}||}}ssppccVV==--&&$$$$$$&&--228833222211**&&&&''((((((((((((((((((##&&""33UUXXVVVVTTTTTTTTTTTTTTVVMM,,11!!99SSWWKK$$//RRWWVVUUUUUUUUVVVVVVUUTTSSRRSSSSTTUUQQAA..&&&&''((((((((((''''&&''''''''((((((((((((((((((((((((&& ""IIUUTTTTTTTTTTVVVVWW@@ ""CC>>44555566..337777660077@@774444443399AALLRRUUUUVVVVUUPPCC00&&''(((((((((((((((((((((((((((((((((((((((((((((((((((((())**&&''$$))JJUUTTTTTT1199>>AABBBBHHNN99 99NNVVZZII''LLGG9900))&&&&''''''''&&'')),,3377??9933++''''((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((%%''**(('';;OOWWUUTTTTTTTT''&&&&''(())**""!!&&))''((1188&&''((((((((((((((((((((((''''&&&&&&''''(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((())((!!))(())''##++88IIUUVVUUTT(((((((())&&%%))(($$))(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((())(())$$!!''((22CCPPUU--$$%%%%%%$$####$$ $$$$##$$%%%%%%$$%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%####$$##%%!!   $$$$"""",,CC''$$$$$$$$$$$$##$$!!$$$$$$####''&&$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$%%%% %%%%$$##,,44::AA<<))""""######$$##""##########**99;;--####$$$$$$$$$$$$$$$$$$$$$$######""####$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$##  $$$$$$$$!!##$$33DDDDDDCC@@//223355::::<>CCDDDDDDDDDDDDDDEE((11AA::44,,##%%..>>DDDDDDDD;;++,,--$$--,,--@@DD??::4411,,,,,,,,,,44>>BBDDDDDDCCBB>>77//$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$  $$$$$$$$$$$$$$## ++@@EEDDCCCCCCCCCCBBCCCCCCCCCCCCCCDD44<>$$AA??AACCCC??--))::DDCCCCCCCCCCCCCCEE''11EECCCCCCCCCCCCCCCCCCCCCCCCCCCCCCBBAAEELLUUUUVVSSLLCC::--!!!!""""!!!!""##%%$$%%&&&&####$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$%% %%$$$$$$$$$$$$!!00DDCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCDDEE,,77EECCCCCCDDFFAADDCCCCCCCCCCCCCCEE55 ==DDCCCCCCCCCCCCCCCCCCCCCCCCCCBBMM]]cchhjjjjjjiiiihhggccPP==5555AAHHUUWWYYQQHHAA??55))##""##$$$$$$$$$$$$$$$$$$$$$$$$$$!!""$$$$$$$$$$$$$$ ((LL 5566::CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCBBAA@@<<$$DDCCCCCCCCCC8855AACCCCCCCCCCCCCCCCDD44++-- $$--++::DDCCCCCCCCCCCCCCCCCCCCCCCCBBCCYYjjiihhhhhhhhhhhhhhhhiikkkkjjjjkkkkjjjjjjjjiiaa]]VVMM??55++!!!!""##############$$$$$$$$%%$$$$$$$$$$$$!!""((FFmm$$\\RRDDCCCCCCCCCCCCCCCCCCBBBBBBAAAAAAGGTTbbggllWWIIBBBBBBCCCCCCCCDDEECCCCCCCCCCCCCCCCCCCCDDEEHH)) 66GGEEDDCCCCCCCCCCCCCCCCCCCCCCCCCCTTcchhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhiiiijjiiff``^^QQ==55,,))))))))))))##""""""""########!!&&==SScciiiiWWRRhhccFFBBBBCCCCCCCCCCCCCCHHHHHHQQTTii��‰‰ŠŠ‹‹ŒŒ}}##kkee\\HHBBCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCDD<<44??DDCCCCCCCCCCCCCCCCCCCCCCCCCCBBCC__jjhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhiiiiiijjjjjjddccccccccccbbWWWWQQ7722))))))))22VVkkkkiihhhhjjkkhhhhee\\FFAAAAAAAAAAAAAA€€€€€€……‡‡‡‡††………………††„„__WWhhiijj]]DDBBCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCDDEEDDCCCCCCCCCCCCCCCCCCCCCCCCCCBBNNbbgghhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhiiiiiiiiiiiiiijjjjjjjjhhcccccccchhjjhhhhhhhhhhhhhhhhhhiijjbbVVVVVVVVVVUU__}}„„„„ƒƒƒƒƒƒƒƒƒƒƒƒƒƒ‚‚ƒƒ††€€oogghhiieeXXDDBBCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCBBBBZZjjhhhhhhhhhhggggggggffgggggghhhhhhhhhhhhhhhhhhhhggffeeeegghhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhiiiiiiiihhhhhhhhhhhhhhhhhhhhhhhhhhiijjjjjjjjjjjjkk€€‚‚����������������������‚‚rrhhhhhhiiccIIBBCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCBBII``hhhhhhhhggggiioouuwwxxxxxxrrlljjggffggggggggggffhhttˆˆ}}ppjjeegghhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhooyy~~zzkkhhhhiiff[[DDBBCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCRRiihhgghhjjnntt||ƒƒƒƒ……††††ˆˆˆˆˆˆ††‚‚{{rrkkkkkkrrzz‹‹ŸŸ££¦¦§§©©ªª••jjgghhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhii||}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}nnhhhhhhiihhKKBBCCCCCCCCCCCCCCCCCCCCCCCCCCCCBBBBBBBBBBCCCCCCCCCCCCAAII``ggggkkuuxx||~~€€€€��‚‚ƒƒ…………‡‡‰‰‹‹������ŽŽ��‘‘——››žžžž¡¡££««¶¶¹¹¶¶}}ffgghhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhmmvv{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{zzttiihhhhiigg[[DDBBCCCCCCCCCCCCCCCCCCAABBFFKKMMLLMMKKEEAAAAAABBBBHHXXllnnttzz||{{||||||}}~~€€����ƒƒ„„……‡‡‰‰ŠŠŒŒ��‘‘’’••——˜˜››žž  ¥¥´´¶¶¶¶´´¥¥ssffhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhggiixxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyywwkkhhhhhhhhiiQQCCAACCCCCCCCCCCCBBBBRR__eehhiiiiiihhcc[[UUTT\\ffrrwwxxyyyyyyxxyyyyzzzz{{||}}}}~~€€��‚‚„„††‡‡‰‰‹‹����‘‘““––——šš��ŸŸ¬¬··¶¶¶¶··»»““ggffhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhggggllssvvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwvvoohhhhhhhhii``PPAABBBBBBBBCCGGRRggiiiihhhhhhhhhhiikkllnnssxxxxwwwwwwwwwwwwwwxxxxxxyyzzzz||||}}~~€€��ƒƒ„„††ˆˆŠŠŒŒŽŽ��’’••——™™œœ¡¡³³¶¶¶¶¶¶¶¶··¨¨……eeggggggggffgghhkkkkkkgggghhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhiikklloouuwwwwttttttttttttttttttttttttttttttttttttttrriihhhhhhhhiiggVVMMLLQQZZ``ffiihhhhhhhhhhjjmmppqqssttuuttttttttttttttttuuuuuuuuvvwwwwyyyy{{||}}~~��ƒƒ……††ˆˆŠŠŒŒŽŽ‘‘““––˜˜šš¡¡³³··¶¶¶¶¶¶¶¶ºº¥¥wwnnkkkkooyy��„„‡‡„„ƒƒwwnniihhhhhhhhhhhhhhhhhhhhhhhhhhmmppqqssttttuuttttttrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrllhhhhhhhhhhjjiihhjjiiiihhhhhhhhhhiikkqqssssrrrrrrrrrrrrrrrrrrrrrrrrssrrssttssttuuvvwwxxyyzz||}}~~€€��ƒƒ……‡‡‰‰‹‹����’’””––™™žž³³¸¸¶¶¶¶··¶¶ªª››••’’��������‹‹ˆˆ‡‡„„ƒƒ��}}qqffhhhhhhhhhhhhhhhhhhiinnssssrrrrrrrrrrrrrrrrpppppppppppppppppppppppppppppppppppppppppplliihhhhhhhhhhhhhhhhhhhhhhhhhhiinnooppppppppppppppppppppppppppppppppppqqqqqqrrrrssttuuvvwwxxzz{{}}~~€€‚‚ƒƒ††‡‡‰‰‹‹ŽŽ��’’••——˜˜§§±±´´µµ««��˜˜••““’’����‹‹‰‰‡‡……‚‚��}}}}xxmmjjhhhhhhhhhhhhkkooqqppppppppppppppppppppppnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnmmjjhhhhhhhhhhhhhhhhhhhhhhiillnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnoonnooppppppqqrrssttuuvvxxyyzz||~~€€��ƒƒ††‡‡‰‰‹‹����‘‘““””––™™™™––••””’’��ŽŽ��ŠŠˆˆ††……‚‚€€}}{{zzyywwuuqqmmkkkkkkmmoooooonnnnnnnnnnnnnnnnnnnnnnlllllllllllllllllllllllllllllllllllllllllllllljjiihhhhhhhhhhhhhhhhhhjjllllllllllllllllllllllllllllllllllllllllllllmmmmnnnnooppqqrrrrssuuvvxxyy{{||~~€€‚‚ƒƒ……ˆˆ‰‰‹‹����‘‘‘‘’’’’’’’’‘‘��ŽŽŒŒŠŠ‰‰‡‡……ƒƒ€€}}{{zzxxwwvvttssrrqqppppoonnmmmmllmmlllllllllllllllllllliiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiijjiiiiiihhhhhhggffhhiijjiiiiiiiiiijjjjiiiijjiiiiiiiiiijjiiiiiiiijjjjjjjjjjkkllmmnnooooppqqssttuuwwyyzz||~~��ƒƒ……††ˆˆŠŠ‹‹ŒŒ����ŽŽ����‹‹ŠŠ‰‰‡‡……„„‚‚€€||{{zzxxvvuussrrqqoooonnmmllllkkjjkkjjjjiiiiiiiiiiiiiiiiiihhhhhhhhggeeffffffggeeggffggffhheeeeffggggffeeggeeeeeeggeeddlluuffeegghheeeehhkkffeegghhffggffffeeffeehhhhhhhhhhhhiiiiiijjjjjjllllmmnnooqqrrssuuwwxxzz{{}}��‚‚ƒƒ……††ˆˆ‰‰‰‰‰‰‰‰‰‰ˆˆ‡‡††„„ƒƒ‚‚€€~~||zzyyxxvvttssqqppoonnnnllkkkkjjiiiihhhhhhhhhhhhhhhhhhhhhhhhhhffffffeell€€rrppssnnppsshhttcczzxxuullssvvyynn‚‚zzll~~xx››hh��jjhh~~€€rr„„qqyy¡¡ssiissvvzzrr~~ffeeffffffffffgggggghhhhiijjkkkkmmnnpppprrttttvvxxzz{{||~~€€��‚‚ƒƒ„„……………………„„ƒƒ‚‚��€€}}{{zzxxwwuussssqqppoommllkkjjiiiiiiggggffffffffffffffffffffffffffccccccaaŸŸššœœ  ––{{¥¥šš––‚‚††ww££  ””ªªzz’’ªª••ŸŸ««™™••¨¨‰‰¡¡””››šš––pp¬¬‰‰  ��šš……‹‹  ™™rr››££��££žž……``ccccccccccddeeeeeeffggggiijjkkllmmnnooqqrrttuuvvxxyyzz||}}~~€€€€€€€€€€~~}}{{zzyywwuuttssqqppoommllkkjjiihhhhffffeeeeddccccccccccccccccccccccccccaaaaaaaaªª——‘‘££••��¡¡��……ªªff€€§§™™ŠŠ³³zzžž««™™……šš‚‚››žžyy§§˜˜¦¦——��oo¢¢yy¥¥¤¤››qq˜˜––��§§››{{¬¬˜˜€€^^aaaaaabbbbbbbbcccccceeeeffgghhiijjllmmmmoopprrssttuuwwxxyyzz{{||||||||||||||{{zzyywwvvttssrrqqppnnmmllkkiihhggffeeeeddccccbbbbbbbbaaaaaaaaaaaaaaaaaaaa______^^ssŠŠ{{vvzz{{��}}iiˆˆ\\bb……��}}pp||……‹‹zzqq{{ooƒƒ­­ˆˆnn‚‚ss‹‹llmm°°ˆˆppxx~~ŒŒkk††tt��‡‡{{ff||ˆˆff^^____________````aaaabbcccceeeegghhhhiikkllmmooppqqrrssttuuvvwwwwxxxxxxxxxxwwvvuuttssrrppoonnmmlljjiihhggffeeddddbbbbaaaa``````________________________]]]]]]]][[ZZZZ[[[[[[YY[[\\YY]]\\ZZZZZZ[[[[ZZYY[[[[ZZZZddxxXX[[ZZ[[ZZ[[ffwwWW[[[[ZZYY\\ZZ[[YYZZZZ\\ZZYY\\]]]]]]]]]]]]]]^^^^^^____``aabbbbccddffffhhiijjkkllmmooppppqqrrssssttttttttttssssqqqqppoonnmmkkjjiihhggeeeeddbbbbaa``______^^]]^^]]]]]]]]]]]]]]]]]]]]]]]][[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[YY[[[[[[[[[[[[[[YY[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[\\\\\\]]]]^^__````aabbddddeeggggiijjkkllmmmmooooppppppppppppppoooonnmmllkkkkiihhggffeeddccbbaa``__^^^^]]]]\\\\\\[[[[[[[[[[[[[[[[[[[[[[[[[[ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ[[[[[[[[\\^^^^____aabbbbddddeeffgghhiijjkkllllmmmmmmmmmmmmmmllkkjjiiiihhggffeeddccbbbb``____^^]]\\\\\\[[ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ \ No newline at end of file diff --git a/crates/exav-viewer/demo/examples/files/images/scene-indexed.png b/crates/exav-viewer/demo/examples/files/images/scene-indexed.png new file mode 100644 index 00000000..8000ff63 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-indexed.png differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-jpeg.tif b/crates/exav-viewer/demo/examples/files/images/scene-jpeg.tif new file mode 100644 index 00000000..7d5071b2 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-jpeg.tif differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-lossless.jxl b/crates/exav-viewer/demo/examples/files/images/scene-lossless.jxl new file mode 100644 index 00000000..5b8e86d0 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-lossless.jxl differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-lossless.webp b/crates/exav-viewer/demo/examples/files/images/scene-lossless.webp new file mode 100644 index 00000000..d6b2f4b9 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-lossless.webp differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-lossy.jxl b/crates/exav-viewer/demo/examples/files/images/scene-lossy.jxl new file mode 100644 index 00000000..689ab28d Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-lossy.jxl differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-lzw.tif b/crates/exav-viewer/demo/examples/files/images/scene-lzw.tif new file mode 100644 index 00000000..f24dda63 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-lzw.tif differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-orientation6.jpg b/crates/exav-viewer/demo/examples/files/images/scene-orientation6.jpg new file mode 100644 index 00000000..a4cecc4c Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-orientation6.jpg differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-pages.tif b/crates/exav-viewer/demo/examples/files/images/scene-pages.tif new file mode 100644 index 00000000..ae661825 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-pages.tif differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-progressive.jpg b/crates/exav-viewer/demo/examples/files/images/scene-progressive.jpg new file mode 100644 index 00000000..62537ea9 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-progressive.jpg differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene-rle8.bmp b/crates/exav-viewer/demo/examples/files/images/scene-rle8.bmp new file mode 100644 index 00000000..5be0f4ce Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene-rle8.bmp differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene.ff b/crates/exav-viewer/demo/examples/files/images/scene.ff new file mode 100644 index 00000000..ced27a42 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene.ff differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene.gif b/crates/exav-viewer/demo/examples/files/images/scene.gif new file mode 100644 index 00000000..6258014d Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene.gif differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene.j2k b/crates/exav-viewer/demo/examples/files/images/scene.j2k new file mode 100644 index 00000000..242e061c Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene.j2k differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene.jp2 b/crates/exav-viewer/demo/examples/files/images/scene.jp2 new file mode 100644 index 00000000..641aa672 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene.jp2 differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene.jpg b/crates/exav-viewer/demo/examples/files/images/scene.jpg new file mode 100644 index 00000000..b4d2d4ae Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene.jpg differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene.pbm b/crates/exav-viewer/demo/examples/files/images/scene.pbm new file mode 100644 index 00000000..a18f24ba Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene.pbm differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene.png b/crates/exav-viewer/demo/examples/files/images/scene.png new file mode 100644 index 00000000..faf9cd8b Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene.png differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene.ppm b/crates/exav-viewer/demo/examples/files/images/scene.ppm new file mode 100644 index 00000000..2baa2fec --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/images/scene.ppm @@ -0,0 +1,23 @@ +P6 +# exav example +128 96 +255 +\\\\\\\\\\\\\\\\\\\\\\\\\#a"&d[\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\[\\\\$b]\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ \ \ \\\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \\ ]%'c"$`\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \\%&c ]\ \\!#_ \\ \ \ \ \ \ \ \ \ \\ ] \\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \#!]#!]#!]$"]$"^#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]" [" \#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]"!\#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]" \#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]" \#!]#!]#!]#!]#!]'%a#"]#!]#!]#!]" \#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]" \(&b&$`" \#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!]#!](#](#]'"\+&a.)d'!\(#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#]("]'!\(#](#](#](#](#]'"])$_*%`'"](#](#](#](#](#](#]'"]'"\(#^+&`("](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#]'"\'!\(#]("])$^3.g'"\(#](#](#](#]'"\("](#](#](#](#](#](#](#](#]'!\("](#](#](#](#]("]'"]'"]("](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#](#]-$^-$^-$^,$^,$]-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^,$^-%^1)b,$^-$^-$^-$^-$^,$]/'a1)c,#]-$^-$^-$^-$^-$^,#]1(b4+e,#]2*d,$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^,#]0(a3+d+#]-$^-$^.&_,$^-$^-$^,$^-$^-$^-$^-$^-$^-$^-$^,#],$^-$^,$^1)b-%^,$^-$^-$^,$^.%_/&`,$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^-$^1&^1&^1&^1%^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1%^2'_:.f1%^2&^1&^1&^1&^2&^1%^1%]2&^1&^1&^1&^1&^1&^1%^2&_2'_1%^0%]1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1%^3'_5)a1%^1&^1&^1%^1&^1&^1%^2'_2&^1&^1&^1&^1&^2&^1%^6*b2&_1&^1%^9.f2'_1%^1&^1&^1%^3'`5)b1%^2&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^1&^6'_6'_6'_7(`7(_6'_6'_6'_6'_6'_6'^6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_5&]7(_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_7(_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'^9*a7(`6'_6'_6'_6'_6'_6'_6&^>/g7(`6'_6'_6'_6'_6'_6'^>.f7(`6'_6'_5%]6'_6'_6'_6'_6'_6'_6'^6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_6'_;)_;)_:'^C0g@-c:(^;)_;)_;(_:'^@.d>+a:(^;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_<)_:(^;(_;)_;)_;(_:(^;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_:'^;(_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_:(^:'^;)_;)_;)_;)_;)_;)_;)_;)_;)_:(^@.d>,b:(^;)_;)_;)_;)_;)_;)_;(^;(_;)_;)_;)_;)_;)_;)_:'^;(_;(_<*`<)`;(_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_;)_@*`@*`@*`?*_@*`@*`@*`?)_H2hA+a?)_G1gC-c?)_@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`?)_I3hA+a@*`@*`A+`D.d@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`?)_I3gA+a@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`?)_D.cG1f?)_@*`@*`@*`@*`@*`@*`@*`@*`@*`?)_?*_@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`?)^H2gE/d?)_@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`@*`E,`E,`E,`E,`E,`E,`E,`E,`F-aE,aC*_I0eL3gC*_E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`F-aE,`E,`E,`F-aL3hD+`E,`E,`E,`E,`E,`E,`E,`E,`E,`D+`J1dE-aE,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`D+`G.bI0cD+_E,`D+_E,`D+`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`D+_D+`E,`E,`E,`E,`E,`D+_D+`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`D+_E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`E,`J-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aI-aJ-aJ.bJ.aJ.bI-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aI-aJ-aJ-aJ-aI-aH,_J-aJ-aJ-aJ-aJ-aJ-aI,`I-`J-aJ-aJ-aH,`I-aJ.bJ.aJ-aI-aI-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aI-`I,`J.aI-aO3fJ-aO3fJ.bI-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aI,`M1dL/cI-`J-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aI-aJ.aL0dI-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aJ-aN/aN/aN/aN/aN/aN/aN/aN/aN/aN.aT4fO/bN.aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aO/aN/aN/aN/aN/aO/aN.`S3eQ1dN.aN/aN/aO/aM.`S4fQ2dM.`O0bO/bN/aN/aN/aN/aN/aN/aN/aO/aO/aN/aN/aR2dO/aO/bN/aO/aO/aN/aN/aN/aN/aN/aN/aN/aO/aN/aN/aN/aN/aN/aO/aM-`U6gR3dM.`O/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aO/bS4fN.aO/aN/aN/aO/aN/aN/aN/aN/aN/aN/aN/aN/aN/aN/aS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0aS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0aV3dU2cS0bS0bS0bS0bS0bS0aS0bR/a[8iX5fR/aS0bS0bS0bS0bS0bS0bS0bS0aS0bS0bR/aS0bS0bS0bR/aS/aS0bS0bS0bS0bS0bS0bS0bR/aS0bS0bS0bS0bS0bS0bS0bR/aR/aS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bS0aS0bS0bR/aS0bS0bS0bS0bS0aS0bS0bS0bS0bS0bS0bS0bS0bS0bS0bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX1bW1aX2bX2bX2bX2bX2bX2bX2bW1aX1bX2bX2bX2bX2bX2bX2bX2bX2bW1aX1bX2bX2bX2bX2bX2bX2bW1bZ4d[5fW1aX2bX2bX2bX2bW0a^9h[6eW1aX2bX2bX2bX2bY4dW1a]7gY3cX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX2bX1bZ4dY3cX2bX2bX2bX2bX2bW1a]7g[5eW1aX2bX2bX2bX2bX2bX2bX2bX2b]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3b_5e`6f\2b]4c\2b\3b\2b]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c^4d_5e]3b]3c]3c]3c]3c\2ba7f_5e]3b]3c]3c]3c\2bg?l_6eb8h]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c\2b]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c]3c\2ba7g_5e]3b]3c]3c]3c]3c]3c^4d]4c]3c]3c]3c]3c]3c]3c]3c]3c]3cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb4cb5cb5cb5cb5cb5cb5cb5cb5cb5ca4bb5cb5cb5cb5cb4cb5cb5cb5cb5cb5cb5ca4be8fg:ia4ba4bh;id8fh;jc6dc6db5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cc6da4cb5cb5cb5cb5cb5cb5ca4ba4cb5cb5cb5cb5cb5c`3bd7ee8fa4bb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5c`3bi>jf:ga4bb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5ca4ba4ca4cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cb5cg6dg6dg6dg6dg6dg6dg6dg6dg6dg6dg6dh8eg6de5cg7dg6dg6dg6dg6df6dh8ep@mf5cg7dg6dg6dh8eg7dg6dg6dg6dg6dg6dg7df6cf5cg7df6cj:gh8ej:gh8fpBmf5cg7dg6dg6dg6dg6dg6dg7dg6dg6dg6dg6dg6dg6dg6dg6dg6dg7df6co?lh7ef6dg6dg6dg6dg6dg6dg7dg7dg6dg6dg6dg6dg6dg7df6cf6cg7dg6dg6dg6dg6dg6dg6dg6dg6dg6dg6dg6dg7dg7dg6dh8eg7df6dg6dg6dg6dg6dg6dg6dg6dg6dg6dg6dg6dg6dg7eljk7dl8dk8dk8dk8dk8dl8el9ek8dk8dl8dk7ctAml9ek8dk8dk8dk8dk8dk8dl8dl8dk8dl8dk7ck7dk7ck8dk7ck8dk8dk8dk8dk8dk8dk8dk7dk8dk8dk8dk8dk8dk8dk8dk8dk8dk8dk8dk7ck8dk8dk8dk8dk8dk8dk8dk8dk8dk8dk8dk8dk8dl8dl8dl8dl8dl8dl8dl9dl9dl8el9el9el9el9el9el9dl8dl8dl8dl8dk8dk8dl8dk8dk8dk8dk8dk8dk8dl8dk8dk8dk8dk8dk8dk8dl9ek8dk8dk8dk8dk8dk8dk8dk8dk8dl8dk7cnhp9dp:ep9e|Blvf|>f|>f}>f}>g|=f€Bj‚El}=e~?g~?g~>g~?g~?g}>f~?f}>f}>f}>f|>f|=f|>f{>f{=f{=f{=fz=fzgyf>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f~=e>f>f>g‚Bj~>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f~>f‚Ai>g>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f=f‚Bj…El>e�?g�@g�?g�?f‚@g‚@gƒ@gƒAgƒ@fƒ?f„Ag„Ag„Ag„Ag„Agƒ@f„AgƒAgƒAg„Bh‚@g‚@g‚@g�@g�@g�?g€?f€>f€>f>f~=f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f>f€?g…Dl~=f>f>f>f>f„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„@g„@hƒ?g„?g„@g„?gƒ?g„@h‰Fnƒ?f„@g„?g„?g„?g„?g„?gƒ?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?gƒ?f„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„@g„@g„@g…@g…@f…?f†Ag†Ag†AgˆBh�Jo‡Bg‰Ch‰Ch‰ChŠChŠDhŠChŠChŠChŠCh‹Di�In‰BgŠChŠDi�KpˆBgˆBhˆBgˆBh‡Bg†Ag†Ag…Ag…Ag…@g„@g„?gƒ?fƒ>fƒ?f„@g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„?g„@g‡Cjƒ?g„?g„?g„?g„?gˆAgˆAgˆAgˆAgˆAg‰AgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAhˆ@fŒFkŽJn‡?f‰AhˆAgˆAgˆAgˆAg‡@f‰AgˆAgˆAgˆAgˆAgˆAg‰Bh‰BhˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAg‰AgˆAgˆAgˆAgˆAgˆAg‰AhˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAg‰Ag‰Ag‰BhŠBgŠBh‹Ch‹ChŒChŒCh�DiŽEi�Ei�Dh�Eh�Dh�Ei�Fi�Fi‘Fi‘Fi‘Fi‘Fi‘Fi•Jm–Kn�Eh�Eh�Dh�Ei�DhŽDh�Dh�ChŒChŒCh‹Ch‹ChŠBh‰Ag‹Dj‹Ci�Gl‹Ekˆ@gˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆAgˆ@fˆAgˆAgˆAgˆAgˆAg�Ch�Ch�Bh�Bh�Ch�Bg�Bh�Ch�Bh�Bh�Ch�Bh�Bh�Bh�Bh�Ch�Bh�Bg�Ch�Ch�BhŽDi�Ch�Bh�Ch�Bh�Ch�Bh�Bh�ChŒBg�Gl“JoŒAg�Ch�Bh�Ch�Ch�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Ch�BgŒAf�Ch�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Ch�Ch�Ch�ChŽChŽCh�Ch�Ch�Dh�Eh‘Di’Ei‘Dg˜Mp–Km“Fh•Gi–Hj™Kl–Hi—Hj—Hj˜Ii˜Ii˜Ii˜Ij˜Ii—Hi˜Ij—Hi—Hi–Hi–Gi•Gi”Gi”Fi“Fi“Fi‘Eh‘Di�Dh�DhŽCg‘Gk’Hm�Ej�Ej�Bh�Ch�Ch�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Bh�Ch�Ch�Bh�Ch�Bh�Bh�Bh’Dh’Dh’Dh’Dh’Ch˜Nq“Ej’Dh’Ei“Ei’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Di’Ch™Oq“Ei’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Ch’Dh’Dh’Dh’Dh‘Cg’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Di‘Cg–ImœRv‘Bf’Di’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Di’Dh’Dh’Dh“Ei’Cg”Ei”Ei•Fi•Fi–Fi—Gi˜Gi™Hj™HišHi›Ij›Hi�Kk¢Oo�JjžKjžKjŸKkŸKkŸKkŸKjŸKkŸKkžKjžKj�Kj�JjœJjœJj›IjšIj™Hi™Hj˜Gi—Gi–Gi•Fi–Fj”Eh“Dh“Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh’Dh˜Gj™Gk–Ei—Fi—Ei˜Gj—Fi–DhšKm›Mo•Cg—Fi—Ei—Fi—Fi—Fi—Fi—Fi—Fi—Ei—Fi–Eh—Ei—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Ei—Fi—Fi—Ei—Fi—Ei—Fj›Jm–Ei—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Ei—FjšJn–Ei—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Ei—Fi–Dh—Ei—Fi˜Fi˜Eh�OpšHjšGišHj›HiœIjœGižJjŸJj Kj¡Kj¢Lk§Qp£Lj£Lj¥Nk¥Nk¥Nk¦Nk¦Ok¦Nl¦Nl¦Ok¦Nk¥Nk¥Nk¥Nk¤Mk£Mk¢Lk¢Ml¡Lk JjŸJj�IjœHi�IjšGhŸNp�Km™Fh˜Gi˜Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—Fi—FižIkŸJm›FhœGiœGiœGiœGiœGj›EhŸMnžKl›FhœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGjžIlœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGi›FhœGiœGiœGiœGiœGiœGiœGiœGiœGi›Fi Np�HjœGi�Hj�Hi MnŸJjŸIj Ij¡Jk¢Jj§Tr¥Ml¥Lk¦Mk§Nk©Ol¬RoªOl«Pl¬Pl¬Ql­Ql­Ql®Ql®Rl®Rl®Ql­Ql­Ql¬Ql¬Pl«PlªPl©Mj¬WrªSo¦Lj¥Lk¦Nm§On¢Jj¢Kj IjŸIjžIjžIj�Hj�HjœGjœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGiœGi Hj Hi¡Ij¡Hj¡Hj¡Ij¡Ij¡Ij Hi£Mm¢Kl Hj¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Hj Hi¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Hj¡Ij¡Ij¡Hj¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij Hj£Mn¡Ij¢Ij¢Jj£Jj£Ii¤Jj¥Kk¦Lk¨Mk¨Mk¬So«Ol¬Ol­Pl®Ql¯Ql°Ql²Sm²Sm³Sm´TmµTmµTmµUm¶Um¶UmµUmµTmµTm´Tm³Sm³Sm±Rm°Rm¯Ql®Pl­Pl¬Ok¬Pm«Pm¨Mk§Mk¦Lk¥Kk¤Kj£Jj£Jj¢Jj¢Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¡Ij¥Jj¦Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¦Jj¥Ii¥Ij¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¦Jj¥Ii¦Jj§Kk§Kk¨Lk©LkªLk«Mk¬Nk®Ol¯Pl°Ok±Ql³Rm´Rm¶Sm·Tm¸Un¹VnºVn»Wn¼Wn¼Xn½Xn½Xn½Xn½Xn½Xn½Xn¼Xn¼Wn»WnºVn¹Vn¸Um·Tm¶Tm´Rm³Rm±Ql°Pl¯Pl®Ol¬Nk«MkªLk©Lk¨Lk§Kk§Kk¦Jk¦Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥Jj¥JjªLkªLkªLkªLkªLkªLkªLkªLkªKkªLkªLkªKkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLk«Lk«Lk¬Mk­Mk®Ml¯Nl°Ol±Pl³Pl´QmµRm·Sm¸TmºTn»Vn½Vn¾Wn¿XoÁYnÂYoÃZoÄZoÄ[oÅ[oÅ[oÆ[oÆ[oÅ[oÅ[oÄ[oÄZoÃZoÂYoÁYn¿Xo¾Wn½Vn»VnºTn¸Tm·SmµRm´Qm³Pl±Pl°Ol¯Nl®Ml­Mk¬Mk«Lk«LkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLkªLk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk°Ml°Nl±Nl²Ol²Ol´Ol´Pl¶Qm·Rm¸SmºTm¼Tn½Vn¿VnÁWnÂXnÄYoÅZoÇ[oÈ\oÊ]pË]pÌ^pÍ^pÍ^pÎ_qÎ_qÎ_qÎ_qÍ^pÍ^pÌ^pË]pÊ]pÈ\oÇ[oÅZoÄYoÂXnÁWn¿Vn½Vn¼TnºTm¸Sm¸Rm¶Qm´Pl´Ol²Ol²Ol±Nl°Nl°Ml¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk¯Mk´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´OlµOlµPl¶Pl·Pl¸Ql¹RmºSm»Sm½Tn¿UnÀVnÂWnÄXnÆYoÈZoÉ[pË\pÍ]pÏ^pÐ_qÑ`qÓaqÔbqÕbqÖcrÖbrÖbrÖbrÖbrÖcrÕbqÔbqÓaqÒ`qÐ_qÏ^pÍ]pË\pÉ[pÈZoÆYoÄXnÂWnÀVn¿Un½Tn¼SmºSm¹Rm¸Ql·Pl¶PlµPlµOl´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol´Ol¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹QmºQmºQm»Rm½Rm½Sm¿TnÀTnÂUnÃWnÅXnÇXoÉZoË[pÍ\pÏ]pÑ^qÓ_qÔaq×bqØcrÚdrÛdrÝerÞesÞfsßgsßgsßgsßgsÞfsÞesÜerÛdrÚdrØcr×bqÔ`qÓ_qÑ^qÏ]pÍ\pË[pÉYoÇXoÅXnÃWnÂUnÀTn¿Tn¾Sm¼Rm»RmºQmºQm¹Qm¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl¹Pl½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm¾Rm¾Rm¾Rm¿RmÀSmÁSnÂTnÃUnÅVnÆWnÈXoÊYoËZoÎ[oÏ\pÒ]pÔ^qÖ`qØaqÚcrÜdrßeràfsâgsähsåitæjtèjtèktèjtèjtèktèjtæjtåitähsâgsàfsßerÜdrÚcrØaqÖ`qÔ^qÒ]pÏ\pÎ[oËZoÊYoÈXoÆWnÅVnÃUnÂTnÁSnÀSm¿Rm¾Rm¾Rm¾Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½Rm½RmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÃSmÃSmÃTmÄTnÄUnÅUnÆVnÇVnÉWoÊXoÌYoÍZoÐ[pÒ\pÔ]pÖ_qÙ`qÛbrÝcràdrâfsägsçhtèitëjtìkuîmuïmuðnuñnuñouñouñnuðnuïmuîmuìkuëjtèitçhtägsâfsßdrÝcrÛarÙ`qÖ_qÔ]pÒ\pÐ[pÍZoÌYoÊXoÉWoÇVnÆVnÅUnÄUnÄTnÃTmÂSmÃSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÂSmÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÆWnÆWnÆWnÇXnÈXnÉYnÊYnËZnÍ[oÎ\oÐ]oÒ^pÔ_p×apÙbqÛcqÞeràfrãhråisèksêltíntïotñpuóruõsv÷tvøtvùuvùuvùuvùuvøtv÷tvõsvôruñpuïotíntêltèksåisãhràfrÞerÛcqÙbq×apÔ_pÒ^pÐ]oÎ\oÍ[oËZnÊYnÉYnÈXnÇXnÆWnÆWnÆWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÅWnÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÉ\nÊ]nÊ]nÌ^nÍ^nÎ_oÐ`oÒaoÔboÖcpØepÚfqÜgqßiqâkrålrènsêpsíqtðstòtuõvuøxvúyvüzvþ{wþ{wÿ|wÿ|wÿ|wÿ|wþ{wþ{wüzvúyvøwvõvuòtuðstíqtêpsènsålrâkrßiqÜgqÚfq×epÖcpÔboÒaoÐ`oÎ_oÍ^nÌ]nÊ]nÊ]nÉ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÈ\nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`oÊ`nÊ`nÊ`oÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nË`nË`nÌanÌanÎbnÎbnÐcnÒdoÓeoÕfo×gpÙipÛjpßkqámqãorçprérsìssïutówtõyuù{uü}vþ~vÿwÿ�wÿ‚wÿƒxÿƒxÿ„xÿ„xÿƒxÿƒxÿ‚wÿ�wÿwþ~vü}vø{uõyuówtïutìssérsçprãorámqßkqÛjpÚip×gpÕfoÓeoÒdoÐcnÎbnÎbnÌanÌanË`nË`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÊ`nÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÌdoÎdlÏdkÎdlÎdlÏdkÏdlÍdnÌdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍenÎenÎenÏfnÐfnÒgnÓhoÕioÖjoØkoÚlpÝnpàoqâqqårrçtrêvsîwsñytô{t÷}uûuþ�vÿƒvÿ…wÿ†wÿˆxÿ‰xÿŠxÿ‹xÿ‹yÿ‹yÿ‹xÿŠxÿ‰xÿˆxÿ†wÿ…wÿƒvþ�vûu÷}uô{tñytîwsêvrçtrårrâqqàoqÝnpÚlpØkoÖjoÕioÓhoÒgnÐfnÏfnÎenÎenÍenÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÍdnÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏioÐhmÒhkÉkv¾oƒ¸rЏrнp„ÇkxÑhlÑhlÏioÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÏinÐinÐinÐinÑjnÒjnÓknÕlnÖmo×noÚooÜppÞqpàspãtqåvqèwrëyrï{sò}sötù‚tý„uÿ…vÿˆvÿ‰wÿ‹xÿ�xÿ�xÿ‘yÿ’yÿ“zÿ“yÿ“yÿ“zÿ’yÿ‘yÿ�xÿ�xÿ‹xÿ‰wÿ‡vÿ…vý„uù‚tõtò}sï{sëyrèwråvqãtqàspÞqpÜppÙoo×noÖmoÔlnÓknÒjnÑjnÐinÐinÐinÏinÏinÏinÏinÏinÏinÏinÏinÏinÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÑmnÔmlÏnp½r…°u”¯v–¯v•¯v•¯v–°u•¹sŠÊowÔmkÓmmÑmoÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒnnÓnnÔnnÕonÖpn×pnÙqoÛroÝtoßupávpäxqæyqê{qí}rï~rô�söƒtú„tþ‡uÿ‰vÿ‹vÿŽwÿ�xÿ’xÿ”yÿ–yÿ˜zÿ™zÿš{ÿœ|ÿœ|ÿš{ÿ™zÿ˜zÿ–yÿ”yÿ’xÿ�xÿ�wÿ‹vÿ‰vþ‡uú…töƒtó�sï~rí}rê{qæyqäxqávpÞuqÝtoÛrnÚqn×pnÕooÕonÔnnÓnnÒnnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÒmnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqo×rkÍrv³u‘®v–±u“²u’±u“±u“²u’±u“¯u•¯u•Às„Órp×rkÔqnÔqoÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÕrnÕrnÖsnÖsnØsnÙtnÚuoÜvoÞwoàxoâzpä{pç}qê~qí€rð‚rô„sö†sûˆtþŠuÿŒuÿ�vÿ‘wÿ”wÿ–xÿ™yÿ›yÿ�zÿŸ{ÿ¢|ÿ yÿŸvÿŸvÿ yÿ¢|ÿŸ{ÿ�zÿ›yÿ™yÿ–xÿ”wÿ‘wÿ�vÿŒuþŠuûˆtö†sô„sð‚rí€ré~qæ|qç|næznÞxpÛwrÙvqÛunÜukÙtmÖsoÕrnÕrnÕrnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqnÔqn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vnÖvoÚwkÈv|°u”°u”²u’±u“±u“°u“°t’°t’±u“±u’±u“®u–´u�Éu|ØvlÙwl×vnÖvoÖvn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vn×vnØwnØwnÙwnÚxnÛynÝzoß{oá|oã}på~pè€pê�qíƒqð…rô‡r÷‰sû‹sÿ�tÿ�uÿ’uÿ•vÿ—wÿšxÿœxÿŸyÿ¡zÿ¤{ÿ¦{ÿ¤uÿ²‡ÿ¿—ÿ¿—ÿ²‡ÿ£uÿ¦{ÿ¤{þ¡zþžyþœyþ™xþ—wÿ”vÿ’uÿ�uÿ�tû‹s÷‰só‡rï„sð„píƒoÙ}yÄx†¶v�´u‘´u‘ºvŒÈwÖxqÜxlÚwl×vo×vn×vn×vn×vn×vn×vn×vn×vn×vn×vnÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙznÙzoÝ{kÊx|¯u•±u“±u“±u“°t’±u“´w•µx–µw–²v”°t’°t’±u’°u”®u–»vŠÏywÚ{mÝ{kÛ{lÚ{nÙznÙzoÙznÙznÙznÙznÙznÙznÙznÙznÙznÙ{nÚ{nÚ{nÛ|nÜ|nÝ}nÞ~nà~oáoã€oæ‚pèƒpë…pí†qðˆqôŠr÷ŒsûŽsþ�tÿ“tÿ•uÿ—vÿšwÿ�wÿŸxÿ¢yÿ¥zÿ¨{ÿ«{ÿ¬zÿÔ¯ÿøßÿþæÿþæÿøßÿÔ®þªyÿ¬zÿ¬yÿ©xÿ¦wÿ£vÿŸvÿšvþ—vþ”uþ’uý�túŽsú�qùŒoä„xÀyНt•®t–°u”°u”°u”¯u•®u–²u’Áw…Ô{sÝ|kÜ{lÚ{nÙznÙzoÙznÙznÙznÙznÙznÙznÜnÜnÜnÜnÜnÜnÜnÜnÜnÜnÜnÜnÜnÜnÜnÜnÜnÜnÛo߀kË{|¯u•±u“±u“°t’±u“¶x–²u“¥mŠži… j‡¬r�´w–¶x–±u“°t’±u’¯u•¯u•¸v�ÈzÓ|uÛoÞ€l߀kÞkÜlÚ~nÛnÛnÜnÜnÜnÜnÜnÜ€nÝ€nÞ€nÞ€nß�ná‚nãƒoä„oæ…oé†pëˆpí‰qð‹qó�q÷�rú‘rþ“sÿ•tÿ—uÿšuÿ�vÿŸwÿ¢xÿ¥xÿ¨yÿ«zÿ¯}ÿ­uÿÖ®ÿÿíÿúáÿùàÿùàÿùáþÿìÿß³øªxà˜†×�‡×ކÜ�„î™}üžxÿŸtÿ�sÿšrÿ˜rÿ”rîŒxË~†±u“­t•²u“²u’±u“±u“±u“±u“²u’±u“®u–±u“Àx†Ñ|wÛoÞ€l߀kÞ€lÝmÜnÛoÛnÜnÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞ„nÞƒn݃oâ…kÌ~|¯t•±u“±u“°t’´w•²u”–c€vOijGafE^hE_nJd~To˜e�°u“¶x—³w•°t’¯t‘®t”­t•¯t“µv޽y‰É}�Õ�zÞ…uä‡nâ…là„kÞƒn݃oÞƒnÞƒnÞƒnß„nß„nà…ná…nâ†nã†nå‡oç‰oéŠoë‹pîŒpðŽqó�qö‘qù“rý–sÿ˜sÿštÿœtÿŸuÿ¡vÿ¤wÿ§xÿªxÿ­zÿ±{ÿ²yÿ¾‡ÿùßÿúáÿúàÿúáþøàÿûâÿþäÝÁ¾µw�¬q•«q•«q•«r•¯t”¹z�ÊƒŠØŠ„Øˆ„ׇ„Ã}Œ±u“¬s•±u“²u“±u“±u“±u“±u“±u“±u“±u“±u“²u’±u“®t–°u”¶v�¿y‡Ë}~Ó€wÙ‚rÞ„nâ…ká…lÞ„náˆnáˆnáˆnáˆnáˆnáˆnáˆnáˆnáˆnáˆnáˆnáˆnáˆnáˆnáˆnáˆnàˆoåŠk΀|®t•±u“±u“°t’¶x—¥m‹wPjeD]gE_jGakHbkGaiF`fD^fD^tMh�^z¦n‹µx–¼{š¶x–¶x•·y—´x—¯u–©q“ k‹Ÿi„®pÆ|Û†zâ‰oä‰jä‰láˆmàˆnàˆoáˆnâ‰nãŠnäŠnæ‹nèŒoé�oëŽoî�pð‘pó“qö”qù–qü˜rÿšsÿœsÿžtÿ¡uÿ£uÿ¦vÿ©wÿ¬xÿ¯yÿ²yÿ¶|ÿµvÿÌ—ÿýæÿùàÿúáþ÷ßÿÿåúóÞا¬lްt”²v“²v“²v“²v“±u“¯t”¬r•«r•«r•«r•­s•±u“²u’±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“²u’±u“°u”®t•®t–¯t•²v’¸xŽÂ{…Ôƒwáˆnã�nã�nã�nã�nã�nã�nã�nã�nã�nã�nã�nã�nã�nã�nãŒnâŒoè�kЃ|®t•±u“±u“°u“·y——d�kGafE^kHbkGajGajGajGajGakHbkHbgE_dC\sLfwPjnMe�_|’`}�^z†YtxPjoJdjF`fD_fEbnJiŒ]{µvŠË‚…Ó…xà‹pæŽlè�kæ�låŽnå�oæŽoè�nê�oì‘oî’oð”pó–põ—qø™qûšqþœrþžsþ sÿ£tÿ¦uÿ©tÿ«vþ­wþ¯xþ³yÿ¶yÿ»|ÿºvÿЗÿýæþøßþøàÿÿçïàÒº„œ¬m޲w”±u“±u“±u“±u“±u“±u“±u“²v“²v’²v’²v’²u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“°t’°t’°t’¯t’±u“±u“±u“²u’²u’±u“±u“¯t”®t–¯u”¹y�æ‘næ‘næ‘næ‘næ‘næ‘næ‘næ‘næ‘næ‘næ‘næ‘næ‘næ‘nå�oê“kц|®t•±u“±u’±u“´w–’a}gE^iF`kHbjGajGajGajGajGajGajGahF`nIdpJeK7M)/S:QkGbgE_eD^iF`jGakHakGaiF_eD]nJf›g‰³w˜³v�Á}ˆÏ„~ÛŒuå‘oì•lí•kí•mí•oí•oï–pñ˜qô™qö›rúœrþžqÿ£qÿ§qÿ©qÿ¨tú¦wú©wú«xÿ´vÿºvÿ»wÿ»yþ½{þ¿zþɇÿøßÿÿåÿÿæàÇòw”®p�³x•±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“®s‘µw–¸y˜¶x—¸y˜®s‘°u“±u“±u“±u“±u“±u“±u“²u’±u“¯t”è•nè•nè•nè•nè•nè•nè•nè•nè•nè•nè•nè•nè•nç•oí™kÓ‰|®t•±u“°t’²v”µx–†YudC\kGajGajGajGajGajGajGajGahF`kHbqKe\@X'#5 ! #& +"$6,?fE_qKfjGaiG`jGajGajGakHbiF_gE]–c¶x˜­s”­s–°u”·x�Æ�…ÖŠ|ä“tïšoô�møŸlú mý¢mÿ£nÿ¥oÿ¤qõ vá”~͈‡À�¶y‘¶y‘·y‘Å…Œ×–…í«~ü¼zÿÅyÿË{ÿÊsÿå®ñæÜË¡¬­mްs’²w”±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“¯t’°t’¼|›ªqŽN;P7.@™gƒ½|›³v”®s‘±u“±u“±u“±u“±u“±u“±u“±u“é™né™né™nê™nê™nëšnëšnëšnëšnëšnëšnëšné™oï�kÔ‹|®s•±u“°t’µw–¯s’�VqfD^kHbjGajGajGajGajGajGaiG`iF`pJegF_:.A$ !%%$%$ )H5JmIcnIdiF`jGajGajGakHbjGafE^–c·y—±u‘±u“°t”­s•­s•±u“»{�Ç‚‡ÓŠ�Ô‹‚Õ‹‚×��·†À~�³w’¬r•¬q•®s”°t”°t”°t”­r”«p•¯s”º~�Æ‹Œ×�‡Ó›ˆÀ‡Ž±v”¬m޲v”±v“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“°u“°t’¯t’¯t’¯t’°t’±u“°u’®s‘¸y˜·x—tRj-$\DY¬r�¼{›¯t’¯t’°u“°u“°u“°u“°u“°u“ô£oõ¤oó£oï nï nìžnë�në�nìžnìžnížnìžoð¡iÔ�{­s•°t’±u“·y—¦m‹uNieD]kHbjGajGajGajGajGajGahF`mIcoJdL7M+$%$$$$$% # $"2X=UqKekHbhF`jGajGajGajGafE^–c€¶y—°t’±u“²u’²u’±u“¯t”­r•¬r–¬r–¬r–«q–¬r•®s”°t“²v“²v“²u“±u“±u“±u“²v“²v“±u“¯s”­q”«o•¬o•®q”±u“²w”±u“±u“±u“°u“°t’¯t’¯t‘¯t‘¯t‘¯t‘¯t‘¯t‘°t’°t’²u“³v”¶x—¸z˜¸y˜¸z˜·y—±u“´w•½|›‘c~5-> "&%$""2{Vn¹z™¸y˜²v”´w•´w•´w•´w•´w•΋kÌŠjÔ�kè�mè�mõ¦nù©où©oõ§oò¤nï¢nó¥ià•‚³v™±u“¶y–±u””b~kHbgE_lHbjGajGajGajGajGajGamHcrLf^AY)$6 +" #&$$$$$$$$%& "$7,?gE_rKfkGbjGajGajGajGafE^–c€·y—°t’±u“±u“±u“²u“²v“²v’²v’²v’²v’²v’²u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“²v“²v“²v’±v“°u’°t’°t’¯t’°u’´w•¸y˜¹z™»{š»{š»{š»{š»{š»{š·y—¸y˜¯t’ªpŽ™e‚�_{�`|Ž_z•c±t“¨p�S>S "&%$$$&  <1CtOi‚Vq~To{Rm|Sn|Sn|SndC`cB`hFasMbuNbŠ^d¤pg¿ƒiוkç l÷«oäš„‚Yw”d±t’‘`|tNheD^iG`kHbjGajGaiG`iF`jGajGakHb`BZ3*=  $$$$$$$$ #  $A1FfE^kGajGajGajGajGafE^•c·y˜°u“°t’±u“±u“±u“±u“±u“±u“±u“±u“±u“±u“°t’¯t’¯t‘¯t‘¯t’¯t’°t’±u“±u“°t’°t’°t’±u“³v•µx–¶x–¶x–¹z˜µx–§nŒ“b~‡ZuoKdpLepLepLepLenJcQ8OR8PG0G/$8+%0,4*3, 55)=.$&$$$$$$%% +!7,?dD]mHcfD^fD^fE^kHakHajGahEagEacB``@``@_hFayQf|V`B3I &@1EeD]mHcjGaiGajGajGahF`mIcoJdiF`iGajGaJ6L4*=<.B=/C:.AA1F8,@' $$$$$$ #,?0E>0D;.B>/D:-A5+>TVS;QS;RO9ON8OfE^kHajGajGajGajGajGaiF`lHbqKeT/D& +"1(;cC\qKeiG`gE_lHbnIdB2F # "%$$$$$$$% )R:QpJejGagE_iF`jGaiGaiGaiGaiF`iF`jGakHbfE^kHaUp—d� j‡ži†Ÿi†’a}�_{~TomIcI2I4%:(2&0%0&1)33%:;*@C.E;*@9(>:)?9(>1#8, 5-!5/"71#80#80"7/"7/"7/"7/"7/"7/"7%1& ! #$$$$$ # #*-"6%1%$$$$$$$$%VkHbhF`jGajGajGakHbnIdnJdpKeM8N-%),& $$$$$ #(& ! +!%$$% #.R:QI6K9-@>/D:.AA1F3*=$$$$$ #(<.B@1E@1E@1F2)<@/DQ7OA-D=+A>+B>,B=+AE0GQ7O^?XgE_lHbmIcpKenJdmIceD^T9Q:(>, 5.!60#80#8/"7/"7/"7/"7/"7/"7/"70"80#80"80"70"80"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"72#9,!5) #$$$$ $$+,))))$$$$$$'.+&7#!2'#4Z>WlIciGajGajGa;)?F0GM5LO6NQ9PR9P\?WfD^E4I& $$$$$ #*.!/"0( $$$ $& /,+F1HgD^oJeyOk_AZ- $$$$$$ #*%7eD]\=VH1I=*A0"7*4, 5, 5, 5, 5, 5+ 4+4.!64%;=+AB.DN5ME0G=+A2$9+4-!50#80"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"70"7) 4)()$$$ $%*'+."72#9/"7* $$$$$$'-"6K3JfD^qKflHbjGaiF`jGajGa-!5+ 4*4+ 4/"7/"71#8"1 " "$$$$$$ "%$1- #$$$$ #(-!63#9*4+ 5:(>C0F'  %$$$$$$!&)3, 5, 5/"70#80"80"80"80"80"70#80#8/"7.!6, 5+ 4*4+ 4, 5.!60#8/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7/"7."70#81#8, #$$$ $#03$9/"70#8."6( #$$$$$$ #$$01"8C/E[>VlHbnJdkHbjGa.!61#81#80#80#8,!5) #%&$$$$$%&$$$($$$$(()* 41#80#8- " # #$$$$$ # # "%( 31#8/"70"70"70"70"70"70"70"70"70"70"70#70#81#80#80#70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"70"7."6)$%$$$%$-1#8(2% "#$$$$$$# #$"/, 4.!6=*@S9PeD]kGa1'@% 7'!8&!8&!8$ 7#6% 7'!9/#$$$$% 3&!8% 7% 7+ #$$ #(' 7(!9& 7'!8% 6./..* #$$$$-./-/&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8&!8% 7# 6% 7' $$ #+& 8"6&!84234+ #$$ #(4333'!9% 7 3 3.&=P:U(#<# 8# 9# 9# 8# 9$ 9# 8. # #$$$$ #%2# 9*$%$$%%&5$!9# 9# 8# 8"7(#<(#;&$$$$ #*%!:# 8%!:%!:" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8# 8# 8# 9# 9# 8# 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8" 8# 8$ 9+ "$$$ "/$!9" 8$ 9$!9%!: 5$$$$$ #1&";%!:$!9 6-'@:.IB3NK9UA3N)#<5 6 6!7!6# 8/%(' $$$$% #$1# 8"7' #$$4# 8"6!6 6!6+%>A2MG6Q. #%$$$% "/1(B!6!6# 8$ 8$ 8$ 8$ 8$ 8$ 8$ 8$ 8$ 8# 8"7!6 6 6!6"7# 8$ 8$ 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8"723& $)42# 8# 8# 8# 8 5 # " $$$$ # "0!7!68-GO;XP0K*$=$!9!6 6 6!6!6!6!6!6 6# 8*$=.'@5,E8-G/'@*$=# 8 6 6# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8&!;.    3%!:# 8# 8# 8$ 9-(*'$$%)'.5+ED5PO;WM:VL:UL:VM:VG6RN;WP1L3*C$ 9# 8$!9$ 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 8# 71! '3$ 8# 8# 8# 8# 8# 8$!9# 8&$%$4-&@J8TQ[M:V) #& !"!4T>\Q=ZR=ZM:VM:VN;WO;XP    #>2JR=ZM:VM:VM:VM:UU?]$ N;WP445!6"7!7!7!7!7"7$ 9$!9$!9%!:%";$ 9# 8# 8# 8$!9# 835&":Q=XŽd…"+ tSpcHfN:WM:VM:VM:VM:VM:VM:VM:VM:VM:VK9VK9VL9VI7VH6VH7VR>WdJXwXZ^X†c_jMg  )YB^J8SJ8TL9UM:VM:VM:VM:VPWS?WR>WaHXdJX�_[¥x^°€_²‚`³‚`¶„b¢v` #+‡`~{XvpPnR=YK9TN;WM:VM:VL:UL9UM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VL:VL9UM:VD4N:.DH7RM:VL9UM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VK9TK9TsSqƒ]{}Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx}Zx}Yw~Zx~Zx€[y€\z„^|„]|‚]{{XvxVtxVtxVtxVtxVtwUshLhhLh_Fc<0I6,E(#<)$<)$<(#<5+EgKh†_~ƒ]{[y}Zx~Zxƒ]|„^}~Zx}Zx{XvoPnPZJ8TM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VN:VK9UT>[tSr~Zx~Zw~Zx}Zy|YyzXz[w‰ar“gm—jl—jl˜jl˜jmŽdr…^u€[w{XyzWz{Xy}Yx}Yx}Yx{XyxWy{Xy�et¢pq±ypŸms‰aw~[y{Tt~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx}Yw}Yw}Yw}Yw~Yw|Yx‰ap›ke¦r^¥q_¥q_¥q_¥q_¥q_¥q_¥q_¥q_¥q_¥q_¥q_¥q_¥q`�ldƒ]u}Zx~Zx~Zx}YwmOlL:UM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VN:VN:VN:VM:VM:VM:VM:VM:VM:VM:VN:VM:VJ8SaGc‚\z}Yy|Yy}Zx�\v‰`q’fl¢oc¬u^®w^°x^²y^³z^µ{_¶{`´zb±xd©sh�lm�csƒ]vƒ]w‚]w�ct›kq·{kÙ�fß“fä—gè–eÕœz­«§ž‘˜~[x~Zx~[x~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx€[w£n`¤o_¤o_¤o_¤o_¤o_¤o_¤o_¤o_¤o_¤o_¤o_¤o_¤o_¤o_¦p^ˆ_q{Yz~Zx~Zx[y[yWA]I7SM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VM:VN:VM:VL:VK9TJ8TJ8TJ8TK9TM:VN:VN;WN;WN:VO:VI7SS>[tTs|Yz{Yyƒ]u–gh�kd£o`§q^ªr]©r^¨q_©r`«s`­t`®t`°va²wa¶ya¹{a¾~aÁ€a€b¿eÁ€eÃ�f͇eÕ‹eØ�eÖŒgÙŽiߎeÓžµ¹°·¼±¸¼±�r†{Tu}Yw~[x~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx~Zx}Zxˆ_r™hf¤m^¢l_¢l_¢l_¢l_¢l_¢l_¢l_¢l_¢l_¢l_¢l_¢l_¢l_¢l_¢l`–eh}Zx~Zx~Zx~Zx~ZxmOlL:UL9UN:VM:VM:VM:VM:VM:VM:VM:VN:VJ8TK9TP[‰?[Š@\‰@\‰?[‡=Z‰?[‹C^‡[‰?[‰?[ŠA\‰?[ˆ>[ˆ=Z‰?[ŠA]‰@\ˆ>Z’Le ct„8VŠB]‰?[ŠA]ˆ>Zˆ>[”Qh bt„8WŠB]‰@\‰?[‡[Š@\‡Z‹C^ŒE_ŒD_ŒD_ŒD_ŒD_�E_�E_�E_ŽE_ŽF_�F_�G_‘G_’H_“H`”I`–J`˜K`™La›MaœNažOa Pb¢Qb£Rb¥Sc§Tc¨UcªVc«Vd¬Wd®Xd®Xd¯Yd¯Yd¯Yd¯Yd¯Xd®Xd­Xd¬WdªVd©Uc§Tc¦Sc¤Rb¢Qb¡PbŸOb�Na›MašLa˜Ka—J`•J`”I`“H`’G_�G_�F_�F_ŽF_ŽE_�E_�E_ŒE_ŒD_ŒD_ŒD_ŒD_ŒD_ŒD_ŒD_ŒD_ŒD_ŒD_ŠC^ŠC^ŠC^ŠC^‹D_‹D_‹D_‹D_‹D_‹D_‹E_‹D_ŠC^‹D_ŠC^ŠC^‹D_‹D_‹D_‹D_‹D_‹D_‹D_‹D_‹D_‹D_‹D_‰B]‡>[ŒE`ŠC_‹D_‹D_‹D_‹D_‰A]‡?[ŒE`‹C_‹D_‹D_‹D_ŠC^‹D_‹D_‹E_‹D_‹D_ŠC^‹D_‹D_ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^‹C^‹D_ŒD_ŒD_ŽE_ŽE_�F_‘G_’G`”H`•I`—J`˜KašLa›Ma�NaŸOb Ob¢Qb£Qc¥Rc¦Sc§Tc¨Td©UdªUdªUdªVdªVdªUdªUd©Ud¨Tc§Tc¥Sc¤Rc£Qb¡Pb ObžNbœMa›La™Ka—K`–I`”I`“H`’G`‘G_�F_�E_�E_�D_ŒD_‹D^‹C^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^ŠC^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^†B^‡B^‡C_†B^‡B^‡B^‡B^‡B^†B^‡B^‡C_†B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^ˆB^ˆB^‰C^‰C^ŠD_‹D_ŒE_�F_ŽF_�G`‘H`’I`”I`•J`—Ka˜LašMa›Na�NbžObŸPb Qb¡Qb¢Rc£Rc¤Sc¤Sc¤Sc¤Sc£Sc£Rc¢Rb¡Qc PbŸPbžObœNb›Ma™Ma˜La–Ka•J`“I`’H`‘G`�G`ŽF_�E_ŒD_‹D_ŠC_‰C^ˆC^ˆB^ˆB^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^‡B^ \ No newline at end of file diff --git a/crates/exav-viewer/demo/examples/files/images/scene.qoi b/crates/exav-viewer/demo/examples/files/images/scene.qoi new file mode 100644 index 00000000..c1adea3e Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene.qoi differ diff --git a/crates/exav-viewer/demo/examples/files/images/scene.webp b/crates/exav-viewer/demo/examples/files/images/scene.webp new file mode 100644 index 00000000..6c586470 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/scene.webp differ diff --git a/crates/exav-viewer/demo/examples/files/images/sunset-flat.hdr b/crates/exav-viewer/demo/examples/files/images/sunset-flat.hdr new file mode 100644 index 00000000..c136e96d --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/images/sunset-flat.hdr @@ -0,0 +1,7 @@ +#?RADIANCE +# written by exav's make-images.py +FORMAT=32-bit_rle_rgbe +EXPOSURE=1.0 + +-Y 96 +X 128 +Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}Û}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}' à}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}bY³€1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}1$å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}E:€€<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}<%å}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}F)ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}S+ê}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}b/ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}r1ï}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}„5ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}—7ô}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¨<ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}¾>ú}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ÖCÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}î˜ô€ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}—Wœ€ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}ïFÿ}…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~…%‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“(‚~“(‚~“(‚~“(‚~“(‚~“(‚~“(‚~“(‚~“(‚~“(‚~“(‚~“(‚~“(‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~“'‚~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~¢)…~¢)…~¢)…~¢)…~£)…~£)…~£*…~£*…~¦*…~¦+†~§+†~§+†~§+†~§+†~§+†~§+†~§+†~¦*†~¦*…~£*…~£*…~£)…~¢)…~¢)…~¢)…~¢)…~¢)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~Ÿ)…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~€k€�¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~²,…~³,…~³,…~³,…~³-†~·-†~¸-†~¸-†~¸-‡~¹/‡~¹/‡~¹/ˆ~½/ˆ~½/ˆ~½/ˆ~¹/‡~¹/‡~¹-‡~¸-‡~¸-†~·-†~·-†~³-…~³,…~³,…~³,…~²,…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~¯+…~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~Ä.ˆ~Ä.ˆ~Ä.ˆ~Å0‰~É0‰~Ê0Š~Ë0Š~Ï0‹~Ð1Œ~Ñ3Œ~…{…�Ó3�~Ô3Ž~Ô3Ž~Ô3Ž~Ô3Ž~Ô3Ž~Ó3�~Ò3�~Ñ1Œ~Ð1Œ~Ì0‹~Ë0Š~Ê0Š~É0‰~Å0‰~Ä.ˆ~Ä.ˆ~Ä.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~À.ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò1ˆ~Ò1ˆ~€h€�Ö1ˆ~×1‰~Ü2‰~Ý3Š~ß4Ž~ä4�~æ5�~è5’~î7“~ð8”~ò8•~ó8–~ø9—~ù9—~ù9—~ù9—~ô9—~ó8–~ñ8•~ð8”~î7“~è5‘~æ5�~ä4�~Þ4Ž~Ý3Š~Ü1‰~×1‰~Ö1ˆ~Ö1ˆ~Ò1ˆ~Ò1ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~Ò/ˆ~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2‹~å3‹~é3‹~ê4Œ~ï5Œ~ñ5�~ó6�~ú8�~ý9’~‚J„KˆLŠM‹O�P� Q‘ Q’ R’ R’ R‘ Q�QŒP‹O‰M…KƒJ�Iü8‘~ù8�~ò5Ž~ð5�~ë5Œ~ê4‹~é3‹~å2‹~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ä2Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø6Š~ø6‹~ø6‹~ý6‹~ÿ6Œ~‚F„I‡I‰KŽL‘M–!O™!QŸ"R¢#T§$Uª%W¬%X­&Y±&Y±&Y®&Y­&X«%W©%V§#U¡#Sž"R˜!P• O�M�L‰J‡IƒH‚Fþ6Œ~ý6‹~ø6‹~ø6‹~ø6Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~ø4Š~„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F†F†F†G†G‰GŠHŽH�I”JšL�!N¤"R«$T°%V¸&Y½([Å*^Ê+`Ñ+bÔ,c×.eØ.eÙ.fØ.eÖ.dÓ,cÐ+aÈ*_Ä)]¼'[¶&X¯%Vª#S£"Qœ!M™L”J�I�HŠG‰G€c€�†F†F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„F„FŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽF‘F‘G‘G”G–HšJŸK¢!M©"O°$Q¹%TÀ(WÊ)[Õ+`à.dé0gô3kû4n‚8€…9€‡:€ˆ;€ˆ;€ˆ;€‡:€„9€€8€ù4mò1jç/gÛ-cÓ+_È)Z¾&Vµ%S¯$Q¨"N¡!MœK™J•H”G‘G‘F‘FŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽFŽF™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™HœH�H I¡ I¦!J¬"L³#N¹%RÂ'UÍ)YÚ,]ç.aö2fƒ5€‰9€‘<€™>€Ÿ @€¦!B€ª"D€­#E€¯#F€±#F€®#F€¬"E€©"D€¥!B€� @€˜=€�;€‡7€ÿ3jó0eå-`×*\Ë(XÀ&T·$P¯"M«!L¥!J¡I IœHœH™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H™H¥Hò`“€¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥ H¥ H¨ H¨ H¬ I®!K³"Lº$NÂ%PÌ'SØ)Wå,[õ/bƒ4€�7€–:€¡ >€­"A€¸$F€Ä&J€Ì)M€Ö*O€Ü+R€ã,S€æ-T€æ-T€å-T€â,S€Ú+Q€Ô*O€Ê'L€Á&I€¶$D€ª!@€Ÿ=€”9€‰6€€3€ï.aß,ZÒ)VÇ'R¾%P¸$M²"L­!K© I¨ H¥ H¥ H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H¥H±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I´"J¶"Jº"KÀ$LÇ%NÑ'QÜ)Vê,Zû0_‡2€’6€Ÿ;€­!?€¼$D€Ë(I€Ü*N€ì.R€ü1X€….�Œ0�‘1�”2�˜3�˜3�•3�“2��1�‰/�„.�ù0W€é-Q€Ø*L€È&G€¸#C€ª >€œ:€�5€…2€÷.^ä+Y×(UÌ'PÃ%N¿$Lº"Kµ"J´"J±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I±!I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾#I¾#IÁ#JÂ#JÇ$LÍ%NÕ'Pà(Sí+Vý/[ˆ1€”5€¡9€³">€Å$C€Ø)J€í-P€ƒ+�Ž.�š1�¥5�°!8�·#;�À$=�Ä&>�Ç&?�È&?�Æ&?�Ã%>�¾$<�µ":�«!7�¡4�—1�Š-�þ0U€è,N€Ô(H€Á$A€­ <€ž8€‘4€†0€ö-Zç+UÛ(RÑ'OÉ%MÆ$LÂ#J¾#J¾#I¾#I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"I¾"IÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KË$KË$KË%KÏ%KÔ%LØ'Nà(Pë*Rù.X‡.€’2€ 6€²!;€Å$A€Û(G€ö-N€ˆ+�˜/�§4�·"8�Ç%<�Ö(A�å+E�ò-H�þ0J�‚&‚„&‚„'‚ƒ&‚�&‚ü/J�ð,G�â*D�Ó'?�Ã%;�´!7�¤2�“.�…*�ð,L€Õ'E€À#?€¬9€�4€�1€ƒ.€ö,Wé*RÞ(O×'MÑ%LÏ%KË$KË$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÈ$KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KØ&KÙ&KÚ&Kß'Lå(Oî*Qù+T„,€Ž.€œ3€«7€À"<€Ö&B€ò+K€‡)�š.�®2�Â#9�Ù(>�î+C�‚$‚Œ'‚–*‚Ÿ,‚§.‚¬ /‚°!/‚±!0‚°!/‚«.‚¦-‚ž+‚”)‚Š&‚ÿ/G�é*B�Ñ&=�½"7�§1�–,�„(�é*I€Ð%A€¹!;€§6€—2€‹-€‚+€÷+Sì*Pä(NÞ'LÙ&KÙ&KÖ&KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%KÕ%Kã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lç'Lç(Mì(Mò)Nø+Pƒ)€‹,€•/€¢2€µ7€É#=€ä(D€‚&�•*�©0�Á"6�Ü'<�ù-D�Š%‚š)‚©,‚·!0‚Ç$3‚Ó&6‚Ü(8‚ä*:‚è*:‚é*;‚ç*:‚ã)9‚Ú(7‚Ï&5‚Â$2‚´!/‚¦+‚•(‚‡$‚ð+B�Ó%:�»!4�¤/��)�ú,J€Þ&B€Ä"<€±5€Ÿ1€“.€‡+€€)€ö*Pî(Nì(Mç(Mä'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lã'Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lö)M÷)Mý*O‚(€ˆ)€�+€œ.€©2€» 7€Ó$=€ð)D€Š&�Ÿ+�¶1�Ò$9�ñ+@�Š#‚�(‚²-‚Ç#1‚Û'5‚ñ+;‚�ƒŠ!ƒ‘"ƒ—#ƒ™$ƒš$ƒ™$ƒ”#ƒ�"ƒˆ ƒÿ-=‚ê*:‚Ö'4‚À#0‚«,‚™&‚†"‚ê)>�Ë#7�°0�˜*�„%�ç(B€Í#;€¶6€¦0€—-€Ž+€‡)€‚(€ü*O÷)Mõ)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)Lò)L€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€‚'€…'€‡(€�)€–+€ .€­1€Á 6€Ú$;€÷)C€Ž&�¥+�À!2�à&9�‚ ‚˜%‚¯*‚È#0‚ã(6‚ÿ-;‚Œ ƒ›#ƒ§&ƒ´ (ƒ¾!*ƒÄ#+ƒÉ$,ƒÊ$-ƒÈ$,ƒÂ#+ƒº!)ƒ° 'ƒ¤%ƒ˜#ƒ‰ƒ÷+:‚Û'4‚À".‚¨)‚’$‚û+?�Ø%7�º 0�Ÿ*�‰%�ï(A€Ò#:€º4€ª0€�-€’*€Œ)€‡(€ƒ'€‚'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€€'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€‹'€Ž(€“)€š+€£-€²0€Ä 5€Ý%:€ü*A€’%�ª+�Ç!1�ê(:�‰!‚¢&‚¼ ,‚Ú&2‚ú+9‚� ƒ¢#ƒµ 'ƒÈ#*ƒØ&.ƒé)0ƒö+3ƒþ.5ƒƒ„ƒ„‚„ý-5ƒò+2ƒä(0ƒÕ&-ƒÂ"*ƒ¯&ƒœ"ƒŠƒó*6‚Ó$0‚´*‚›$‚„ ‚ß&8�¾ /�¢)�‹$�ò(?€Õ#9€¿4€­/€¡,€˜*€’)€Ž(€‹'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€ˆ'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€Ž'€�'€‘(€’(€–)€œ*€¥,€³/€Ã!3€Û%8€ú*?€�$�©)�Ç"/�í(8�Œ ‚¥&‚Ä",‚å)2‚†ƒ›!ƒ³ &ƒÊ$*ƒã).ƒû-3ƒ‰„”„�„¤ „§ „¨!„¦ „£ „›„‘„‡„ö,2ƒÝ'-ƒÅ#)ƒ¬$ƒ– ƒ�ƒÞ&1‚»!*‚Ÿ%‚…‚â'6�¿ .�¡(�Š#�ð)=€Õ$7€¿ 2€®.€£+€›)€•(€’(€�(€�'€Ž'€�'€�'€�'€�'€�'€�'€�'€�'€�'€�'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€”(€•(€˜(€�)€¤+€±-€À"1€Ö&5€ó,;€Œ"�£'�Á#-�å*6�‰‚£%‚Ä$+‚ê+2‚‰ƒ¡!ƒ½$'ƒØ)+ƒø01ƒ�„œ„¬! „º$#„Å&$„Î(&„Ô)'„Õ*'„Ò('„Ì'%„Ã%$„¶#"„§ „—„ˆ„ï-0ƒÒ(*ƒµ"%ƒ› ƒ„ƒß*0‚»#)‚�#‚ƒ‚Ü(3�¸!+�œ&�†!�é*:€Î%4€¼"0€¬,€£*€œ)€—(€”(€”'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€’'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€—'€—'€—(€š(€ž)€¤*€­!+€¼$/€Î(3€è-8€…�›%�·#*�Ü+2�ƒ‚Ÿ"‚À%)‚ä-0‚‰ƒ£ !ƒÀ''ƒâ.,ƒƒ„—„«# „¿'#„Ó,&„á/)„í3+„ö5-„ü7.„ý7.„û7.„ô4-„ê2+„Þ/(„Ï*%„»&"„¦!„‘„ü31ƒÚ,+ƒº%%ƒ� ƒƒƒÛ,.‚¶$'‚—!‚ü19�Ñ(/�°")�•#���á,7€É'2€·#-€«!+€£)€ž(€š(€—(€—'€•'€•'€•'€•'€•'€•'€•'€•'€•'€•'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€œ'€� (€ !(€¥!)€¬#*€·&,€È)0€ß.5€û4;€‘"�­#'�Î+.�ù47�• ‚¶&'‚Ý..‚…ƒ " ƒ¿(&ƒã0+ƒ‡„�"„µ'!„Ë-$„à2)„ó9,„‚…Š!…�#…“$…”$…’$…Ž#…ˆ!…€…î7,„Û1(„Æ+#„®% „— „�„Û/*ƒ¶'$ƒ˜ ƒý54‚Ñ-,‚¬$%‚�‚ì24�Ã),�¥"&�Œ!�ô29€×,4€Â(/€´%,€ª#*€¤!)€Ÿ!(€œ (€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€š'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€Ÿ"'€ "(€¢#(€¦$(€¬%)€´(+€Á+-€Ô.2€ï47€‰� #$�¾**�æ32�Š‚©&#‚Î.*‚ü82‚˜"ƒº)$ƒß2*ƒ…„ž#„·*!„Ï1%„ç8*„ÿA/„‹$…–(…Ÿ+…¦-…«/ …¬0 …ª/ …¥-…�*…“'…ˆ#…ù?.„á7)„È/$„±( „—"„ÿ:0ƒÔ0(ƒ±("ƒ’!ƒï60‚Ä,(‚ #!‚„‚Ú0/�¶((�™"#�ƒ�æ25€Í-1€¾)-€²&*€©%)€¤$(€¢#(€Ÿ"'€Ÿ"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€ž"'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€á[•á[•á[•á[•á[•á[•á[•£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€¥&(€¦&(€«((€±**€½++€Ë//€á43€€�”#!�®)&�Ò1,�ý<5�›$ ‚¾,&‚è7.‚Ž"ƒ®)!ƒÓ2'ƒ€„™$„²+ „Ì4$„ç=*„‚#…�(…ž.…¬2 …·7#…À;%…Æ>&…Ç>&…Ä<&…¾:%…´7#…¨1 …›,…Œ'…üD.„à:)„Å2#„«*„’#„ô:,ƒÉ/&ƒ¥'ƒ† ƒÜ4,‚³*%‚’"‚ð83�Ç/*�§&$��!�ö97€Ú32€Ç.-€¸++€°()€ª'(€¦&(€¥&(€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€£&'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€á[•á[•á[•á[•á[•á[•á[•á[•á[•¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¨)'€©*(€¬*(€±,)€¸-*€Å0,€Õ50€ï;5€ˆ!�Ÿ'"�¼/(�ä8/�‹"‚ª*"‚Ó4)‚� ƒŸ'ƒÃ0$ƒí;+ƒ�$„¨,„Ã4#„à?)„ÿJ/„�+…¡2…²9"…Ã@%…ÑF(…ÜL+…ãO-…åP-…âN,…ÚJ+…ÍE(…¾>%…®7!…�0…Œ*…÷G-„Ø<'„¼2"„¢)„‰"„á9)ƒ¹."ƒ–&ƒõ=/‚Ç2'‚¢( ‚„ ‚Ø6-�µ,&�—%!�‚ �æ93€Ñ4/€Á0+€·-*€°+(€¬*(€©)(€¨)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¦)'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬-'€¬-'€®-(€²/(€·0)€À2*€Ì6-€à;1€üB6€�&�©,#�Ê5)�ö@2�—'‚º0$‚æ<,‚�%ƒ¯. ƒÙ9&ƒƒ#„›*„µ4 „Ó>&„ôK-„‹,…ž4…²="…ÇF&…ÛP+…íY.…û`2…‚2†ƒ3†�2†ø^2…èV.…ÖM*…ÁD%…­;!…™2…‡*…ëG+„Ë;$„®1„•(„üB,ƒÍ6%ƒ¦,ƒ‡#ƒÙ9)‚°."‚�%‚é=/�Â2(�¡*"�£Bl€’;a€…6X€É4,€¾2*€´/)€±.(€®-(€¬-'€¬-'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¬,'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€¯0'€±0'€±1'€´2(€¶2(€¼4)€Ç6+€Õ:-€ë@2€„$�™*�´2$�×;+�ƒ$‚¢-‚È7&‚øD.‚š*ƒÀ4!ƒëA)ƒ‹(„¤0„Á;#„áH)„‚+…–4…¬> …ÃI%…ÛT+…óa0…„6†Žˆk†”Žp†–�q†“�o†Œ;†‚5†ì^.…ÔR*…½F$…¦;…‘1…üS.„ÙE(„¹8!„ž.„…%„Þ='ƒµ2 ƒ’(ƒìA,‚½4#‚™*‚…5X�á[•€ÀM€§Co€•"ƒ“;a‚ôc¢�ÊR†�¨Do�Œ8]�ë_œ€ÈQ„€­Fs€™=e€Š7[€€3T€òb é^›ä\—â[–á[•á[•¹9'€¹9'€¹9'€¹9'€¹9'€¹9'€¹9'€¹9'€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€á[•á[•á[•á[•á[•á[•á[•“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€¾='€Á>(€Â>(€Ç@(€ÎB)€ÙE+€ëK/€‚)�”/�¬6!�ÌA&�ùO-�™0‚½eƒ�4UƒØW�‚´Iw‚•f€‹8\€€3U€òb¡ê^›å\˜â[–á[•á[•á[•á[•á[•á[•á[•¾='€¾='€¾='€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€ÂB'€á[•á[•á[•á[•á[•á[•á[•“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~á[•á[•á[•á[•á[•á[•á[•“@z~“@z~“@z~“@z~“@z~á[•á[•ÂB'€ÂB'€ÂB'€ÂB'€ÄB'€ÅB(€ÈC(€ÍD(€ÔG)€ßK+€òP/€…,�—3�°;!�ÓF&�þU.�œ3‚Á@!‚ðO)‚•1ƒ³=ƒØL%ƒ�/„š:„·G!„ØX'„þk.„”A…¬N!…Ç^&…æq-…„D†–�q†«¤�†Âº’†ÛÒ¥†î䳆ÕË †¥Cn…’;`…€3U…àZ”„ÃN�„©Dp„’;`„úe¦ƒÖVŽƒ¶Ixƒš>fƒ�4VƒÙW�‚µIx‚–f€‹8\€€4U€ób¡ê^›å\˜â[–á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€ÈF'€á[•á[•á[•á[•á[•á[•“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~á[•á[•á[•ÈF'€ÈF'€ÉG(€ÌG(€ÑJ(€ØK)€ãP+€÷V/€‰/�›6�´>!�ÖK&�‚-‚Ÿ7‚ÅD!‚õS)‚”4ƒ²AƒÖP%ƒ€1„™=„µJ „Ö['„ûo.„’C…ªQ …Åb&…âv,…�F†“�o†§Ÿ}†»³�†ÍÄš†ÕË †±Gu… @j…Ž9^…ûe¦„ÜY’„ÀM„§Cn„�:_„ød¤ƒÔUŒƒ´Iwƒ˜=eƒ€4Uƒ×W�‚³Hw‚•f€Š8\€€3U€òb¡ê^›å\˜â[–á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€ÌK'€á[•á[•á[•á[•á[•á[•“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~á[•á[•á[•á[•á[•â[–ÔN(€ÞQ)€éU+€ú[/€Š2��8�¶B �×N&�‚/‚ 9‚ÆG!‚ñX(‚‘6ƒ¯CƒÒS$ƒÝY’‚ƒ5Wƒœ?gƒÑ^%„õr-„ŽE…¥S…¾c%…Úu+…øŠ2…ŒQ†œ–v†¬¥�†¢Ak…¥Cn… @j…”f„…6X„ç]™ƒÆPƒƒ©Dpƒ�:_ƒób¡‚ÌR‡‚ªEq‚Ž9^‚ì_��ÄO‚�£Bl�ˆ7Z�å]˜€ÄO‚€ªDp€–=d€ˆ7Z€þf¨&Fš{%D•{$B’{â[–á[•á[•á[•á[•á[•á[•á[•á[•á[•á[•ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€ÖV'€á[•á[•á[•á[•á[•á[•“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~á[•á[•á[•â[–å\—é^›òb €3T€Š7[€˜=e€¬Er€ÆP„€é^š€Š7[�¥Bm�ÆPƒ�î`ž��9_‚«Eq‚ÌR‡‚òb ‚�9^ƒ§CoƒÃO�ƒâ[–ƒ‚4V„”e„ªEq„»K|„ÊQ†„ÖVŽ„ÞY“„àZ”„ÜY’„ÓUŒ„ÆPƒ„·Jy„¦Cn„•f‚�4V‚ØW��´Iw�—=d�þf¨€×W�€¹K{€3^Ð{.Tº{*M«{'H {&E˜{$C”{$B’{$A‘{$A�{#A�{#A�{á[•á[•á[•á[•á[•á[•á[•“@z~“@z~“@z~“@z~“@z~áa'€áa'€áa'€áa'€áa'€áa'€áa'€á[•á[•á[•á[•á[•“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~á[•â[•ã\—ç]™î`žùe¥…6X€‘;`€¢Al€¹K{€×W�€ýf¨€––€c7…€®bë˜UÍ„J±q?˜a6‚2]Ì}+N¬}$B‘}b7„~¥]ß}ŒN¼}$C“|?sý{7dÝ{0YÄ{,Q²{)K¥{&Gœ{%D–{$C“{$B‘{$A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~”A{~å\˜ê^›ób¡€3U€Š7[€—=d€ªDq€ÂN�€á[•€„5W�›?g�¸Jz�ÚX‘��4U‚˜=d‚²Hv‚ÏS‰‚ðaŸ‚‰7[ƒœ?hƒ°GuƒÅO‚ƒÙX�ƒí`�ƒ§I‹‚±M“‚¹Q™‚–TÉ€—UË€•TÈ€�Q€ŠM¹€�I®€xC¡€m=“€c7…€±cîœXÒ‰M¸wC g:Š6cÛ}.U»}'Hž}!=†}8gâ|/W¿|(J¢|"?‹|•Gª¶O—�ÌY©�ãc¼�úmÏ�‡;p‚r@š€yD¢€}F©€€H­€�H®€€H¬€|F§€wC €p?—€h;�€`6�€®bêœXÒ‹N»zE¤!<…~9hæ}1ZÆ}*M©}$A�}=oõ|3^Ð|,P±|%D—| ;‚|·g÷|£[Û|“SÆ|*L©{'HŸ{%E˜{$C”{$B’{$A‘{$A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~“@z~#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~”@z~•A{~—B}~›C€~¡F…~ªJ�~¸P™~ÊX¨~ãc¼~�8kuBž}ˆM¸} Z×}»iû}m=“~H¬~”SÇ~«`æ~b7„p?–~Gª�O¾œXÒ«`æ¹høb7„€g:‹€k<�€n>”€n>”€m=“€j<�€f9Š€a6‚€µfô§^á˜U͉M¸%E˜~!=†~:kì}3]Í},P±}&E˜} ;‚}7eß|/V¾|(J£|#?Œ|”zE¥‡Lµ“SÆžYÕ©_ã±cî·gö»iû¼jýºiú¶fô¯bë¦]ßœWÑ�Q„J±wC k<�¾kÿ~§^á~-Rµ}'G�}!=ˆ}:jê|2[É|+N­|%D•|h;Œ}¸g÷|2\Ì{.T¸{*Mª{'I {&E™{%C•{$B’{$B‘{$A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~tAœ|“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~#A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~“@z~“@z~#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{“@z~“@z~“@z~“@z~tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|“@z~”@z~•A{~—B}~›D�~H«|‡Lµ|‘QÃ|ŸYÖ|±cî|d8†}r@™}ƒI°}—UË}¯bë}e8‡~tAœ~„J²~—UË~ª`å~_5€i;ŽtAœ~G©‡L¶�PÁ–TÊ›WÑžYÕŸYÖ�XÔšVÏ”SÈ�O¾…J³{E¦q?˜g:йhù~¥]Þ~-Rµ}'HŸ}"?‹}ˆ|;mð{5aÕ{›WÐ|ŽP¿|…J²|~G©|%D—{$C“{$B‘{$A‘{$A�{#A�{#A�{tAœ|“@z~“@z~“@z~“@z~“@z~“@z~“@z~“@z~tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{tAœ|tAœ|#A�{#A�{#A�{#A�{#A�{#A�{#A�{tAœ|tAœ|tAœ|tAœ|tAœ|“@z~“@z~#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|tAœ|uB�|vBŸ|yD¢|}F¨|‚I°|‹N»|–TÊ|¥]Þ|¸gø|h:Œ}vBŸ}‡L¶}›WÐ}²dï}e9ˆ~sA›~ƒI°~“RÆ~¤\Ü~µfó~c7…k<�r@šyD£GªƒI°…K³†K´…J²‚I®}F©wC¡q?—i;�a6‚°cí~ŸYÖ~�PÀ~Gª~p?–~–p?—o>•m=’i;�d8‡_5€²dï~¤\Ý~–TÊ~ˆL·~zE¥~m=“~ª|J@¯|NC·|RFÂ|XKÏ|_Qà|gYô|91†}>6“}E;£}LA´}THÇ}\OÚ}eWî}7/�~;3‹~?6•~C9�~F<¥~H>«~J@°~KA²~LA³~KA²~J?¯~H>ª~E;£~B8›~>5’~:2‰~k\þ}cUé}ZMÕ}RFÂ}'G�|#@Ž| :€|:jé{5`Õ{0YÃ{-Rµ{QE¿|MBµ|J?®|G=©|F<¥|E;£|$B‘{$A�{#A�{#A�{#A�{#A�{#A�{D;¡|D;¡|D;¡|D;¡|—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{#A�{#A�{D;¡|D;¡|D;¡|D;¡|D;¡|#A�{#A�{#A�{#A�{#A�{D;¡|D;¡|D;¡|D;¡|D;¡|—V¾}—V¾}—V¾}#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¢|E;¢|E;£|F<¤|G=§|H>«|K@°|NC¸|RGÂ|XKÏ|^QÞ|fXñ|7/ƒ}=4�}B9œ}H>«}ODº}VJÊ}]OÚ}cUê}j[ù}70ƒ~:2‰~<3Ž~=5‘~>6“~?6”~>5“~=4‘~;3�~91ˆ~7/�~hYõ}aTæ}[NÖ}THÆ}MB¶}G=§}A8™};3Œ}9hå{4_Ò{0XÂ{VJË|QF¿|MB¶|J?¯|H>ª|F<¦|E;¤|$B‘{$A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{D;¡|D;¡|—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{#A�{#A�{#A�{—V¾}—V¾}D;¡|D;¡|#A�{#A�{#A�{#A�{#A�{#A�{#A�{D;¡|—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{—V¾}—V¾}—V¾}D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|D;¡|—V¾}—V¾}—V¾}—V¾}˜V¾}˜V¾}˜V¿}E;£|F<¥|G=§|H>«|K@±|NC¸|RFÁ|WKÍ|]PÛ|dVë|l\þ|:2‰}?6”}D: }I?­}NC¹}THÅ}XLÑ}]PÛ}aSå}dVì}fXñ}hYõ}hYö}gYô}fWð}cUê}`Râ}\OÙ}WKÎ}RGÂ}MB¶}H>©}B9�}=5‘}91†}i[ù|3]Î{B_®z>Y¤z*Mª{MB¶|J@¯|H>ª|F<¦|E<¤|E;£|E;¢|D;¢|D;¡|#A�{#A�{#A�{D;¡|D;¡|D;¡|—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{#A�{#A�{#A�{#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}#A�{1Gƒz#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}˜V¾}˜V¾}˜W¿}™WÀ}›XÂ}G=§|H>«|K@°|MB·|QF¿|UIÊ|[NÖ|`Sä|gYó|7/‚};2‹}?6”}C9ž}G=§}K@°}NC¸}QF¿}SHÅ}UIÉ}VJÌ}WJÌ}VJË}UIÈ}SGÄ}PE½}MB¶}J?®}F<¥}B8›}>5’}:2‰}6.€}eWï|_Qà|YMÒ|=X¡z:T™zMBµ|J?¯|H>ª|F=§|F<¤|E;£|E;¢|D;¢|D;¢|#A�{#A�{#A�{#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{1Gƒz#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{#A�{#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}˜V¾}˜V¾}˜W¿}™WÀ}›XÂ}G=§|H>«|J@¯|MBµ|PD¼|SHÅ|XKÏ|]PÛ|bTç|hYõ|7/�}:1ˆ}=4�}?6–}B9œ}D;¡}F<¦}G=©}H>«}H>«}H>ª}G=¨}F<¥}D: }A8›}?6”}<3Ž}91†}l]ÿ|fXñ|aSä|[NØ|WJÍ|RGÃ|9R—z7O‘zJ?®|H>ª|F<¦|F<¤|E;£|E;¢|D;¢|D;¢|#A�{#A�{#A�{1Gƒz#A�{#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}1Gƒz—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{1Gƒz1Gƒz#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÖiÁ~×iÂ~ÙjÃ~ÜlÆ~ŸZÈ}I?®|LA³|NC¹|QFÀ|UIÈ|YLÑ|]PÛ|k=‡~p@�~uB“~zE™~~Gž~:2Š}<3�}=4�}=5‘}=5‘}=5‘}<4�};3Œ}:2‰}80…}6/€}iZ÷|dVí|`Râ|\OØ|XKÏ|THÆ|PE¾|MC·|6N�z5LŒzG=©|F<¦|E<¤|E;£|E;¢|D;¢|D;¢|D;¡|#A�{#A�{#A�{1Gƒz#A�{#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{1Gƒz1Gƒz#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}1Gƒz—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}#A�{#A�{#A�{1Gƒz1Gƒz#A�{#A�{#A�{—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÖiÁ~×iÁ~ÙjÃ~ÛkÅ~ÞmÈ~ãoÌ~èqÑ~îuÖ~öxÝ~þ|å~ƒ@vˆBz�E’Gƒ–I‡n>Š~q@�~sA�~tB’~j[û|j[û|j[ú|iZø|gYô|eWï|cUé|`Rã|]PÛ|ZMÔ|WJÍ|THÆ|QE¿|NC¹|LA³|J?¯|H>«|G=¨|F<¥|E;¤|E;£|E;¢|D;¢|D;¢|D;¡|D;¡|D;¡|ÕhÀ~ÕhÀ~1GƒzÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}1Gƒz1Gƒz—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}1Gƒz1Gƒz—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÖiÀ~×iÁ~ØjÂ~ÚkÄ~ÜlÆ~àmÉ~äoÍ~érÑ~îuÖ~ôxÜ~û{â~�?t„@w‡BzŠC|�E�F�‘G‚g;‚~h;‚~]PÝ|]PÛ|\OØ|ZMÕ|XLÑ|VJÌ|THÇ|RGÂ|PE½|NC¸|LA³|J@¯|I>¬|G=©|F<¦|F<¥|E;£|E;¢|E;¢|D;¢|D;¢|D;¡|D;¡|ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~1GƒzÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}Ój:Ój:Ój:Ój:Ój:1Gƒz1GƒzÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}1Gƒz1Gƒz—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÖhÀ~ÖiÁ~×iÂ~ÙjÃ~ÛkÅ~ÝlÇ~ànÊ~äoÍ~èqÐ~ìsÔ~ðuØ~õxÜ~ùzà~ý|ä~€?s‚?uƒ@vƒ@vƒ@vƒ@v‚@u�?t€>sü{ã~øyß~ôwÛ~ïu×~ësÓ~æqÏ~I>¬|H=©|G=§|F<¥|E<¤|E;£|E;¢|D;¢|D;¢|D;¢|D;¡|ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}Ðe:Ðe:Ðe:Ðe:Ðe:Ðe:Ðe:Ðe:Ðe:Ðe:Ðe:Ðe:1Gƒz1GƒzÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÖiÁ~×iÁ~ØiÂ~ÙjÃ~ÛkÅ~ÝlÇ~ßmÉ~ânË~äpÎ~çqÐ~êrÓ~ítÕ~ïu×~ñvÙ~òwÚ~ówÛ~ówÛ~ówÛ~òvÚ~ðvØ~îu×~ìsÔ~érÒ~çqÏ~äoÍ~ánÊ~ÞmÈ~ÜlÆ~ÚkÄ~ÙjÃ~×iÂ~ÖiÁ~ÖiÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}Ë`:Ë`:Ë`:Ë`:Ë`:Ë`:Ë`:Ë`:Ë`:Ë`:Ë`:Ë`:Ë`:Ë`:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÖhÀ~ÖiÁ~×iÁ~ØiÂ~ÙjÃ~ÚkÄ~ÛkÅ~ÝlÇ~ßmÈ~ànÊ~ânË~ãoÍ~åpÎ~åpÎ~æpÏ~æpÏ~æpÏ~åpÎ~äpÍ~ãoÌ~ânË~àmÊ~ÞmÈ~ÝlÇ~ÛkÅ~ÚjÄ~ØjÃ~×iÂ~ÖiÁ~ÖiÁ~ÖhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~È\:È\:È\:È\:È\:È\:È\:È\:È\:È\:È\:È\:È\:È\:È\:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÖhÀ~ÖiÁ~ÖiÁ~×iÂ~ØjÂ~ÙjÃ~ÚjÄ~ÚkÅ~ÛkÅ~ÜlÆ~ÝlÇ~ÝlÇ~ÞlÇ~ÞlÈ~ÞlÇ~ÝlÇ~ÝlÇ~ÜlÆ~ÛkÅ~ÚkÄ~ÙjÄ~ØjÃ~ØiÂ~×iÁ~ÖiÁ~ÖiÀ~ÖhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÃW:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~Í[:Ð\:Õ^;Ø^;Þ`;ãa;éc=ïg=õi=ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÖhÀ~ÖiÁ~«E"€³G"€¹J#€ÁL#€ÉP$€ðùÞÙjÃ~ÙjÃ~ÙjÃ~ÙjÃ~ÙjÃ~ØjÃ~ØjÂ~ØiÂ~×iÂ~×iÁ~ÖiÁ~ÖiÁ~ÖhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:¾Q:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÀR:ÃR:ÅT:ÈT:ËV:ÍW:ÐW;ÕY;Ø[;Þ\;ã^=éa=ïc>øe>þi>ƒ5€ˆ7€�9 €’; €—=!€œ>!€£A"€ªC"€°E"€·G#€¿I#€íöÜíöÜíöÜîöÜÖiÁ~ÖiÁ~ÖiÁ~ÖiÁ~ÖiÁ~ÖiÁ~ÖhÀ~ÖhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~»N:»N:»N:»N:»N:»N:»N:»N:»N:»N:»N:»N:»N:»N:»N:»N:»N:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~»N:»N:»N:¾O:ÀO:ÀO:ÃQ:ÅR:ËR:ÍT;ÐV;ÕW;ÛY;á[=æ\=ì`>õa>ûe>‚3€†5€‹7 €�8 €•:!€œ"€¨A"€¯C"€·E#€¾H$€ìõÛìõÛìõÛìõÛìõÛÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:¶I:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~¶I:¶I:¶I:¶I:¶I:¸I:¸K:»K:¾K:ÀL:ÃN:ÅN:ÈO;ÍQ;ÐR;ÕT;ÛV=áW=é[>ï\>ø`>þa?ƒ2€ˆ4 €�5 €“7!€˜9!€Ÿ;"€¤>"€¬@"€³B#€¼E$€ìõÛìõÛìõÛìõÛìõÛìõÛÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:³F:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~³F:³F:³F:³F:³F:³F:³F:³F:³F:¶F:¸H:¸H:»I:¾I:ÃK:ÅL;ÈN;ÍO;ÓQ;ØR=ÞT=ãV>ìY>ò[>û^?‚0€†1 €‹3 €�5!€—7!€œ9"€£;"€ª>"€±@#€¹B$€ìõÛìõÛìõÛìõÛìõÛìõÛìõÛÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~³F:³F:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~—V¾}—V¾}—V¾}—V¾}—V¾}—V¾}ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:¯B:±B:±B:³B:¶D:¶D:»E:¾F:ÀH;ÅH;ÈI;ÍK;ÓN=ØO=ÞQ>æT>ìV>õY?þ[?ƒ/ €ˆ0 €�2!€“4!€˜6"€Ÿ8"€¦:"€¬<#€³?$€ìõÛìõÛìõÛìõÛìõÛìõÛìõÛìõÛÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~†0 €‚. €û[?ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~¯B:¯B:¯B:¯B:¯B:¯B:¯B:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:¬=:¬>:¬>:¯>:±?:±?:³A:¸A:»B;¾D;ÃE;ÈF;ÍH=ÓI=ØK=ÞN>æO>ìR?õT?þWAƒ- €ˆ/!€�0!€“2"€˜4"€Ÿ6"€¤8"€¬:#€³<$€ìõÛìõÛìõÛìõÛìõÛìõÛìõÛµ=$€­;#€¦8#€¡6"€š4"€“2"€Ž0!€Š/!€…- €€, €øV?ïR?æQ>áN>ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~¬>:¬=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:ª=:§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::§::ª::ª;:¬;:¯;:±=:³>:¶>:¸?;¾A;ÀB;ÅD;ËE=ÐF=ÕI>ÞK>ãL>ìO?õR?ûTA‚+ €ˆ-!€�/!€’0"€˜2"€ž4"€¤6#€ª8#€¯:$€ìõÛìõÛìõÛìõÛìõÛìõÛ±:$€¬8#€¤6#€Ÿ4"€˜2"€“0"€Ž/!€ˆ-!€ƒ, €þVAõR?ìO?æN>ÞK>ØI>ÓH=ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ª;:ª::§::§::§::§::§::§::§::§::§::§::£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:£6:¥6:¥6:¥6:§8:§8:ª9:¬9:¯::±:;¶;;¸=;¾>;Ã?=ÈA=ÍD=ÓE>ÛF>áI>éK?ïN?øQA€) €…+!€Š,!€�."€•0"€š0"€Ÿ2"€¤4#€ª5#€­7$€¯8$€±8$€±8$€­7$€ª6#€¦4#€¡3#€œ1"€—0"€�."€‹,!€†+!€‚* €ûQAòN?éL?ãI?ÛH>ÕE>ÍD=ÈB=Ã?=ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ª9:ª8:§8:¥6:¥6:¥6:£6:£6:£6:£6:£6:£6:£6:£6: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4:£4:£4:¥4:¥5:§5:ª6:¬8:±8;³9;¸:;»;;À==Å>=ËA=ÓB>ØD>ÞF?æH?ïK?øLAþOAƒ)!€ˆ+!€�+"€’-"€—/"€œ0"€¡1#€¤2#€¦3#€¨4$€¨4$€¨3#€¤2#€¡1#€ž0"€˜/"€“."€Ž,"€Š+!€…)!€€(!€øNAïKAéI?áF?ÛE>ÓB>ÍA=È?=Ã>=¾;;¸:;³9;±9;¯8:ª6:§6:§5:¥5:£4:£4: 4: 4: 4: 4: 4: 4: 4: 4: 4: 4:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:ž0:ž0:ž0: 0:£1:¥1:§3:ª4:¬4;±5;³6;¸8;¾9=Ã;=È==Í>>Õ?>ÛB?áD?éF?òHAûKA€'!€…'!€Š)"€Ž*"€’+"€•,"€˜-"€œ.#€ž/#€ž/#€ž/#€œ/#€š."€—-"€“+"€Ž+"€‹)"€†(!€‚'!€ûKAòIAìFAãE?ÞB?ÕA>Ð>>È=>Ã;=¾:=¸9=¶6;±6;¯5;ª4:§3:¥3:£1: 1: 0:ž0:ž0:œ0:œ/:œ/:œ/:œ/:œ/:œ/:œ/:œ/:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~ÕhÀ~™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:™-:™-:œ-:œ-:ž.: .:£/:¥0:ª1;¬1;±3;¶4;¸5=¾8=Ã9=Ë:>Ð;>Õ>?Þ??ãB?ìDAòFAûHB€%!€…&"€ˆ'"€‹("€Ž)"€’*"€“*"€•+"€•+"€“*"€’*"€�)"€�("€Š'"€†&"€‚%!€þHBõFAìDAæBAÞ??Ø>?Ð=>Ë:>Å9>À8=»6=¶5=±4;¯3;ª1;§0;¥/: /: .:ž.:œ-:™-:™-:™,:™,:™,:™,:™,:™,:™,:™,:™,:™,:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:ôÔ¤€•*:•*:•*:•*:•*:•*:•*:Ä“z€•*:•*:•*:†LI€•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:•*:—*:—*:™+:™+:œ,:ž,: -:¥.;§.;ª/;¯0;³1=¶3=»4=À6>È8>Í9>Ó:?Û=?á>?æAAïBAõEBþFB‚$!€…$"€ˆ%"€Š&"€‹'"€�'"€�'"€�'"€�'"€‹'"€ˆ&"€…%"€‚$!€þFBøEBïBAéAAá?AÛ=?Õ;?Í9>È8>Ã6>¾5=¸4=³1=¯0;¬/;§/;¥.;£-:ž,:œ,:œ+:™+:—+:—*:—*:•*:•*:•*:•*:•*:•*:•*:•*:•*:“':“':“':“':“':Â’y€òУ€ß·’€“':“':ëÈœ€ôÔ¤€†JI€“':ôÔ¤€“':•\U€†KI€“':¥4FôÔ¤€—*=ø~‚“':ẕ€Ð£…€’VQ€Ÿf]€ôÔ¤€†KI€¹…q€ôÔ¤€“':Â’y€òУ€“':ôÔ¤€†KI€¹…q€ôÔ¤€“':û‚„û„†ẕ€“':“':“':“':Ô¨ˆ€“':ƒGF€ágp“':“':“':“':“':“':•':•(:—(:™):œ):ž*: +;¥,;§,;¬-;¯.=³/=¸1=¾3>Ã4>È5>Ð6?Õ9?Û:?á;Aé>Aï?BõABûBBþDB‚""€ƒ#"€…#"€…#"€…#"€ƒ#"€ƒ#"€€""€þDBøBBòABì?Aæ=Aá;AÛ:?Ó8?Í6?È5>Ã4>¾1>¸0=³/=¯.=ª-;§,;£+; +;ž*:œ):™):—(:•(:•':“':“':“':“':“':“':“':“':“':“':“':“':Ž%:Ž%:Ž%:Ž%:Ž%:ôÔ¤€ôÔ¤€Ž%:Êž‚€Ž%:ôÔ¤€ëÈœ€Ž%:Ž%:³ARŽ%:ôÔ¤€ôÔ¤€‹QN€�QO€Ž%: 1F±|l€Ž%:Ú¯�€Ž%:Ž%:ôÔ¤€ôÔ¤€Ž%:ß¹“€ôÔ¤€Ž%:ôÔ¤€ôÔ¤€Ž%:ôÔ¤€Ž%:ß¹“€ôÔ¤€ðС€Ž%:Ž%:د�€Ž%:Ž%:Ž%:Ž%:Ž%:ðС€ôÔ¤€ôÔ¤€Ž%:Ž%:Ž%:Ž%:Ž%:Ž%:�%:�%:“%:•%:•%:—':™':ž(; );£*;§+;¬,=¯-=³.=¸/=¾0>Ã1>È3>Í5?Ó6?Ø8Aá9Aæ:Aé=Bï>Bõ>Bø?BûABþADþADþADûADûABø?Bò>Bï=Bé;Aã:AÞ9AØ8?Ó6?Í4?È3>À1>»0>¶/=³-=¯,=ª+;¥*;£*; );œ(:™':—':•%:“%:“%:�%:�%:Ž%:Ž%:Ž%:Ž%:Ž%:Ž%:Ž%:Ž%:Ž%:Ž%:Œ!:Œ!:Œ!:Œ!:Œ!:¨pe€òÒ£€éÆœ€Œ!:Œ!:·‚q€Ø­�€Œ!:òÒ£€Œ!:Œ!:¹ƒq€ƒFH€Œ!:Ž";ôÔ¤€¡h`€‹PO€—)BØ­�€Œ!:Œ!:ôÔ¤€ôÔ¤€€CF€˜]X€îÊž€Œ!:¨pe€òÒ£€Œ!:ôÔ¤€€CF€˜]X€ôÔ¤€�$=òx‚Ø^n¼ˆv€Œ!:ẕ€Ìžƒ€Œ!:Œ!:Ž#;õ|„ìr~Œ!:Œ!:Œ!:Œ!:Œ!:Œ":Œ":Ž":Ž#:�#:“#:•$:—%:œ%;ž%; ';¥(;§)=¬*=±+=¶,>»->À.>Å/?Ë1?Ð3?Ó4?Ø5AÞ6Aá8Aæ9Bé9Bì:Bì:Bï:Bï:Bì:Bì:Bé9Bã8Aá8AÛ6AØ5AÓ4?Í1?È0?Ã/>¾.>¸->³,=±+=¬*=§)=£(; ';ž%;™%;—%:•$:“#:�#:Ž":Ž":Œ":Œ":Œ!:Œ!:Œ!:Œ!:Œ!:Œ!:Œ!:Œ!:Œ!:Œ!:Œ!:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ôÔ¤€ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ôÔ¤€ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:Š:Š:Œ :Ž :�!:“!:•";—#;œ#;ž$;£%;¥%=ª'=¯(=±)>¶*>»+>À,?Å-?È.?Í/?Ð0AÕ1AØ3AÛ3AÞ4AÞ4Aá4Aá4AÞ4AÞ4AÛ3AØ3AÕ1AÐ0AÍ/?È.?Ã-?¾,>»+>¶*>±)>¬(=ª'=¥%= %;ž$;™#;—";•";“!:�!:Ž :Œ :Š:Š:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:ˆ:„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9„9†9†:†:ˆ:ˆ:Š:Œ:Ž:� :“ ;—!;™";œ#; $=¥$=§%=¬%=¯'>³(>¸)>¾*>À+?Å,?È-?Ë.?Í.AÐ/AÓ/AÓ0AÕ0AÕ0AÓ/AÓ/AÐ/AÍ.AË-?È-?Ã,?À+?»*>¶)>³(>¯'>ª%=§%=£$= #;œ";™";•!;“ ;�:Ž:Œ:Š:ˆ:ˆ:†:†:„9„9„9„9„9„9„9„9„9„9„9„9ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~€9€9‚9‚:„:†:ˆ:Š:Œ:� ;“!;•";™#;œ$= $=£%=§%=¬'>¯(>±)>¶*>¸*>»+?¾,?À,?Ã,?Ã-?Ã-?Ã-?Ã-?À,?À,?¾+?»+?¸*>³)>±)>¯(>ª'=§%=£%=ž$=œ#;—";•";“!;Ž ;Œ:Š:ˆ:†:„:‚9‚9€9€9ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ü7r~ \ No newline at end of file diff --git a/crates/exav-viewer/demo/examples/files/images/sunset.hdr b/crates/exav-viewer/demo/examples/files/images/sunset.hdr new file mode 100644 index 00000000..4e99f890 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/images/sunset.hdr differ diff --git a/crates/exav-viewer/demo/examples/files/media/canon.flac b/crates/exav-viewer/demo/examples/files/media/canon.flac new file mode 100644 index 00000000..3bfd35f2 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/media/canon.flac differ diff --git a/crates/exav-viewer/demo/examples/files/media/canon.m4a b/crates/exav-viewer/demo/examples/files/media/canon.m4a new file mode 100644 index 00000000..aac119b0 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/media/canon.m4a differ diff --git a/crates/exav-viewer/demo/examples/files/media/canon.mp3 b/crates/exav-viewer/demo/examples/files/media/canon.mp3 new file mode 100644 index 00000000..a8a5fdd5 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/media/canon.mp3 differ diff --git a/crates/exav-viewer/demo/examples/files/media/canon.ogg b/crates/exav-viewer/demo/examples/files/media/canon.ogg new file mode 100644 index 00000000..8a153fba Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/media/canon.ogg differ diff --git a/crates/exav-viewer/demo/examples/files/media/canon.opus b/crates/exav-viewer/demo/examples/files/media/canon.opus new file mode 100644 index 00000000..e12cb949 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/media/canon.opus differ diff --git a/crates/exav-viewer/demo/examples/files/media/canon.wav b/crates/exav-viewer/demo/examples/files/media/canon.wav new file mode 100644 index 00000000..484a9dd9 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/media/canon.wav differ diff --git a/crates/exav-viewer/demo/examples/files/media/testcard.mp4 b/crates/exav-viewer/demo/examples/files/media/testcard.mp4 new file mode 100644 index 00000000..49a7b38a Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/media/testcard.mp4 differ diff --git a/crates/exav-viewer/demo/examples/files/media/testcard.webm b/crates/exav-viewer/demo/examples/files/media/testcard.webm new file mode 100644 index 00000000..23b44b25 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/media/testcard.webm differ diff --git a/crates/exav-viewer/demo/examples/files/models/arm.glb b/crates/exav-viewer/demo/examples/files/models/arm.glb new file mode 100644 index 00000000..4b57871e Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/models/arm.glb differ diff --git a/crates/exav-viewer/demo/examples/files/models/bracket-ascii.stl b/crates/exav-viewer/demo/examples/files/models/bracket-ascii.stl new file mode 100644 index 00000000..31f3f09f --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/models/bracket-ascii.stl @@ -0,0 +1,254 @@ +solid bracket + facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 0.000000e+00 + vertex 0.000000e+00 2.500000e+01 0.000000e+00 + vertex 4.000000e+01 2.500000e+01 0.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 0.000000e+00 + vertex 4.000000e+01 2.500000e+01 0.000000e+00 + vertex 4.000000e+01 0.000000e+00 0.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+01 0.000000e+00 4.000000e+00 + vertex 4.000000e+01 2.500000e+01 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+01 2.500000e+01 4.000000e+00 + vertex 0.000000e+00 2.500000e+01 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 0.000000e+00 + vertex 4.000000e+01 0.000000e+00 0.000000e+00 + vertex 4.000000e+01 0.000000e+00 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 0.000000e+00 + vertex 4.000000e+01 0.000000e+00 4.000000e+00 + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + endloop + endfacet + facet normal 1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+01 0.000000e+00 0.000000e+00 + vertex 4.000000e+01 2.500000e+01 0.000000e+00 + vertex 4.000000e+01 2.500000e+01 4.000000e+00 + endloop + endfacet + facet normal 1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+01 0.000000e+00 0.000000e+00 + vertex 4.000000e+01 2.500000e+01 4.000000e+00 + vertex 4.000000e+01 0.000000e+00 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 1.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+01 2.500000e+01 0.000000e+00 + vertex 0.000000e+00 2.500000e+01 0.000000e+00 + vertex 0.000000e+00 2.500000e+01 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 1.000000e+00 -0.000000e+00 + outer loop + vertex 4.000000e+01 2.500000e+01 0.000000e+00 + vertex 0.000000e+00 2.500000e+01 4.000000e+00 + vertex 4.000000e+01 2.500000e+01 4.000000e+00 + endloop + endfacet + facet normal -1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 2.500000e+01 0.000000e+00 + vertex 0.000000e+00 0.000000e+00 0.000000e+00 + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + endloop + endfacet + facet normal -1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 2.500000e+01 0.000000e+00 + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + vertex 0.000000e+00 2.500000e+01 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + vertex 0.000000e+00 2.500000e+01 4.000000e+00 + vertex 4.000000e+00 2.500000e+01 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+00 2.500000e+01 4.000000e+00 + vertex 4.000000e+00 0.000000e+00 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 3.000000e+01 + vertex 4.000000e+00 0.000000e+00 3.000000e+01 + vertex 4.000000e+00 2.500000e+01 3.000000e+01 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 3.000000e+01 + vertex 4.000000e+00 2.500000e+01 3.000000e+01 + vertex 0.000000e+00 2.500000e+01 3.000000e+01 + endloop + endfacet + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+00 0.000000e+00 3.000000e+01 + endloop + endfacet + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+00 0.000000e+00 3.000000e+01 + vertex 0.000000e+00 0.000000e+00 3.000000e+01 + endloop + endfacet + facet normal 1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+00 2.500000e+01 4.000000e+00 + vertex 4.000000e+00 2.500000e+01 3.000000e+01 + endloop + endfacet + facet normal 1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+00 2.500000e+01 3.000000e+01 + vertex 4.000000e+00 0.000000e+00 3.000000e+01 + endloop + endfacet + facet normal 0.000000e+00 1.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+00 2.500000e+01 4.000000e+00 + vertex 0.000000e+00 2.500000e+01 4.000000e+00 + vertex 0.000000e+00 2.500000e+01 3.000000e+01 + endloop + endfacet + facet normal 0.000000e+00 1.000000e+00 -0.000000e+00 + outer loop + vertex 4.000000e+00 2.500000e+01 4.000000e+00 + vertex 0.000000e+00 2.500000e+01 3.000000e+01 + vertex 4.000000e+00 2.500000e+01 3.000000e+01 + endloop + endfacet + facet normal -1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 2.500000e+01 4.000000e+00 + vertex 0.000000e+00 0.000000e+00 4.000000e+00 + vertex 0.000000e+00 0.000000e+00 3.000000e+01 + endloop + endfacet + facet normal -1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 2.500000e+01 4.000000e+00 + vertex 0.000000e+00 0.000000e+00 3.000000e+01 + vertex 0.000000e+00 2.500000e+01 3.000000e+01 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 + outer loop + vertex 4.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+00 2.500000e+01 4.000000e+00 + vertex 6.000000e+00 2.500000e+01 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 + outer loop + vertex 4.000000e+00 0.000000e+00 4.000000e+00 + vertex 6.000000e+00 2.500000e+01 4.000000e+00 + vertex 6.000000e+00 0.000000e+00 4.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 1.000000e+00 + outer loop + vertex 4.000000e+00 0.000000e+00 1.200000e+01 + vertex 6.000000e+00 0.000000e+00 1.200000e+01 + vertex 6.000000e+00 2.500000e+01 1.200000e+01 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 1.000000e+00 + outer loop + vertex 4.000000e+00 0.000000e+00 1.200000e+01 + vertex 6.000000e+00 2.500000e+01 1.200000e+01 + vertex 4.000000e+00 2.500000e+01 1.200000e+01 + endloop + endfacet + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+00 0.000000e+00 4.000000e+00 + vertex 6.000000e+00 0.000000e+00 4.000000e+00 + vertex 6.000000e+00 0.000000e+00 1.200000e+01 + endloop + endfacet + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+00 0.000000e+00 4.000000e+00 + vertex 6.000000e+00 0.000000e+00 1.200000e+01 + vertex 4.000000e+00 0.000000e+00 1.200000e+01 + endloop + endfacet + facet normal 1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 6.000000e+00 0.000000e+00 4.000000e+00 + vertex 6.000000e+00 2.500000e+01 4.000000e+00 + vertex 6.000000e+00 2.500000e+01 1.200000e+01 + endloop + endfacet + facet normal 1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 6.000000e+00 0.000000e+00 4.000000e+00 + vertex 6.000000e+00 2.500000e+01 1.200000e+01 + vertex 6.000000e+00 0.000000e+00 1.200000e+01 + endloop + endfacet + facet normal 0.000000e+00 1.000000e+00 0.000000e+00 + outer loop + vertex 6.000000e+00 2.500000e+01 4.000000e+00 + vertex 4.000000e+00 2.500000e+01 4.000000e+00 + vertex 4.000000e+00 2.500000e+01 1.200000e+01 + endloop + endfacet + facet normal 0.000000e+00 1.000000e+00 -0.000000e+00 + outer loop + vertex 6.000000e+00 2.500000e+01 4.000000e+00 + vertex 4.000000e+00 2.500000e+01 1.200000e+01 + vertex 6.000000e+00 2.500000e+01 1.200000e+01 + endloop + endfacet + facet normal -1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+00 2.500000e+01 4.000000e+00 + vertex 4.000000e+00 0.000000e+00 4.000000e+00 + vertex 4.000000e+00 0.000000e+00 1.200000e+01 + endloop + endfacet + facet normal -1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 4.000000e+00 2.500000e+01 4.000000e+00 + vertex 4.000000e+00 0.000000e+00 1.200000e+01 + vertex 4.000000e+00 2.500000e+01 1.200000e+01 + endloop + endfacet +endsolid bracket diff --git a/crates/exav-viewer/demo/examples/files/models/gear.stl b/crates/exav-viewer/demo/examples/files/models/gear.stl new file mode 100644 index 00000000..fade3959 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/models/gear.stl differ diff --git a/crates/exav-viewer/demo/examples/files/models/icosphere-ascii.ply b/crates/exav-viewer/demo/examples/files/models/icosphere-ascii.ply new file mode 100644 index 00000000..7bacbe7a --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/models/icosphere-ascii.ply @@ -0,0 +1,499 @@ +ply +format ascii 1.0 +comment an icosphere, colours from the position +element vertex 162 +property float x +property float y +property float z +property float nx +property float ny +property float nz +property uchar red +property uchar green +property uchar blue +property uchar alpha +element face 320 +property list uchar uint vertex_indices +end_header +-0.52573 0.85065 0.00000 -0.52573 0.85065 0.00000 60 235 127 255 +0.52573 0.85065 0.00000 0.52573 0.85065 0.00000 194 235 127 255 +-0.52573 -0.85065 0.00000 -0.52573 -0.85065 0.00000 60 19 127 255 +0.52573 -0.85065 0.00000 0.52573 -0.85065 0.00000 194 19 127 255 +0.00000 -0.52573 0.85065 0.00000 -0.52573 0.85065 127 60 235 255 +0.00000 0.52573 0.85065 0.00000 0.52573 0.85065 127 194 235 255 +0.00000 -0.52573 -0.85065 0.00000 -0.52573 -0.85065 127 60 19 255 +0.00000 0.52573 -0.85065 0.00000 0.52573 -0.85065 127 194 19 255 +0.85065 0.00000 -0.52573 0.85065 0.00000 -0.52573 235 127 60 255 +0.85065 0.00000 0.52573 0.85065 0.00000 0.52573 235 127 194 255 +-0.85065 0.00000 -0.52573 -0.85065 0.00000 -0.52573 19 127 60 255 +-0.85065 0.00000 0.52573 -0.85065 0.00000 0.52573 19 127 194 255 +-0.80902 0.50000 0.30902 -0.80902 0.50000 0.30902 24 191 166 255 +-0.50000 0.30902 0.80902 -0.50000 0.30902 0.80902 63 166 230 255 +-0.30902 0.80902 0.50000 -0.30902 0.80902 0.50000 88 230 191 255 +0.30902 0.80902 0.50000 0.30902 0.80902 0.50000 166 230 191 255 +0.00000 1.00000 0.00000 0.00000 1.00000 0.00000 127 255 127 255 +0.30902 0.80902 -0.50000 0.30902 0.80902 -0.50000 166 230 63 255 +-0.30902 0.80902 -0.50000 -0.30902 0.80902 -0.50000 88 230 63 255 +-0.50000 0.30902 -0.80902 -0.50000 0.30902 -0.80902 63 166 24 255 +-0.80902 0.50000 -0.30902 -0.80902 0.50000 -0.30902 24 191 88 255 +-1.00000 0.00000 0.00000 -1.00000 0.00000 0.00000 0 127 127 255 +0.50000 0.30902 0.80902 0.50000 0.30902 0.80902 191 166 230 255 +0.80902 0.50000 0.30902 0.80902 0.50000 0.30902 230 191 166 255 +-0.50000 -0.30902 0.80902 -0.50000 -0.30902 0.80902 63 88 230 255 +0.00000 0.00000 1.00000 0.00000 0.00000 1.00000 127 127 255 255 +-0.80902 -0.50000 -0.30902 -0.80902 -0.50000 -0.30902 24 63 88 255 +-0.80902 -0.50000 0.30902 -0.80902 -0.50000 0.30902 24 63 166 255 +0.00000 0.00000 -1.00000 0.00000 0.00000 -1.00000 127 127 0 255 +-0.50000 -0.30902 -0.80902 -0.50000 -0.30902 -0.80902 63 88 24 255 +0.80902 0.50000 -0.30902 0.80902 0.50000 -0.30902 230 191 88 255 +0.50000 0.30902 -0.80902 0.50000 0.30902 -0.80902 191 166 24 255 +0.80902 -0.50000 0.30902 0.80902 -0.50000 0.30902 230 63 166 255 +0.50000 -0.30902 0.80902 0.50000 -0.30902 0.80902 191 88 230 255 +0.30902 -0.80902 0.50000 0.30902 -0.80902 0.50000 166 24 191 255 +-0.30902 -0.80902 0.50000 -0.30902 -0.80902 0.50000 88 24 191 255 +0.00000 -1.00000 0.00000 0.00000 -1.00000 0.00000 127 0 127 255 +-0.30902 -0.80902 -0.50000 -0.30902 -0.80902 -0.50000 88 24 63 255 +0.30902 -0.80902 -0.50000 0.30902 -0.80902 -0.50000 166 24 63 255 +0.50000 -0.30902 -0.80902 0.50000 -0.30902 -0.80902 191 88 24 255 +0.80902 -0.50000 -0.30902 0.80902 -0.50000 -0.30902 230 63 88 255 +1.00000 0.00000 0.00000 1.00000 0.00000 0.00000 255 127 127 255 +-0.69378 0.70205 0.16062 -0.69378 0.70205 0.16062 39 217 147 255 +-0.58779 0.68819 0.42533 -0.58779 0.68819 0.42533 52 215 181 255 +-0.43389 0.86267 0.25989 -0.43389 0.86267 0.25989 72 237 160 255 +-0.70205 0.16062 0.69378 -0.70205 0.16062 0.69378 37 147 215 255 +-0.68819 0.42533 0.58779 -0.68819 0.42533 0.58779 39 181 202 255 +-0.86267 0.25989 0.43389 -0.86267 0.25989 0.43389 17 160 182 255 +-0.16062 0.69378 0.70205 -0.16062 0.69378 0.70205 107 215 217 255 +-0.42533 0.58779 0.68819 -0.42533 0.58779 0.68819 73 202 215 255 +-0.25989 0.43389 0.86267 -0.25989 0.43389 0.86267 94 182 237 255 +-0.16246 0.95106 0.26287 -0.16246 0.95106 0.26287 106 248 161 255 +-0.27327 0.96194 0.00000 -0.27327 0.96194 0.00000 92 250 127 255 +0.16062 0.69378 0.70205 0.16062 0.69378 0.70205 147 215 217 255 +0.00000 0.85065 0.52573 0.00000 0.85065 0.52573 127 235 194 255 +0.27327 0.96194 0.00000 0.27327 0.96194 0.00000 162 250 127 255 +0.16246 0.95106 0.26287 0.16246 0.95106 0.26287 148 248 161 255 +0.43389 0.86267 0.25989 0.43389 0.86267 0.25989 182 237 160 255 +-0.16246 0.95106 -0.26287 -0.16246 0.95106 -0.26287 106 248 93 255 +-0.43389 0.86267 -0.25989 -0.43389 0.86267 -0.25989 72 237 94 255 +0.43389 0.86267 -0.25989 0.43389 0.86267 -0.25989 182 237 94 255 +0.16246 0.95106 -0.26287 0.16246 0.95106 -0.26287 148 248 93 255 +-0.16062 0.69378 -0.70205 -0.16062 0.69378 -0.70205 107 215 37 255 +0.00000 0.85065 -0.52573 0.00000 0.85065 -0.52573 127 235 60 255 +0.16062 0.69378 -0.70205 0.16062 0.69378 -0.70205 147 215 37 255 +-0.58779 0.68819 -0.42533 -0.58779 0.68819 -0.42533 52 215 73 255 +-0.69378 0.70205 -0.16062 -0.69378 0.70205 -0.16062 39 217 107 255 +-0.25989 0.43389 -0.86267 -0.25989 0.43389 -0.86267 94 182 17 255 +-0.42533 0.58779 -0.68819 -0.42533 0.58779 -0.68819 73 202 39 255 +-0.86267 0.25989 -0.43389 -0.86267 0.25989 -0.43389 17 160 72 255 +-0.68819 0.42533 -0.58779 -0.68819 0.42533 -0.58779 39 181 52 255 +-0.70205 0.16062 -0.69378 -0.70205 0.16062 -0.69378 37 147 39 255 +-0.85065 0.52573 0.00000 -0.85065 0.52573 0.00000 19 194 127 255 +-0.96194 0.00000 -0.27327 -0.96194 0.00000 -0.27327 4 127 92 255 +-0.95106 0.26287 -0.16246 -0.95106 0.26287 -0.16246 6 161 106 255 +-0.95106 0.26287 0.16246 -0.95106 0.26287 0.16246 6 161 148 255 +-0.96194 0.00000 0.27327 -0.96194 0.00000 0.27327 4 127 162 255 +0.58779 0.68819 0.42533 0.58779 0.68819 0.42533 202 215 181 255 +0.69378 0.70205 0.16062 0.69378 0.70205 0.16062 215 217 147 255 +0.25989 0.43389 0.86267 0.25989 0.43389 0.86267 160 182 237 255 +0.42533 0.58779 0.68819 0.42533 0.58779 0.68819 181 202 215 255 +0.86267 0.25989 0.43389 0.86267 0.25989 0.43389 237 160 182 255 +0.68819 0.42533 0.58779 0.68819 0.42533 0.58779 215 181 202 255 +0.70205 0.16062 0.69378 0.70205 0.16062 0.69378 217 147 215 255 +-0.26287 0.16246 0.95106 -0.26287 0.16246 0.95106 93 148 248 255 +0.00000 0.27327 0.96194 0.00000 0.27327 0.96194 127 162 250 255 +-0.70205 -0.16062 0.69378 -0.70205 -0.16062 0.69378 37 107 215 255 +-0.52573 0.00000 0.85065 -0.52573 0.00000 0.85065 60 127 235 255 +0.00000 -0.27327 0.96194 0.00000 -0.27327 0.96194 127 92 250 255 +-0.26287 -0.16246 0.95106 -0.26287 -0.16246 0.95106 93 106 248 255 +-0.25989 -0.43389 0.86267 -0.25989 -0.43389 0.86267 94 72 237 255 +-0.95106 -0.26287 0.16246 -0.95106 -0.26287 0.16246 6 93 148 255 +-0.86267 -0.25989 0.43389 -0.86267 -0.25989 0.43389 17 94 182 255 +-0.86267 -0.25989 -0.43389 -0.86267 -0.25989 -0.43389 17 94 72 255 +-0.95106 -0.26287 -0.16246 -0.95106 -0.26287 -0.16246 6 93 106 255 +-0.69378 -0.70205 0.16062 -0.69378 -0.70205 0.16062 39 37 147 255 +-0.85065 -0.52573 0.00000 -0.85065 -0.52573 0.00000 19 60 127 255 +-0.69378 -0.70205 -0.16062 -0.69378 -0.70205 -0.16062 39 37 107 255 +-0.52573 0.00000 -0.85065 -0.52573 0.00000 -0.85065 60 127 19 255 +-0.70205 -0.16062 -0.69378 -0.70205 -0.16062 -0.69378 37 107 39 255 +0.00000 0.27327 -0.96194 0.00000 0.27327 -0.96194 127 162 4 255 +-0.26287 0.16246 -0.95106 -0.26287 0.16246 -0.95106 93 148 6 255 +-0.25989 -0.43389 -0.86267 -0.25989 -0.43389 -0.86267 94 72 17 255 +-0.26287 -0.16246 -0.95106 -0.26287 -0.16246 -0.95106 93 106 6 255 +0.00000 -0.27327 -0.96194 0.00000 -0.27327 -0.96194 127 92 4 255 +0.42533 0.58779 -0.68819 0.42533 0.58779 -0.68819 181 202 39 255 +0.25989 0.43389 -0.86267 0.25989 0.43389 -0.86267 160 182 17 255 +0.69378 0.70205 -0.16062 0.69378 0.70205 -0.16062 215 217 107 255 +0.58779 0.68819 -0.42533 0.58779 0.68819 -0.42533 202 215 73 255 +0.70205 0.16062 -0.69378 0.70205 0.16062 -0.69378 217 147 39 255 +0.68819 0.42533 -0.58779 0.68819 0.42533 -0.58779 215 181 52 255 +0.86267 0.25989 -0.43389 0.86267 0.25989 -0.43389 237 160 72 255 +0.69378 -0.70205 0.16062 0.69378 -0.70205 0.16062 215 37 147 255 +0.58779 -0.68819 0.42533 0.58779 -0.68819 0.42533 202 39 181 255 +0.43389 -0.86267 0.25989 0.43389 -0.86267 0.25989 182 17 160 255 +0.70205 -0.16062 0.69378 0.70205 -0.16062 0.69378 217 107 215 255 +0.68819 -0.42533 0.58779 0.68819 -0.42533 0.58779 215 73 202 255 +0.86267 -0.25989 0.43389 0.86267 -0.25989 0.43389 237 94 182 255 +0.16062 -0.69378 0.70205 0.16062 -0.69378 0.70205 147 39 217 255 +0.42533 -0.58779 0.68819 0.42533 -0.58779 0.68819 181 52 215 255 +0.25989 -0.43389 0.86267 0.25989 -0.43389 0.86267 160 72 237 255 +0.16246 -0.95106 0.26287 0.16246 -0.95106 0.26287 148 6 161 255 +0.27327 -0.96194 0.00000 0.27327 -0.96194 0.00000 162 4 127 255 +-0.16062 -0.69378 0.70205 -0.16062 -0.69378 0.70205 107 39 217 255 +0.00000 -0.85065 0.52573 0.00000 -0.85065 0.52573 127 19 194 255 +-0.27327 -0.96194 0.00000 -0.27327 -0.96194 0.00000 92 4 127 255 +-0.16246 -0.95106 0.26287 -0.16246 -0.95106 0.26287 106 6 161 255 +-0.43389 -0.86267 0.25989 -0.43389 -0.86267 0.25989 72 17 160 255 +0.16246 -0.95106 -0.26287 0.16246 -0.95106 -0.26287 148 6 93 255 +0.43389 -0.86267 -0.25989 0.43389 -0.86267 -0.25989 182 17 94 255 +-0.43389 -0.86267 -0.25989 -0.43389 -0.86267 -0.25989 72 17 94 255 +-0.16246 -0.95106 -0.26287 -0.16246 -0.95106 -0.26287 106 6 93 255 +0.16062 -0.69378 -0.70205 0.16062 -0.69378 -0.70205 147 39 37 255 +0.00000 -0.85065 -0.52573 0.00000 -0.85065 -0.52573 127 19 60 255 +-0.16062 -0.69378 -0.70205 -0.16062 -0.69378 -0.70205 107 39 37 255 +0.58779 -0.68819 -0.42533 0.58779 -0.68819 -0.42533 202 39 73 255 +0.69378 -0.70205 -0.16062 0.69378 -0.70205 -0.16062 215 37 107 255 +0.25989 -0.43389 -0.86267 0.25989 -0.43389 -0.86267 160 72 17 255 +0.42533 -0.58779 -0.68819 0.42533 -0.58779 -0.68819 181 52 39 255 +0.86267 -0.25989 -0.43389 0.86267 -0.25989 -0.43389 237 94 72 255 +0.68819 -0.42533 -0.58779 0.68819 -0.42533 -0.58779 215 73 52 255 +0.70205 -0.16062 -0.69378 0.70205 -0.16062 -0.69378 217 107 39 255 +0.85065 -0.52573 0.00000 0.85065 -0.52573 0.00000 235 60 127 255 +0.96194 0.00000 -0.27327 0.96194 0.00000 -0.27327 250 127 92 255 +0.95106 -0.26287 -0.16246 0.95106 -0.26287 -0.16246 248 93 106 255 +0.95106 -0.26287 0.16246 0.95106 -0.26287 0.16246 248 93 148 255 +0.96194 0.00000 0.27327 0.96194 0.00000 0.27327 250 127 162 255 +0.26287 -0.16246 0.95106 0.26287 -0.16246 0.95106 161 106 248 255 +0.52573 0.00000 0.85065 0.52573 0.00000 0.85065 194 127 235 255 +0.26287 0.16246 0.95106 0.26287 0.16246 0.95106 161 148 248 255 +-0.58779 -0.68819 0.42533 -0.58779 -0.68819 0.42533 52 39 181 255 +-0.42533 -0.58779 0.68819 -0.42533 -0.58779 0.68819 73 52 215 255 +-0.68819 -0.42533 0.58779 -0.68819 -0.42533 0.58779 39 73 202 255 +-0.42533 -0.58779 -0.68819 -0.42533 -0.58779 -0.68819 73 52 39 255 +-0.58779 -0.68819 -0.42533 -0.58779 -0.68819 -0.42533 52 39 73 255 +-0.68819 -0.42533 -0.58779 -0.68819 -0.42533 -0.58779 39 73 52 255 +0.52573 0.00000 -0.85065 0.52573 0.00000 -0.85065 194 127 19 255 +0.26287 -0.16246 -0.95106 0.26287 -0.16246 -0.95106 161 106 6 255 +0.26287 0.16246 -0.95106 0.26287 0.16246 -0.95106 161 148 6 255 +0.95106 0.26287 0.16246 0.95106 0.26287 0.16246 248 161 148 255 +0.95106 0.26287 -0.16246 0.95106 0.26287 -0.16246 248 161 106 255 +0.85065 0.52573 0.00000 0.85065 0.52573 0.00000 235 194 127 255 +3 0 42 44 +3 12 43 42 +3 14 44 43 +3 42 43 44 +3 11 45 47 +3 13 46 45 +3 12 47 46 +3 45 46 47 +3 5 48 50 +3 14 49 48 +3 13 50 49 +3 48 49 50 +3 12 46 43 +3 13 49 46 +3 14 43 49 +3 46 49 43 +3 0 44 52 +3 14 51 44 +3 16 52 51 +3 44 51 52 +3 5 53 48 +3 15 54 53 +3 14 48 54 +3 53 54 48 +3 1 55 57 +3 16 56 55 +3 15 57 56 +3 55 56 57 +3 14 54 51 +3 15 56 54 +3 16 51 56 +3 54 56 51 +3 0 52 59 +3 16 58 52 +3 18 59 58 +3 52 58 59 +3 1 60 55 +3 17 61 60 +3 16 55 61 +3 60 61 55 +3 7 62 64 +3 18 63 62 +3 17 64 63 +3 62 63 64 +3 16 61 58 +3 17 63 61 +3 18 58 63 +3 61 63 58 +3 0 59 66 +3 18 65 59 +3 20 66 65 +3 59 65 66 +3 7 67 62 +3 19 68 67 +3 18 62 68 +3 67 68 62 +3 10 69 71 +3 20 70 69 +3 19 71 70 +3 69 70 71 +3 18 68 65 +3 19 70 68 +3 20 65 70 +3 68 70 65 +3 0 66 42 +3 20 72 66 +3 12 42 72 +3 66 72 42 +3 10 73 69 +3 21 74 73 +3 20 69 74 +3 73 74 69 +3 11 47 76 +3 12 75 47 +3 21 76 75 +3 47 75 76 +3 20 74 72 +3 21 75 74 +3 12 72 75 +3 74 75 72 +3 1 57 78 +3 15 77 57 +3 23 78 77 +3 57 77 78 +3 5 79 53 +3 22 80 79 +3 15 53 80 +3 79 80 53 +3 9 81 83 +3 23 82 81 +3 22 83 82 +3 81 82 83 +3 15 80 77 +3 22 82 80 +3 23 77 82 +3 80 82 77 +3 5 50 85 +3 13 84 50 +3 25 85 84 +3 50 84 85 +3 11 86 45 +3 24 87 86 +3 13 45 87 +3 86 87 45 +3 4 88 90 +3 25 89 88 +3 24 90 89 +3 88 89 90 +3 13 87 84 +3 24 89 87 +3 25 84 89 +3 87 89 84 +3 11 76 92 +3 21 91 76 +3 27 92 91 +3 76 91 92 +3 10 93 73 +3 26 94 93 +3 21 73 94 +3 93 94 73 +3 2 95 97 +3 27 96 95 +3 26 97 96 +3 95 96 97 +3 21 94 91 +3 26 96 94 +3 27 91 96 +3 94 96 91 +3 10 71 99 +3 19 98 71 +3 29 99 98 +3 71 98 99 +3 7 100 67 +3 28 101 100 +3 19 67 101 +3 100 101 67 +3 6 102 104 +3 29 103 102 +3 28 104 103 +3 102 103 104 +3 19 101 98 +3 28 103 101 +3 29 98 103 +3 101 103 98 +3 7 64 106 +3 17 105 64 +3 31 106 105 +3 64 105 106 +3 1 107 60 +3 30 108 107 +3 17 60 108 +3 107 108 60 +3 8 109 111 +3 31 110 109 +3 30 111 110 +3 109 110 111 +3 17 108 105 +3 30 110 108 +3 31 105 110 +3 108 110 105 +3 3 112 114 +3 32 113 112 +3 34 114 113 +3 112 113 114 +3 9 115 117 +3 33 116 115 +3 32 117 116 +3 115 116 117 +3 4 118 120 +3 34 119 118 +3 33 120 119 +3 118 119 120 +3 32 116 113 +3 33 119 116 +3 34 113 119 +3 116 119 113 +3 3 114 122 +3 34 121 114 +3 36 122 121 +3 114 121 122 +3 4 123 118 +3 35 124 123 +3 34 118 124 +3 123 124 118 +3 2 125 127 +3 36 126 125 +3 35 127 126 +3 125 126 127 +3 34 124 121 +3 35 126 124 +3 36 121 126 +3 124 126 121 +3 3 122 129 +3 36 128 122 +3 38 129 128 +3 122 128 129 +3 2 130 125 +3 37 131 130 +3 36 125 131 +3 130 131 125 +3 6 132 134 +3 38 133 132 +3 37 134 133 +3 132 133 134 +3 36 131 128 +3 37 133 131 +3 38 128 133 +3 131 133 128 +3 3 129 136 +3 38 135 129 +3 40 136 135 +3 129 135 136 +3 6 137 132 +3 39 138 137 +3 38 132 138 +3 137 138 132 +3 8 139 141 +3 40 140 139 +3 39 141 140 +3 139 140 141 +3 38 138 135 +3 39 140 138 +3 40 135 140 +3 138 140 135 +3 3 136 112 +3 40 142 136 +3 32 112 142 +3 136 142 112 +3 8 143 139 +3 41 144 143 +3 40 139 144 +3 143 144 139 +3 9 117 146 +3 32 145 117 +3 41 146 145 +3 117 145 146 +3 40 144 142 +3 41 145 144 +3 32 142 145 +3 144 145 142 +3 4 120 88 +3 33 147 120 +3 25 88 147 +3 120 147 88 +3 9 83 115 +3 22 148 83 +3 33 115 148 +3 83 148 115 +3 5 85 79 +3 25 149 85 +3 22 79 149 +3 85 149 79 +3 33 148 147 +3 22 149 148 +3 25 147 149 +3 148 149 147 +3 2 127 95 +3 35 150 127 +3 27 95 150 +3 127 150 95 +3 4 90 123 +3 24 151 90 +3 35 123 151 +3 90 151 123 +3 11 92 86 +3 27 152 92 +3 24 86 152 +3 92 152 86 +3 35 151 150 +3 24 152 151 +3 27 150 152 +3 151 152 150 +3 6 134 102 +3 37 153 134 +3 29 102 153 +3 134 153 102 +3 2 97 130 +3 26 154 97 +3 37 130 154 +3 97 154 130 +3 10 99 93 +3 29 155 99 +3 26 93 155 +3 99 155 93 +3 37 154 153 +3 26 155 154 +3 29 153 155 +3 154 155 153 +3 8 141 109 +3 39 156 141 +3 31 109 156 +3 141 156 109 +3 6 104 137 +3 28 157 104 +3 39 137 157 +3 104 157 137 +3 7 106 100 +3 31 158 106 +3 28 100 158 +3 106 158 100 +3 39 157 156 +3 28 158 157 +3 31 156 158 +3 157 158 156 +3 9 146 81 +3 41 159 146 +3 23 81 159 +3 146 159 81 +3 8 111 143 +3 30 160 111 +3 41 143 160 +3 111 160 143 +3 1 78 107 +3 23 161 78 +3 30 107 161 +3 78 161 107 +3 41 160 159 +3 30 161 160 +3 23 159 161 +3 160 161 159 diff --git a/crates/exav-viewer/demo/examples/files/models/knot.obj b/crates/exav-viewer/demo/examples/files/models/knot.obj new file mode 100644 index 00000000..f2f5f665 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/models/knot.obj @@ -0,0 +1,2944 @@ +# A trefoil knot with a colour per vertex, and a base: two objects. +o knot +v 3.3200 0.0006 0.0000 1.000 0.000 0.000 +v 3.2587 0.0846 -0.1682 1.000 0.000 0.000 +v 3.0986 0.1363 -0.2722 1.000 0.000 0.000 +v 2.9009 0.1359 -0.2722 1.000 0.000 0.000 +v 2.7410 0.0837 -0.1682 1.000 0.000 0.000 +v 2.6800 -0.0006 -0.0000 1.000 0.000 0.000 +v 2.7413 -0.0846 0.1682 1.000 0.000 0.000 +v 2.9014 -0.1363 0.2722 1.000 0.000 0.000 +v 3.0991 -0.1359 0.2722 1.000 0.000 0.000 +v 3.2590 -0.0837 0.1682 1.000 0.000 0.000 +v 3.2596 0.4923 0.2079 1.000 0.067 0.000 +v 3.1801 0.5577 0.0390 1.000 0.067 0.000 +v 3.0125 0.5684 -0.0654 1.000 0.067 0.000 +v 2.8207 0.5203 -0.0654 1.000 0.067 0.000 +v 2.6779 0.4318 0.0390 1.000 0.067 0.000 +v 2.6388 0.3366 0.2079 1.000 0.067 0.000 +v 2.7182 0.2713 0.3768 1.000 0.067 0.000 +v 2.8858 0.2606 0.4812 1.000 0.067 0.000 +v 3.0776 0.3087 0.4812 1.000 0.067 0.000 +v 3.2204 0.3972 0.3768 1.000 0.067 0.000 +v 3.0834 0.9529 0.4067 1.000 0.133 0.000 +v 2.9926 0.9938 0.2359 1.000 0.133 0.000 +v 2.8285 0.9618 0.1303 1.000 0.133 0.000 +v 2.6537 0.8692 0.1303 1.000 0.133 0.000 +v 2.5351 0.7513 0.2359 1.000 0.133 0.000 +v 2.5179 0.6532 0.4067 1.000 0.133 0.000 +v 2.6088 0.6124 0.5776 1.000 0.133 0.000 +v 2.7729 0.6444 0.6832 1.000 0.133 0.000 +v 2.9476 0.7370 0.6832 1.000 0.133 0.000 +v 3.0662 0.8549 0.5776 1.000 0.133 0.000 +v 2.8057 1.3547 0.5878 1.000 0.200 0.000 +v 2.7125 1.3678 0.4139 1.000 0.200 0.000 +v 2.5634 1.2948 0.3064 1.000 0.200 0.000 +v 2.4153 1.1637 0.3064 1.000 0.200 0.000 +v 2.3249 1.0245 0.4139 1.000 0.200 0.000 +v 2.3266 0.9304 0.5878 1.000 0.200 0.000 +v 2.4198 0.9173 0.7617 1.000 0.200 0.000 +v 2.5689 0.9902 0.8692 1.000 0.200 0.000 +v 2.7170 1.1213 0.8692 1.000 0.200 0.000 +v 2.8075 1.2605 0.7617 1.000 0.200 0.000 +v 2.4482 1.6758 0.7431 1.000 0.267 0.000 +v 2.3626 1.6614 0.5654 1.000 0.267 0.000 +v 2.2392 1.5527 0.4556 1.000 0.267 0.000 +v 2.1251 1.3911 0.4556 1.000 0.267 0.000 +v 2.0639 1.2385 0.5654 1.000 0.267 0.000 +v 2.0789 1.1531 0.7431 1.000 0.267 0.000 +v 2.1645 1.1675 0.9209 1.000 0.267 0.000 +v 2.2879 1.2762 1.0307 1.000 0.267 0.000 +v 2.4020 1.4377 1.0307 1.000 0.267 0.000 +v 2.4632 1.5903 0.9209 1.000 0.267 0.000 +v 2.0373 1.9027 0.8660 1.000 0.333 0.000 +v 1.9694 1.8647 0.6842 1.000 0.333 0.000 +v 1.8807 1.7282 0.5719 1.000 0.333 0.000 +v 1.8051 1.5454 0.5719 1.000 0.333 0.000 +v 1.7716 1.3861 0.6842 1.000 0.333 0.000 +v 1.7929 1.3112 0.8660 1.000 0.333 0.000 +v 1.8609 1.3493 1.0478 1.000 0.333 0.000 +v 1.9495 1.4857 1.1602 1.000 0.333 0.000 +v 2.0251 1.6685 1.1602 1.000 0.333 0.000 +v 2.0586 1.8278 1.0478 1.000 0.333 0.000 +v 1.6017 2.0309 0.9511 1.000 0.400 0.000 +v 1.5599 1.9763 0.7656 1.000 0.400 0.000 +v 1.5123 1.8223 0.6510 1.000 0.400 0.000 +v 1.4773 1.6277 0.6510 1.000 0.400 0.000 +v 1.4681 1.4667 0.7656 1.000 0.400 0.000 +v 1.4883 1.4010 0.9511 1.000 0.400 0.000 +v 1.5302 1.4556 1.1365 1.000 0.400 0.000 +v 1.5777 1.6096 1.2511 1.000 0.400 0.000 +v 1.6128 1.8042 1.2511 1.000 0.400 0.000 +v 1.6219 1.9651 1.1365 1.000 0.400 0.000 +v 1.1690 2.0646 0.9945 1.000 0.467 0.000 +v 1.1592 2.0033 0.8068 1.000 0.467 0.000 +v 1.1562 1.8431 0.6907 1.000 0.467 0.000 +v 1.1611 1.6454 0.6907 1.000 0.467 0.000 +v 1.1720 1.4856 0.8068 1.000 0.467 0.000 +v 1.1847 1.4248 0.9945 1.000 0.467 0.000 +v 1.1945 1.4862 1.1823 1.000 0.467 0.000 +v 1.1975 1.6463 1.2983 1.000 0.467 0.000 +v 1.1926 1.8440 1.2983 1.000 0.467 0.000 +v 1.1817 2.0038 1.1823 1.000 0.467 0.000 +v 0.7629 2.0163 0.9945 1.000 0.533 0.000 +v 0.7880 1.9592 0.8068 1.000 0.533 0.000 +v 0.8295 1.8045 0.6908 1.000 0.533 0.000 +v 0.8715 1.6113 0.6908 1.000 0.533 0.000 +v 0.8981 1.4533 0.8068 1.000 0.533 0.000 +v 0.8990 1.3910 0.9945 1.000 0.533 0.000 +v 0.8739 1.4480 1.1822 1.000 0.533 0.000 +v 0.8324 1.6028 1.2982 1.000 0.533 0.000 +v 0.7903 1.7960 1.2982 1.000 0.533 0.000 +v 0.7638 1.9540 1.1822 1.000 0.533 0.000 +v 0.4015 1.9045 0.9511 1.000 0.600 0.000 +v 0.4605 1.8625 0.7670 1.000 0.600 0.000 +v 0.5432 1.7235 0.6533 1.000 0.600 0.000 +v 0.6180 1.5404 0.6533 1.000 0.600 0.000 +v 0.6563 1.3832 0.7670 1.000 0.600 0.000 +v 0.6436 1.3120 0.9511 1.000 0.600 0.000 +v 0.5846 1.3539 1.1351 1.000 0.600 0.000 +v 0.5019 1.4930 1.2489 1.000 0.600 0.000 +v 0.4271 1.6760 1.2489 1.000 0.600 0.000 +v 0.3888 1.8332 1.1351 1.000 0.600 0.000 +v 0.0959 1.7516 0.8660 1.000 0.667 0.000 +v 0.1845 1.7335 0.6902 1.000 0.667 0.000 +v 0.3022 1.6175 0.5815 1.000 0.667 0.000 +v 0.4039 1.4479 0.5815 1.000 0.667 0.000 +v 0.4508 1.2895 0.6902 1.000 0.667 0.000 +v 0.4250 1.2028 0.8660 1.000 0.667 0.000 +v 0.3364 1.2209 1.0419 1.000 0.667 0.000 +v 0.2188 1.3369 1.1506 1.000 0.667 0.000 +v 0.1171 1.5065 1.1506 1.000 0.667 0.000 +v 0.0701 1.6649 1.0419 1.000 0.667 0.000 +v -0.1505 1.5823 0.7431 1.000 0.733 0.000 +v -0.0386 1.5922 0.5804 1.000 0.733 0.000 +v 0.1059 1.5019 0.4799 1.000 0.733 0.000 +v 0.2276 1.3461 0.4799 1.000 0.733 0.000 +v 0.2801 1.1841 0.5804 1.000 0.733 0.000 +v 0.2434 1.0779 0.7431 1.000 0.733 0.000 +v 0.1315 1.0680 0.9059 1.000 0.733 0.000 +v -0.0130 1.1582 1.0064 1.000 0.733 0.000 +v -0.1347 1.3140 1.0064 1.000 0.733 0.000 +v -0.1873 1.4760 0.9059 1.000 0.733 0.000 +v -0.3408 1.4203 0.5878 1.000 0.800 0.000 +v -0.2116 1.4559 0.4424 1.000 0.800 0.000 +v -0.0491 1.3879 0.3525 1.000 0.800 0.000 +v 0.0846 1.2421 0.3525 1.000 0.800 0.000 +v 0.1385 1.0744 0.4424 1.000 0.800 0.000 +v 0.0918 0.9487 0.5878 1.000 0.800 0.000 +v -0.0374 0.9130 0.7332 1.000 0.800 0.000 +v -0.1999 0.9811 0.8230 1.000 0.800 0.000 +v -0.3336 1.1268 0.8230 1.000 0.800 0.000 +v -0.3874 1.2946 0.7332 1.000 0.800 0.000 +v -0.4823 1.2870 0.4067 1.000 0.863 0.000 +v -0.3390 1.3381 0.2803 1.000 0.863 0.000 +v -0.1667 1.2807 0.2022 1.000 0.863 0.000 +v -0.0310 1.1368 0.2022 1.000 0.863 0.000 +v 0.0161 0.9613 0.2803 1.000 0.863 0.000 +v -0.0434 0.8213 0.4067 1.000 0.863 0.000 +v -0.1866 0.7703 0.5331 1.000 0.863 0.000 +v -0.3590 0.8277 0.6113 1.000 0.863 0.000 +v -0.4947 0.9716 0.6113 1.000 0.863 0.000 +v -0.5418 1.1470 0.5331 1.000 0.863 0.000 +v -0.5838 1.1964 0.2079 1.000 0.933 0.000 +v -0.4271 1.2444 0.0973 1.000 0.933 0.000 +v -0.2534 1.1790 0.0289 1.000 0.933 0.000 +v -0.1292 1.0251 0.0289 1.000 0.933 0.000 +v -0.1018 0.8416 0.0973 1.000 0.933 0.000 +v -0.1818 0.6985 0.2079 1.000 0.933 0.000 +v -0.3385 0.6505 0.3185 1.000 0.933 0.000 +v -0.5122 0.7159 0.3869 1.000 0.933 0.000 +v -0.6364 0.8697 0.3869 1.000 0.933 0.000 +v -0.6638 1.0533 0.3185 1.000 0.933 0.000 +v -0.6597 1.1434 0.0000 1.000 1.000 0.000 +v -0.4935 1.1685 -0.1043 1.000 1.000 0.000 +v -0.3299 1.0781 -0.1688 1.000 1.000 0.000 +v -0.2312 0.9067 -0.1688 1.000 1.000 0.000 +v -0.2352 0.7197 -0.1043 1.000 1.000 0.000 +v -0.3403 0.5887 0.0000 1.000 1.000 0.000 +v -0.5065 0.5636 0.1043 1.000 1.000 0.000 +v -0.6701 0.6540 0.1688 1.000 1.000 0.000 +v -0.7688 0.8254 0.1688 1.000 1.000 0.000 +v -0.7648 1.0123 0.1043 1.000 1.000 0.000 +v -0.7437 1.1040 -0.2079 0.933 1.000 0.000 +v -0.5799 1.1014 -0.3187 0.933 1.000 0.000 +v -0.4349 0.9856 -0.3872 0.933 1.000 0.000 +v -0.3641 0.8009 -0.3872 0.933 1.000 0.000 +v -0.3945 0.6179 -0.3187 0.933 1.000 0.000 +v -0.5146 0.5064 -0.2079 0.933 1.000 0.000 +v -0.6783 0.5091 -0.0971 0.933 1.000 0.000 +v -0.8233 0.6249 -0.0286 0.933 1.000 0.000 +v -0.8941 0.8095 -0.0286 0.933 1.000 0.000 +v -0.8637 0.9925 -0.0971 0.933 1.000 0.000 +v -0.8733 1.0613 -0.4067 0.867 1.000 0.000 +v -0.7226 1.0426 -0.5334 0.867 1.000 0.000 +v -0.5944 0.9140 -0.6117 0.867 1.000 0.000 +v -0.5376 0.7245 -0.6117 0.867 1.000 0.000 +v -0.5741 0.5466 -0.5334 0.867 1.000 0.000 +v -0.6897 0.4482 -0.4067 0.867 1.000 0.000 +v -0.8405 0.4668 -0.2801 0.867 1.000 0.000 +v -0.9687 0.5954 -0.2018 0.867 1.000 0.000 +v -1.0254 0.7849 -0.2018 0.867 1.000 0.000 +v -0.9890 0.9628 -0.2801 0.867 1.000 0.000 +v -1.0598 1.0052 -0.5878 0.796 1.000 0.000 +v -0.9279 0.9827 -0.7334 0.796 1.000 0.000 +v -0.8096 0.8523 -0.8235 0.796 1.000 0.000 +v -0.7501 0.6637 -0.8235 0.796 1.000 0.000 +v -0.7721 0.4890 -0.7334 0.796 1.000 0.000 +v -0.8673 0.3949 -0.5878 0.796 1.000 0.000 +v -0.9991 0.4173 -0.4421 0.796 1.000 0.000 +v -1.1174 0.5478 -0.3521 0.796 1.000 0.000 +v -1.1769 0.7364 -0.3521 0.796 1.000 0.000 +v -1.1549 0.9111 -0.4421 0.796 1.000 0.000 +v -1.2954 0.9213 -0.7431 0.733 1.000 0.000 +v -1.1854 0.9000 -0.9061 0.733 1.000 0.000 +v -1.0714 0.7736 -1.0068 0.733 1.000 0.000 +v -0.9970 0.5904 -1.0068 0.733 1.000 0.000 +v -0.9907 0.4203 -0.9061 0.733 1.000 0.000 +v -1.0547 0.3283 -0.7431 0.733 1.000 0.000 +v -1.1648 0.3496 -0.5802 0.733 1.000 0.000 +v -1.2787 0.4760 -0.4795 0.733 1.000 0.000 +v -1.3531 0.6593 -0.4795 0.733 1.000 0.000 +v -1.3595 0.8293 -0.5802 0.733 1.000 0.000 +v -1.5655 0.7925 -0.8660 0.667 1.000 0.000 +v -1.4778 0.7714 -1.0420 0.667 1.000 0.000 +v -1.3641 0.6517 -1.1508 0.667 1.000 0.000 +v -1.2677 0.4790 -1.1508 0.667 1.000 0.000 +v -1.2255 0.3193 -1.0420 0.667 1.000 0.000 +v -1.2536 0.2336 -0.8660 0.667 1.000 0.000 +v -1.3413 0.2547 -0.6900 0.667 1.000 0.000 +v -1.4550 0.3744 -0.5812 0.667 1.000 0.000 +v -1.5514 0.5471 -0.5812 0.667 1.000 0.000 +v -1.5936 0.7068 -0.6900 0.667 1.000 0.000 +v -1.8507 0.6040 -0.9511 0.600 1.000 0.000 +v -1.7828 0.5794 -1.1352 0.600 1.000 0.000 +v -1.6658 0.4677 -1.2490 0.600 1.000 0.000 +v -1.5443 0.3117 -1.2490 0.600 1.000 0.000 +v -1.4647 0.1709 -1.1352 0.600 1.000 0.000 +v -1.4574 0.0991 -0.9511 0.600 1.000 0.000 +v -1.5252 0.1238 -0.7669 0.600 1.000 0.000 +v -1.6423 0.2354 -0.6531 0.600 1.000 0.000 +v -1.7638 0.3914 -0.6531 0.600 1.000 0.000 +v -1.8434 0.5322 -0.7669 0.600 1.000 0.000 +v -2.1282 0.3469 -0.9945 0.533 1.000 0.000 +v -2.0748 0.3148 -1.1822 0.533 1.000 0.000 +v -1.9511 0.2130 -1.2982 0.533 1.000 0.000 +v -1.8045 0.0803 -1.2982 0.533 1.000 0.000 +v -1.6908 -0.0325 -1.1822 0.533 1.000 0.000 +v -1.6535 -0.0824 -0.9945 0.533 1.000 0.000 +v -1.7069 -0.0504 -0.8068 0.533 1.000 0.000 +v -1.8306 0.0515 -0.6908 0.533 1.000 0.000 +v -1.9772 0.1841 -0.6908 0.533 1.000 0.000 +v -2.0909 0.2970 -0.8068 0.533 1.000 0.000 +v -2.3730 0.0192 -0.9945 0.467 1.000 0.000 +v -2.3267 -0.0224 -1.1823 0.467 1.000 0.000 +v -2.1937 -0.1115 -1.2983 0.467 1.000 0.000 +v -2.0246 -0.2141 -1.2983 0.467 1.000 0.000 +v -1.8841 -0.2910 -1.1823 0.467 1.000 0.000 +v -1.8258 -0.3128 -0.9945 0.467 1.000 0.000 +v -1.8721 -0.2712 -0.8068 0.467 1.000 0.000 +v -2.0052 -0.1821 -0.6908 0.467 1.000 0.000 +v -2.1742 -0.0795 -0.6908 0.467 1.000 0.000 +v -2.3147 -0.0026 -0.8068 0.467 1.000 0.000 +v -2.5600 -0.3726 -0.9511 0.396 1.000 0.000 +v -2.5131 -0.4231 -1.1364 0.396 1.000 0.000 +v -2.3690 -0.4953 -1.2510 0.396 1.000 0.000 +v -2.1827 -0.5617 -1.2510 0.396 1.000 0.000 +v -2.0254 -0.5970 -1.1364 0.396 1.000 0.000 +v -1.9571 -0.5875 -0.9511 0.396 1.000 0.000 +v -2.0040 -0.5371 -0.7657 0.396 1.000 0.000 +v -2.1481 -0.4648 -0.6511 0.396 1.000 0.000 +v -2.3344 -0.3984 -0.6511 0.396 1.000 0.000 +v -2.4917 -0.3632 -0.7657 0.396 1.000 0.000 +v -2.6666 -0.8140 -0.8660 0.329 1.000 0.000 +v -2.6122 -0.8699 -1.0478 0.329 1.000 0.000 +v -2.4574 -0.9202 -1.1601 0.329 1.000 0.000 +v -2.2612 -0.9455 -1.1601 0.329 1.000 0.000 +v -2.0987 -0.9363 -1.0478 0.329 1.000 0.000 +v -2.0319 -0.8961 -0.8660 0.329 1.000 0.000 +v -2.0863 -0.8402 -0.6843 0.329 1.000 0.000 +v -2.2411 -0.7899 -0.5720 0.329 1.000 0.000 +v -2.4372 -0.7646 -0.5720 0.329 1.000 0.000 +v -2.5998 -0.7738 -0.6843 0.329 1.000 0.000 +v -2.6753 -1.2833 -0.7431 0.267 1.000 0.000 +v -2.6086 -1.3390 -0.9208 0.267 1.000 0.000 +v -2.4457 -1.3619 -1.0306 0.267 1.000 0.000 +v -2.2488 -1.3432 -1.0306 0.267 1.000 0.000 +v -2.0931 -1.2901 -0.9208 0.267 1.000 0.000 +v -2.0381 -1.2228 -0.7431 0.267 1.000 0.000 +v -2.1048 -1.1671 -0.5655 0.267 1.000 0.000 +v -2.2677 -1.1443 -0.4557 0.267 1.000 0.000 +v -2.4646 -1.1630 -0.4557 0.267 1.000 0.000 +v -2.6203 -1.2161 -0.5655 0.267 1.000 0.000 +v -2.5757 -1.7535 -0.5878 0.200 1.000 0.000 +v -2.4948 -1.8019 -0.7617 0.200 1.000 0.000 +v -2.3291 -1.7927 -0.8691 0.200 1.000 0.000 +v -2.1417 -1.7294 -0.8691 0.200 1.000 0.000 +v -2.0043 -1.6362 -0.7617 0.200 1.000 0.000 +v -1.9694 -1.5487 -0.5878 0.200 1.000 0.000 +v -2.0502 -1.5003 -0.4139 0.200 1.000 0.000 +v -2.2160 -1.5095 -0.3065 0.200 1.000 0.000 +v -2.4034 -1.5728 -0.3065 0.200 1.000 0.000 +v -2.5408 -1.6660 -0.4139 0.200 1.000 0.000 +v -2.3664 -2.1948 -0.4067 0.129 1.000 0.000 +v -2.2727 -2.2287 -0.5776 0.129 1.000 0.000 +v -2.1115 -2.1844 -0.6832 0.129 1.000 0.000 +v -1.9442 -2.0788 -0.6832 0.129 1.000 0.000 +v -1.8349 -1.9522 -0.5776 0.129 1.000 0.000 +v -1.8253 -1.8531 -0.4067 0.129 1.000 0.000 +v -1.9190 -1.8192 -0.2359 0.129 1.000 0.000 +v -2.0802 -1.8635 -0.1303 0.129 1.000 0.000 +v -2.2474 -1.9691 -0.1303 0.129 1.000 0.000 +v -2.3567 -2.0956 -0.2359 0.129 1.000 0.000 +v -2.0553 -2.5775 -0.2079 0.063 1.000 0.000 +v -1.9533 -2.5908 -0.3768 0.063 1.000 0.000 +v -1.8055 -2.5109 -0.4812 0.063 1.000 0.000 +v -1.6684 -2.3684 -0.4812 0.063 1.000 0.000 +v -1.5944 -2.2176 -0.3768 0.063 1.000 0.000 +v -1.6117 -2.1162 -0.2079 0.063 1.000 0.000 +v -1.7138 -2.1028 -0.0390 0.063 1.000 0.000 +v -1.8615 -2.1827 0.0654 0.063 1.000 0.000 +v -1.9986 -2.3253 0.0654 0.063 1.000 0.000 +v -2.0726 -2.4760 -0.0390 0.063 1.000 0.000 +v -1.6595 -2.8755 -0.0000 0.000 1.000 0.000 +v -1.5561 -2.8644 -0.1682 0.000 1.000 0.000 +v -1.4313 -2.7516 -0.2722 0.000 1.000 0.000 +v -1.3327 -2.5802 -0.2722 0.000 1.000 0.000 +v -1.2980 -2.4156 -0.1682 0.000 1.000 0.000 +v -1.3405 -2.3207 -0.0000 0.000 1.000 0.000 +v -1.4439 -2.3317 0.1682 0.000 1.000 0.000 +v -1.5687 -2.4445 0.2722 0.000 1.000 0.000 +v -1.6673 -2.6160 0.2722 0.000 1.000 0.000 +v -1.7020 -2.7806 0.1682 0.000 1.000 0.000 +v -1.2034 -3.0690 0.2079 0.000 1.000 0.063 +v -1.1071 -3.0329 0.0390 0.000 1.000 0.063 +v -1.0140 -2.8931 -0.0654 0.000 1.000 0.063 +v -0.9598 -2.7029 -0.0654 0.000 1.000 0.063 +v -0.9650 -2.5350 0.0390 0.000 1.000 0.063 +v -1.0278 -2.4536 0.2079 0.000 1.000 0.063 +v -1.1242 -2.4896 0.3768 0.000 1.000 0.063 +v -1.2172 -2.6295 0.4812 0.000 1.000 0.063 +v -1.2715 -2.8197 0.4812 0.000 1.000 0.063 +v -1.2662 -2.9875 0.3768 0.000 1.000 0.063 +v -0.7164 -3.1468 0.4067 0.000 1.000 0.129 +v -0.6357 -3.0886 0.2359 0.000 1.000 0.129 +v -0.5813 -2.9304 0.1303 0.000 1.000 0.129 +v -0.5742 -2.7328 0.1303 0.000 1.000 0.129 +v -0.6169 -2.5711 0.2359 0.000 1.000 0.129 +v -0.6933 -2.5072 0.4067 0.000 1.000 0.129 +v -0.7740 -2.5654 0.5776 0.000 1.000 0.129 +v -0.8284 -2.7236 0.6832 0.000 1.000 0.129 +v -0.8355 -2.9212 0.6832 0.000 1.000 0.129 +v -0.7928 -3.0829 0.5776 0.000 1.000 0.129 +v -0.2297 -3.1072 0.5878 0.000 1.000 0.196 +v -0.1717 -3.0330 0.4139 0.000 1.000 0.196 +v -0.1603 -2.8674 0.3064 0.000 1.000 0.196 +v -0.1998 -2.6736 0.3064 0.000 1.000 0.196 +v -0.2752 -2.5257 0.4139 0.000 1.000 0.196 +v -0.3575 -2.4801 0.5878 0.000 1.000 0.196 +v -0.4155 -2.5543 0.7617 0.000 1.000 0.196 +v -0.4269 -2.7199 0.8692 0.000 1.000 0.196 +v -0.3874 -2.9137 0.8692 0.000 1.000 0.196 +v -0.3121 -3.0616 0.7617 0.000 1.000 0.196 +v 0.2272 -2.9581 0.7431 0.000 1.000 0.263 +v 0.2575 -2.8768 0.5654 0.000 1.000 0.263 +v 0.2250 -2.7156 0.4556 0.000 1.000 0.263 +v 0.1422 -2.5360 0.4556 0.000 1.000 0.263 +v 0.0406 -2.4066 0.5654 0.000 1.000 0.263 +v -0.0409 -2.3769 0.7431 0.000 1.000 0.263 +v -0.0712 -2.4582 0.9209 0.000 1.000 0.263 +v -0.0387 -2.6194 1.0307 0.000 1.000 0.263 +v 0.0441 -2.7990 1.0307 0.000 1.000 0.263 +v 0.1457 -2.9284 0.9209 0.000 1.000 0.263 +v 0.6291 -2.7157 0.8660 0.000 1.000 0.333 +v 0.6302 -2.6379 0.6842 0.000 1.000 0.333 +v 0.5563 -2.4928 0.5719 0.000 1.000 0.333 +v 0.4358 -2.3360 0.5719 0.000 1.000 0.333 +v 0.3146 -2.2273 0.6842 0.000 1.000 0.333 +v 0.2391 -2.2083 0.8660 0.000 1.000 0.333 +v 0.2381 -2.2862 1.0478 0.000 1.000 0.333 +v 0.3119 -2.4312 1.1602 0.000 1.000 0.333 +v 0.4324 -2.5880 1.1602 0.000 1.000 0.333 +v 0.5536 -2.6967 1.0478 0.000 1.000 0.333 +v 0.9579 -2.4026 0.9511 0.000 1.000 0.400 +v 0.9316 -2.3390 0.7656 0.000 1.000 0.400 +v 0.8220 -2.2209 0.6510 0.000 1.000 0.400 +v 0.6709 -2.0932 0.6510 0.000 1.000 0.400 +v 0.5362 -2.0048 0.7656 0.000 1.000 0.400 +v 0.4691 -1.9894 0.9511 0.000 1.000 0.400 +v 0.4955 -2.0530 1.1365 0.000 1.000 0.400 +v 0.6051 -2.1711 1.2511 0.000 1.000 0.400 +v 0.7561 -2.2988 1.2511 0.000 1.000 0.400 +v 0.8909 -2.3872 1.1365 0.000 1.000 0.400 +v 1.2035 -2.0447 0.9945 0.000 1.000 0.467 +v 1.1553 -2.0055 0.8068 0.000 1.000 0.467 +v 1.0181 -1.9229 0.6907 0.000 1.000 0.467 +v 0.8444 -1.8282 0.6907 0.000 1.000 0.467 +v 0.7006 -1.7578 0.8068 0.000 1.000 0.467 +v 0.6416 -1.7384 0.9945 0.000 1.000 0.467 +v 0.6899 -1.7775 1.1823 0.000 1.000 0.467 +v 0.8270 -1.8602 1.2983 0.000 1.000 0.467 +v 1.0007 -1.9548 1.2983 0.000 1.000 0.467 +v 1.1445 -2.0253 1.1823 0.000 1.000 0.467 +v 1.3647 -1.6688 0.9945 0.000 1.000 0.529 +v 1.3028 -1.6620 0.8068 0.000 1.000 0.529 +v 1.1480 -1.6206 0.6908 0.000 1.000 0.529 +v 0.9596 -1.5604 0.6908 0.000 1.000 0.529 +v 0.8096 -1.5044 0.8068 0.000 1.000 0.529 +v 0.7551 -1.4740 0.9945 0.000 1.000 0.529 +v 0.8171 -1.4808 1.1822 0.000 1.000 0.529 +v 0.9718 -1.5222 1.2982 0.000 1.000 0.529 +v 1.1602 -1.5824 1.2982 0.000 1.000 0.529 +v 1.3103 -1.6384 1.1822 0.000 1.000 0.529 +v 1.4485 -1.3000 0.9511 0.000 1.000 0.600 +v 1.3827 -1.3301 0.7670 0.000 1.000 0.600 +v 1.2210 -1.3322 0.6533 0.000 1.000 0.600 +v 1.0250 -1.3054 0.6533 0.000 1.000 0.600 +v 0.8697 -1.2600 0.7670 0.000 1.000 0.600 +v 0.8144 -1.2133 0.9511 0.000 1.000 0.600 +v 0.8802 -1.1832 1.1351 0.000 1.000 0.600 +v 1.0420 -1.1811 1.2489 0.000 1.000 0.600 +v 1.2380 -1.2079 1.2489 0.000 1.000 0.600 +v 1.3932 -1.2533 1.1351 0.000 1.000 0.600 +v 1.4690 -0.9589 0.8660 0.000 1.000 0.663 +v 1.4090 -1.0266 0.6902 0.000 1.000 0.663 +v 1.2497 -1.0705 0.5815 0.000 1.000 0.663 +v 1.0520 -1.0737 0.5815 0.000 1.000 0.663 +v 0.8913 -1.0352 0.6902 0.000 1.000 0.663 +v 0.8291 -0.9695 0.8660 0.000 1.000 0.663 +v 0.8891 -0.9018 1.0419 0.000 1.000 0.663 +v 1.0484 -0.8579 1.1506 0.000 1.000 0.663 +v 1.2461 -0.8546 1.1506 0.000 1.000 0.663 +v 1.4068 -0.8932 1.0419 0.000 1.000 0.663 +v 1.4455 -0.6608 0.7431 0.000 1.000 0.733 +v 1.3981 -0.7627 0.5804 0.000 1.000 0.733 +v 1.2478 -0.8427 0.4799 0.000 1.000 0.733 +v 1.0519 -0.8702 0.4799 0.000 1.000 0.733 +v 0.8854 -0.8347 0.5804 0.000 1.000 0.733 +v 0.8117 -0.7497 0.7431 0.000 1.000 0.733 +v 0.8592 -0.6478 0.9059 0.000 1.000 0.733 +v 1.0095 -0.5678 1.0064 0.000 1.000 0.733 +v 1.2053 -0.5403 1.0064 0.000 1.000 0.733 +v 1.3719 -0.5758 0.9059 0.000 1.000 0.733 +v 1.4004 -0.4150 0.5878 0.000 1.000 0.796 +v 1.3666 -0.5447 0.4424 0.000 1.000 0.796 +v 1.2264 -0.6514 0.3525 0.000 1.000 0.796 +v 1.0334 -0.6944 0.3525 0.000 1.000 0.796 +v 0.8612 -0.6571 0.4424 0.000 1.000 0.796 +v 0.7756 -0.5539 0.5878 0.000 1.000 0.796 +v 0.8094 -0.4241 0.7332 0.000 1.000 0.796 +v 0.9496 -0.3174 0.8230 0.000 1.000 0.796 +v 1.1426 -0.2745 0.8230 0.000 1.000 0.796 +v 1.3148 -0.3117 0.7332 0.000 1.000 0.796 +v 1.3558 -0.2258 0.4067 0.000 1.000 0.867 +v 1.3284 -0.3754 0.2803 0.000 1.000 0.867 +v 1.1925 -0.4960 0.2022 0.000 1.000 0.867 +v 1.0000 -0.5415 0.2022 0.000 1.000 0.867 +v 0.8245 -0.4946 0.2803 0.000 1.000 0.867 +v 0.7330 -0.3731 0.4067 0.000 1.000 0.867 +v 0.7604 -0.2235 0.5331 0.000 1.000 0.867 +v 0.8963 -0.1029 0.6113 0.000 1.000 0.867 +v 1.0887 -0.0574 0.6113 0.000 1.000 0.867 +v 1.2643 -0.1043 0.5331 0.000 1.000 0.867 +v 1.3280 -0.0926 0.2079 0.000 1.000 0.929 +v 1.2913 -0.2523 0.0973 0.000 1.000 0.929 +v 1.1478 -0.3700 0.0289 0.000 1.000 0.929 +v 0.9524 -0.4007 0.0289 0.000 1.000 0.929 +v 0.7797 -0.3326 0.0973 0.000 1.000 0.929 +v 0.6958 -0.1918 0.2079 0.000 1.000 0.929 +v 0.7326 -0.0321 0.3185 0.000 1.000 0.929 +v 0.8761 0.0856 0.3869 0.000 1.000 0.929 +v 1.0714 0.1163 0.3869 0.000 1.000 0.929 +v 1.2441 0.0482 0.3185 0.000 1.000 0.929 +v 1.3200 -0.0004 0.0000 0.000 1.000 1.000 +v 1.2587 -0.1568 -0.1043 0.000 1.000 1.000 +v 1.0986 -0.2533 -0.1688 0.000 1.000 1.000 +v 0.9008 -0.2531 -0.1688 0.000 1.000 1.000 +v 0.7409 -0.1562 -0.1043 0.000 1.000 1.000 +v 0.6800 0.0004 0.0000 0.000 1.000 1.000 +v 0.7413 0.1568 0.1043 0.000 1.000 1.000 +v 0.9014 0.2533 0.1688 0.000 1.000 1.000 +v 1.0992 0.2531 0.1688 0.000 1.000 1.000 +v 1.2591 0.1562 0.1043 0.000 1.000 1.000 +v 1.3279 0.0920 -0.2079 0.000 0.933 1.000 +v 1.2438 -0.0485 -0.3187 0.000 0.933 1.000 +v 1.0710 -0.1162 -0.3872 0.000 0.933 1.000 +v 0.8757 -0.0852 -0.3872 0.000 0.933 1.000 +v 0.7324 0.0327 -0.3187 0.000 0.933 1.000 +v 0.6959 0.1924 -0.2079 0.000 0.933 1.000 +v 0.7801 0.3329 -0.0971 0.000 0.933 1.000 +v 0.9528 0.4006 -0.0286 0.000 0.933 1.000 +v 1.1481 0.3696 -0.0286 0.000 0.933 1.000 +v 1.2914 0.2517 -0.0971 0.000 0.933 1.000 +v 1.3557 0.2257 -0.4067 0.000 0.863 1.000 +v 1.2642 0.1045 -0.5334 0.000 0.863 1.000 +v 1.0887 0.0578 -0.6117 0.000 0.863 1.000 +v 0.8963 0.1033 -0.6117 0.000 0.863 1.000 +v 0.7604 0.2238 -0.5334 0.000 0.863 1.000 +v 0.7330 0.3732 -0.4067 0.000 0.863 1.000 +v 0.8245 0.4944 -0.2801 0.000 0.863 1.000 +v 1.0000 0.5412 -0.2018 0.000 0.863 1.000 +v 1.1925 0.4956 -0.2018 0.000 0.863 1.000 +v 1.3283 0.3751 -0.2801 0.000 0.863 1.000 +v 1.4004 0.4152 -0.5878 0.000 0.796 1.000 +v 1.3150 0.3122 -0.7334 0.000 0.796 1.000 +v 1.1429 0.2750 -0.8235 0.000 0.796 1.000 +v 0.9498 0.3178 -0.8235 0.000 0.796 1.000 +v 0.8095 0.4242 -0.7334 0.000 0.796 1.000 +v 0.7756 0.5536 -0.5878 0.000 0.796 1.000 +v 0.8610 0.6566 -0.4421 0.000 0.796 1.000 +v 1.0331 0.6938 -0.3521 0.000 0.796 1.000 +v 1.2262 0.6510 -0.3521 0.000 0.796 1.000 +v 1.3665 0.5446 -0.4421 0.000 0.796 1.000 +v 1.4456 0.6612 -0.7431 0.000 0.733 1.000 +v 1.3721 0.5766 -0.9061 0.000 0.733 1.000 +v 1.2057 0.5411 -1.0068 0.000 0.733 1.000 +v 1.0098 0.5683 -1.0068 0.000 0.733 1.000 +v 0.8593 0.6478 -0.9061 0.000 0.733 1.000 +v 0.8117 0.7493 -0.7431 0.000 0.733 1.000 +v 0.8851 0.8339 -0.5802 0.000 0.733 1.000 +v 1.0516 0.8694 -0.4795 0.000 0.733 1.000 +v 1.2475 0.8422 -0.4795 0.000 0.733 1.000 +v 1.3980 0.7627 -0.5802 0.000 0.733 1.000 +v 1.4690 0.9595 -0.8660 0.000 0.663 1.000 +v 1.4070 0.8941 -1.0420 0.000 0.663 1.000 +v 1.2464 0.8555 -1.1508 0.000 0.663 1.000 +v 1.0486 0.8584 -1.1508 0.000 0.663 1.000 +v 0.8892 0.9017 -1.0420 0.000 0.663 1.000 +v 0.8291 0.9689 -0.8660 0.000 0.663 1.000 +v 0.8912 1.0343 -0.6900 0.000 0.663 1.000 +v 1.0518 1.0729 -0.5812 0.000 0.663 1.000 +v 1.2495 1.0700 -0.5812 0.000 0.663 1.000 +v 1.4089 1.0267 -0.6900 0.000 0.663 1.000 +v 1.4484 1.3007 -0.9511 0.000 0.600 1.000 +v 1.3932 1.2543 -1.1352 0.000 0.600 1.000 +v 1.2380 1.2088 -1.2490 0.000 0.600 1.000 +v 1.0421 1.1815 -1.2490 0.000 0.600 1.000 +v 0.8804 1.1830 -1.1352 0.000 0.600 1.000 +v 0.8145 1.2126 -0.9511 0.000 0.600 1.000 +v 0.8698 1.2590 -0.7669 0.000 0.600 1.000 +v 1.0250 1.3045 -0.6531 0.000 0.600 1.000 +v 1.2209 1.3318 -0.6531 0.000 0.600 1.000 +v 1.3826 1.3303 -0.7669 0.000 0.600 1.000 +v 1.3645 1.6696 -0.9945 0.000 0.533 1.000 +v 1.3100 1.6394 -1.1822 0.000 0.533 1.000 +v 1.1600 1.5833 -1.2982 0.000 0.533 1.000 +v 0.9718 1.5226 -1.2982 0.000 0.533 1.000 +v 0.8172 1.4805 -1.1822 0.000 0.533 1.000 +v 0.7554 1.4732 -0.9945 0.000 0.533 1.000 +v 0.8099 1.5034 -0.8068 0.000 0.533 1.000 +v 0.9599 1.5596 -0.6908 0.000 0.533 1.000 +v 1.1481 1.6203 -0.6908 0.000 0.533 1.000 +v 1.3026 1.6623 -0.8068 0.000 0.533 1.000 +v 1.2031 2.0455 -0.9945 0.000 0.467 1.000 +v 1.1440 2.0262 -1.1823 0.000 0.467 1.000 +v 1.0003 1.9555 -1.2983 0.000 0.467 1.000 +v 0.8269 1.8604 -1.2983 0.000 0.467 1.000 +v 0.6900 1.7772 -1.1823 0.000 0.467 1.000 +v 0.6420 1.7376 -0.9945 0.000 0.467 1.000 +v 0.7012 1.7569 -0.8068 0.000 0.467 1.000 +v 0.8449 1.8276 -0.6908 0.000 0.467 1.000 +v 1.0183 1.9227 -0.6908 0.000 0.467 1.000 +v 1.1551 2.0059 -0.8068 0.000 0.467 1.000 +v 0.9573 2.4033 -0.9511 0.000 0.400 1.000 +v 0.8901 2.3879 -1.1364 0.000 0.400 1.000 +v 0.7555 2.2993 -1.2510 0.000 0.400 1.000 +v 0.6049 2.1711 -1.2510 0.000 0.400 1.000 +v 0.4957 2.0525 -1.1364 0.000 0.400 1.000 +v 0.4697 1.9887 -0.9511 0.000 0.400 1.000 +v 0.5369 2.0041 -0.7657 0.000 0.400 1.000 +v 0.6715 2.0928 -0.6511 0.000 0.400 1.000 +v 0.8222 2.2209 -0.6511 0.000 0.400 1.000 +v 0.9313 2.3395 -0.7657 0.000 0.400 1.000 +v 0.6283 2.7163 -0.8660 0.000 0.329 1.000 +v 0.5527 2.6972 -1.0478 0.000 0.329 1.000 +v 0.4318 2.5882 -1.1601 0.000 0.329 1.000 +v 0.3117 2.4311 -1.1601 0.000 0.329 1.000 +v 0.2385 2.2857 -1.0478 0.000 0.329 1.000 +v 0.2399 2.2077 -0.8660 0.000 0.329 1.000 +v 0.3155 2.2268 -0.6843 0.000 0.329 1.000 +v 0.4365 2.3358 -0.5720 0.000 0.329 1.000 +v 0.5565 2.4930 -0.5720 0.000 0.329 1.000 +v 0.6298 2.6383 -0.6843 0.000 0.329 1.000 +v 0.2262 2.9585 -0.7431 0.000 0.263 1.000 +v 0.1447 2.9286 -0.9208 0.000 0.263 1.000 +v 0.0434 2.7990 -1.0306 0.000 0.263 1.000 +v -0.0388 2.6191 -1.0306 0.000 0.263 1.000 +v -0.0707 2.4577 -0.9208 0.000 0.263 1.000 +v -0.0399 2.3765 -0.7431 0.000 0.263 1.000 +v 0.0416 2.4064 -0.5655 0.000 0.263 1.000 +v 0.1429 2.5360 -0.4557 0.000 0.263 1.000 +v 0.2251 2.7159 -0.4557 0.000 0.263 1.000 +v 0.2570 2.8773 -0.5655 0.000 0.263 1.000 +v -0.2308 3.1074 -0.5878 0.000 0.200 1.000 +v -0.3131 3.0616 -0.7617 0.000 0.200 1.000 +v -0.3880 2.9134 -0.8691 0.000 0.200 1.000 +v -0.4269 2.7195 -0.8691 0.000 0.200 1.000 +v -0.4148 2.5539 -0.7617 0.000 0.200 1.000 +v -0.3565 2.4799 -0.5878 0.000 0.200 1.000 +v -0.2741 2.5257 -0.4139 0.000 0.200 1.000 +v -0.1992 2.6739 -0.3065 0.000 0.200 1.000 +v -0.1604 2.8678 -0.3065 0.000 0.200 1.000 +v -0.1724 3.0334 -0.4139 0.000 0.200 1.000 +v -0.7175 3.1467 -0.4067 0.000 0.129 1.000 +v -0.7937 3.0826 -0.5776 0.000 0.129 1.000 +v -0.8360 2.9208 -0.6832 0.000 0.129 1.000 +v -0.8282 2.7231 -0.6832 0.000 0.129 1.000 +v -0.7732 2.5652 -0.5776 0.000 0.129 1.000 +v -0.6922 2.5073 -0.4067 0.000 0.129 1.000 +v -0.6160 2.5714 -0.2359 0.000 0.129 1.000 +v -0.5737 2.7332 -0.1303 0.000 0.129 1.000 +v -0.5815 2.9309 -0.1303 0.000 0.129 1.000 +v -0.6365 3.0888 -0.2359 0.000 0.129 1.000 +v -1.2045 3.0687 -0.2079 0.000 0.063 1.000 +v -1.2670 2.9870 -0.3768 0.000 0.063 1.000 +v -1.2718 2.8191 -0.4812 0.000 0.063 1.000 +v -1.2168 2.6291 -0.4812 0.000 0.063 1.000 +v -1.1233 2.4896 -0.3768 0.000 0.063 1.000 +v -1.0268 2.4539 -0.2079 0.000 0.063 1.000 +v -0.9642 2.5356 -0.0390 0.000 0.063 1.000 +v -0.9595 2.7035 0.0654 0.000 0.063 1.000 +v -1.0144 2.8935 0.0654 0.000 0.063 1.000 +v -1.1080 3.0330 -0.0390 0.000 0.063 1.000 +v -1.6605 2.8749 -0.0000 0.000 0.000 1.000 +v -1.7026 2.7799 -0.1682 0.000 0.000 1.000 +v -1.6673 2.6154 -0.2722 0.000 0.000 1.000 +v -1.5682 2.4443 -0.2722 0.000 0.000 1.000 +v -1.4429 2.3319 -0.1682 0.000 0.000 1.000 +v -1.3395 2.3212 -0.0000 0.000 0.000 1.000 +v -1.2974 2.4163 0.1682 0.000 0.000 1.000 +v -1.3327 2.5808 0.2722 0.000 0.000 1.000 +v -1.4318 2.7519 0.2722 0.000 0.000 1.000 +v -1.5571 2.8642 0.1682 0.000 0.000 1.000 +v -2.0561 2.5767 0.2079 0.063 0.000 1.000 +v -2.0731 2.4752 0.0390 0.063 0.000 1.000 +v -1.9985 2.3247 -0.0654 0.063 0.000 1.000 +v -1.8609 2.1826 -0.0654 0.063 0.000 1.000 +v -1.7129 2.1033 0.0390 0.063 0.000 1.000 +v -1.6109 2.1169 0.2079 0.063 0.000 1.000 +v -1.5940 2.2184 0.3768 0.063 0.000 1.000 +v -1.6686 2.3689 0.4812 0.063 0.000 1.000 +v -1.8061 2.5110 0.4812 0.063 0.000 1.000 +v -1.9542 2.5904 0.3768 0.063 0.000 1.000 +v -2.3670 2.1938 0.4067 0.129 0.000 1.000 +v -2.3569 2.0948 0.2359 0.129 0.000 1.000 +v -2.2472 1.9686 0.1303 0.129 0.000 1.000 +v -2.0796 1.8636 0.1303 0.129 0.000 1.000 +v -1.9182 1.8198 0.2359 0.129 0.000 1.000 +v -1.8247 1.8540 0.4067 0.129 0.000 1.000 +v -1.8347 1.9531 0.5776 0.129 0.000 1.000 +v -1.9445 2.0792 0.6832 0.129 0.000 1.000 +v -2.1121 2.1842 0.6832 0.129 0.000 1.000 +v -2.2735 2.2280 0.5776 0.129 0.000 1.000 +v -2.5760 1.7525 0.5878 0.196 0.000 1.000 +v -2.5408 1.6652 0.4139 0.196 0.000 1.000 +v -2.4031 1.5725 0.3064 0.196 0.000 1.000 +v -2.2155 1.5099 0.3064 0.196 0.000 1.000 +v -2.0497 1.5011 0.4139 0.196 0.000 1.000 +v -1.9690 1.5497 0.5878 0.196 0.000 1.000 +v -2.0043 1.6370 0.7617 0.196 0.000 1.000 +v -2.1420 1.7297 0.8692 0.196 0.000 1.000 +v -2.3296 1.7923 0.8692 0.196 0.000 1.000 +v -2.4954 1.8011 0.7617 0.196 0.000 1.000 +v -2.6754 1.2823 0.7431 0.263 0.000 1.000 +v -2.6201 1.2154 0.5654 0.263 0.000 1.000 +v -2.4643 1.1629 0.4556 0.263 0.000 1.000 +v -2.2673 1.1448 0.4556 0.263 0.000 1.000 +v -2.1045 1.1681 0.5654 0.263 0.000 1.000 +v -2.0380 1.2239 0.7431 0.263 0.000 1.000 +v -2.0933 1.2908 0.9209 0.263 0.000 1.000 +v -2.2491 1.3433 1.0307 0.263 0.000 1.000 +v -2.4461 1.3613 1.0307 0.263 0.000 1.000 +v -2.6089 1.3380 0.9209 0.263 0.000 1.000 +v -2.6665 0.8130 0.8660 0.329 0.000 1.000 +v -2.5995 0.7732 0.6842 0.329 0.000 1.000 +v -2.4370 0.7646 0.5719 0.329 0.000 1.000 +v -2.2409 0.7906 0.5719 0.329 0.000 1.000 +v -2.0862 0.8412 0.6842 0.329 0.000 1.000 +v -2.0320 0.8971 0.8660 0.329 0.000 1.000 +v -2.0989 0.9369 1.0478 0.329 0.000 1.000 +v -2.2615 0.9455 1.1602 0.329 0.000 1.000 +v -2.4575 0.9195 1.1602 0.329 0.000 1.000 +v -2.6122 0.8689 1.0478 0.329 0.000 1.000 +v -2.5597 0.3717 0.9511 0.396 0.000 1.000 +v -2.4915 0.3627 0.7656 0.396 0.000 1.000 +v -2.3343 0.3986 0.6510 0.396 0.000 1.000 +v -2.1482 0.4656 0.6510 0.396 0.000 1.000 +v -2.0043 0.5381 0.7656 0.396 0.000 1.000 +v -1.9575 0.5884 0.9511 0.396 0.000 1.000 +v -2.0256 0.5974 1.1365 0.396 0.000 1.000 +v -2.1828 0.5616 1.2511 0.396 0.000 1.000 +v -2.3689 0.4946 1.2511 0.396 0.000 1.000 +v -2.5128 0.4221 1.1365 0.396 0.000 1.000 +v -2.3725 -0.0200 0.9945 0.467 0.000 1.000 +v -2.3145 0.0023 0.8068 0.467 0.000 1.000 +v -2.1743 0.0797 0.6907 0.467 0.000 1.000 +v -2.0055 0.1828 0.6907 0.467 0.000 1.000 +v -1.8726 0.2721 0.8068 0.467 0.000 1.000 +v -1.8263 0.3136 0.9945 0.467 0.000 1.000 +v -1.8843 0.2913 1.1823 0.467 0.000 1.000 +v -2.0245 0.2139 1.2983 0.467 0.000 1.000 +v -2.1933 0.1108 1.2983 0.467 0.000 1.000 +v -2.3262 0.0215 1.1823 0.467 0.000 1.000 +v -2.1276 -0.3475 0.9945 0.529 0.000 1.000 +v -2.0907 -0.2972 0.8068 0.529 0.000 1.000 +v -1.9775 -0.1839 0.6908 0.529 0.000 1.000 +v -1.8312 -0.0509 0.6908 0.529 0.000 1.000 +v -1.7076 0.0511 0.8068 0.529 0.000 1.000 +v -1.6541 0.0831 0.9945 0.529 0.000 1.000 +v -1.6910 0.0328 1.1822 0.529 0.000 1.000 +v -1.8042 -0.0805 1.2982 0.529 0.000 1.000 +v -1.9505 -0.2136 1.2982 0.529 0.000 1.000 +v -2.0741 -0.3155 1.1822 0.529 0.000 1.000 +v -1.8501 -0.6045 0.9511 0.600 0.000 1.000 +v -1.8433 -0.5324 0.7670 0.600 0.000 1.000 +v -1.7642 -0.3913 0.6533 0.600 0.000 1.000 +v -1.6430 -0.2350 0.6533 0.600 0.000 1.000 +v -1.5261 -0.1232 0.7670 0.600 0.000 1.000 +v -1.4580 -0.0987 0.9511 0.600 0.000 1.000 +v -1.4648 -0.1707 1.1351 0.600 0.000 1.000 +v -1.5439 -0.3118 1.2489 0.600 0.000 1.000 +v -1.6650 -0.4682 1.2489 0.600 0.000 1.000 +v -1.7820 -0.5799 1.1351 0.600 0.000 1.000 +v -1.5649 -0.7928 0.8660 0.667 0.000 1.000 +v -1.5936 -0.7070 0.6902 0.667 0.000 1.000 +v -1.5519 -0.5471 0.5815 0.667 0.000 1.000 +v -1.4559 -0.3742 0.5815 0.667 0.000 1.000 +v -1.3421 -0.2543 0.6902 0.667 0.000 1.000 +v -1.2542 -0.2333 0.8660 0.667 0.000 1.000 +v -1.2255 -0.3191 1.0419 0.667 0.000 1.000 +v -1.2672 -0.4790 1.1506 0.667 0.000 1.000 +v -1.3632 -0.6519 1.1506 0.667 0.000 1.000 +v -1.4769 -0.7717 1.0419 0.667 0.000 1.000 +v -1.2950 -0.9215 0.7431 0.733 0.000 1.000 +v -1.3596 -0.8295 0.5804 0.733 0.000 1.000 +v -1.3537 -0.6593 0.4799 0.733 0.000 1.000 +v -1.2795 -0.4759 0.4799 0.733 0.000 1.000 +v -1.1655 -0.3494 0.5804 0.733 0.000 1.000 +v -1.0552 -0.3281 0.7431 0.733 0.000 1.000 +v -0.9906 -0.4201 0.9059 0.733 0.000 1.000 +v -0.9965 -0.5903 1.0064 0.733 0.000 1.000 +v -1.0706 -0.7737 1.0064 0.733 0.000 1.000 +v -1.1846 -0.9002 0.9059 0.733 0.000 1.000 +v -1.0596 -1.0053 0.5878 0.800 0.000 1.000 +v -1.1551 -0.9112 0.4424 0.800 0.000 1.000 +v -1.1774 -0.7364 0.3525 0.800 0.000 1.000 +v -1.1180 -0.5478 0.3525 0.800 0.000 1.000 +v -0.9997 -0.4173 0.4424 0.800 0.000 1.000 +v -0.8675 -0.3948 0.5878 0.800 0.000 1.000 +v -0.7720 -0.4889 0.7332 0.800 0.000 1.000 +v -0.7497 -0.6637 0.8230 0.800 0.000 1.000 +v -0.8090 -0.8523 0.8230 0.800 0.000 1.000 +v -0.9274 -0.9828 0.7332 0.800 0.000 1.000 +v -0.8735 -1.0612 0.4067 0.867 0.000 1.000 +v -0.9893 -0.9627 0.2803 0.867 0.000 1.000 +v -1.0258 -0.7847 0.2022 0.867 0.000 1.000 +v -0.9690 -0.5953 0.2022 0.867 0.000 1.000 +v -0.8406 -0.4667 0.2803 0.867 0.000 1.000 +v -0.6896 -0.4482 0.4067 0.867 0.000 1.000 +v -0.5737 -0.5468 0.5331 0.867 0.000 1.000 +v -0.5373 -0.7247 0.6113 0.867 0.000 1.000 +v -0.5941 -0.9142 0.6113 0.867 0.000 1.000 +v -0.7225 -1.0427 0.5331 0.867 0.000 1.000 +v -0.7442 -1.1038 0.2079 0.933 0.000 1.000 +v -0.8642 -0.9921 0.0973 0.933 0.000 1.000 +v -0.8943 -0.8090 0.0289 0.933 0.000 1.000 +v -0.8232 -0.6244 0.0289 0.933 0.000 1.000 +v -0.6779 -0.5090 0.0973 0.933 0.000 1.000 +v -0.5140 -0.5066 0.2079 0.933 0.000 1.000 +v -0.3941 -0.6184 0.3185 0.933 0.000 1.000 +v -0.3639 -0.8015 0.3869 0.933 0.000 1.000 +v -0.4350 -0.9860 0.3869 0.933 0.000 1.000 +v -0.5803 -1.1015 0.3185 0.933 0.000 1.000 +v -0.6603 -1.1430 0.0000 1.000 0.000 1.000 +v -0.7652 -1.0116 -0.1043 1.000 0.000 1.000 +v -0.7687 -0.8247 -0.1688 1.000 0.000 1.000 +v -0.6696 -0.6536 -0.1688 1.000 0.000 1.000 +v -0.5057 -0.5636 -0.1043 1.000 0.000 1.000 +v -0.3397 -0.5891 0.0000 1.000 0.000 1.000 +v -0.2348 -0.7204 0.1043 1.000 0.000 1.000 +v -0.2313 -0.9073 0.1688 1.000 0.000 1.000 +v -0.3304 -1.0785 0.1688 1.000 0.000 1.000 +v -0.4943 -1.1685 0.1043 1.000 0.000 1.000 +v -0.5843 -1.1961 -0.2079 1.000 0.000 0.933 +v -0.6639 -1.0529 -0.3187 1.000 0.000 0.933 +v -0.6361 -0.8694 -0.3872 1.000 0.000 0.933 +v -0.5116 -0.7158 -0.3872 1.000 0.000 0.933 +v -0.3379 -0.6506 -0.3187 1.000 0.000 0.933 +v -0.1813 -0.6988 -0.2079 1.000 0.000 0.933 +v -0.1017 -0.8420 -0.0971 1.000 0.000 0.933 +v -0.1295 -1.0255 -0.0286 1.000 0.000 0.933 +v -0.2540 -1.1791 -0.0286 1.000 0.000 0.933 +v -0.4277 -1.2443 -0.0971 1.000 0.000 0.933 +v -0.4824 -1.2870 -0.4067 1.000 0.000 0.867 +v -0.5416 -1.1471 -0.5334 1.000 0.000 0.867 +v -0.4943 -0.9717 -0.6117 1.000 0.000 0.867 +v -0.3586 -0.8279 -0.6117 1.000 0.000 0.867 +v -0.1863 -0.7705 -0.5334 1.000 0.000 0.867 +v -0.0433 -0.8214 -0.4067 1.000 0.000 0.867 +v 0.0159 -0.9613 -0.2801 1.000 0.000 0.867 +v -0.0313 -1.1366 -0.2018 1.000 0.000 0.867 +v -0.1670 -1.2805 -0.2018 1.000 0.000 0.867 +v -0.3393 -1.3379 -0.2801 1.000 0.000 0.867 +v -0.3406 -1.4204 -0.5878 1.000 0.000 0.796 +v -0.3871 -1.2950 -0.7334 1.000 0.000 0.796 +v -0.3333 -1.1273 -0.8235 1.000 0.000 0.796 +v -0.1997 -0.9815 -0.8235 1.000 0.000 0.796 +v -0.0374 -0.9132 -0.7334 1.000 0.000 0.796 +v 0.0917 -0.9485 -0.5878 1.000 0.000 0.796 +v 0.1381 -1.0739 -0.4421 1.000 0.000 0.796 +v 0.0843 -1.2416 -0.3521 1.000 0.000 0.796 +v -0.0493 -1.3874 -0.3521 1.000 0.000 0.796 +v -0.2116 -1.4557 -0.4421 1.000 0.000 0.796 +v -0.1502 -1.5825 -0.7431 1.000 0.000 0.733 +v -0.1867 -1.4766 -0.9061 1.000 0.000 0.733 +v -0.1343 -1.3147 -1.0068 1.000 0.000 0.733 +v -0.0127 -1.1586 -1.0068 1.000 0.000 0.733 +v 0.1314 -1.0681 -0.9061 1.000 0.000 0.733 +v 0.2430 -1.0776 -0.7431 1.000 0.000 0.733 +v 0.2796 -1.1835 -0.5802 1.000 0.000 0.733 +v 0.2271 -1.3454 -0.4795 1.000 0.000 0.733 +v 0.1056 -1.5015 -0.4795 1.000 0.000 0.733 +v -0.0385 -1.5920 -0.5802 1.000 0.000 0.733 +v 0.0964 -1.7520 -0.8660 1.000 0.000 0.667 +v 0.0708 -1.6655 -1.0420 1.000 0.000 0.667 +v 0.1177 -1.5071 -1.1508 1.000 0.000 0.667 +v 0.2191 -1.3373 -1.1508 1.000 0.000 0.667 +v 0.3362 -1.2209 -1.0420 1.000 0.000 0.667 +v 0.4245 -1.2024 -0.8660 1.000 0.000 0.667 +v 0.4501 -1.2889 -0.6900 1.000 0.000 0.667 +v 0.4033 -1.4473 -0.5812 1.000 0.000 0.667 +v 0.3019 -1.6171 -0.5812 1.000 0.000 0.667 +v 0.1847 -1.7335 -0.6900 1.000 0.000 0.667 +v 0.4022 -1.9047 -0.9511 1.000 0.000 0.596 +v 0.3896 -1.8337 -1.1352 1.000 0.000 0.596 +v 0.4278 -1.6765 -1.2490 1.000 0.000 0.596 +v 0.5022 -1.4932 -1.2490 1.000 0.000 0.596 +v 0.5843 -1.3539 -1.1352 1.000 0.000 0.596 +v 0.6429 -1.3117 -0.9511 1.000 0.000 0.596 +v 0.6554 -1.3828 -0.7669 1.000 0.000 0.596 +v 0.6173 -1.5399 -0.6531 1.000 0.000 0.596 +v 0.5429 -1.7232 -0.6531 1.000 0.000 0.596 +v 0.4608 -1.8625 -0.7669 1.000 0.000 0.596 +v 0.7637 -2.0165 -0.9945 1.000 0.000 0.529 +v 0.7648 -1.9542 -1.1822 1.000 0.000 0.529 +v 0.7911 -1.7962 -1.2982 1.000 0.000 0.529 +v 0.8327 -1.6029 -1.2982 1.000 0.000 0.529 +v 0.8736 -1.4480 -1.1822 1.000 0.000 0.529 +v 0.8982 -1.3908 -0.9945 1.000 0.000 0.529 +v 0.8971 -1.4531 -0.8068 1.000 0.000 0.529 +v 0.8707 -1.6110 -0.6908 1.000 0.000 0.529 +v 0.8292 -1.8044 -0.6908 1.000 0.000 0.529 +v 0.7883 -1.9593 -0.8068 1.000 0.000 0.529 +v 1.1699 -2.0647 -0.9945 1.000 0.000 0.467 +v 1.1828 -2.0038 -1.1823 1.000 0.000 0.467 +v 1.1934 -1.8440 -1.2983 1.000 0.000 0.467 +v 1.1977 -1.6463 -1.2983 1.000 0.000 0.467 +v 1.1941 -1.4862 -1.1823 1.000 0.000 0.467 +v 1.1838 -1.4248 -0.9945 1.000 0.000 0.467 +v 1.1709 -1.4857 -0.8068 1.000 0.000 0.467 +v 1.1603 -1.6455 -0.6908 1.000 0.000 0.467 +v 1.1560 -1.8432 -0.6908 1.000 0.000 0.467 +v 1.1596 -2.0033 -0.8068 1.000 0.000 0.467 +v 1.6027 -2.0307 -0.9511 1.000 0.000 0.400 +v 1.6229 -1.9649 -1.1364 1.000 0.000 0.400 +v 1.6135 -1.8039 -1.2510 1.000 0.000 0.400 +v 1.5778 -1.6094 -1.2510 1.000 0.000 0.400 +v 1.5297 -1.4556 -1.1364 1.000 0.000 0.400 +v 1.4874 -1.4012 -0.9511 1.000 0.000 0.400 +v 1.4671 -1.4670 -0.7657 1.000 0.000 0.400 +v 1.4766 -1.6279 -0.6511 1.000 0.000 0.400 +v 1.5122 -1.8225 -0.6511 1.000 0.000 0.400 +v 1.5604 -1.9763 -0.7657 1.000 0.000 0.400 +v 2.0383 -1.9023 -0.8660 1.000 0.000 0.333 +v 2.0595 -1.8273 -1.0478 1.000 0.000 0.333 +v 2.0256 -1.6680 -1.1601 1.000 0.000 0.333 +v 1.9495 -1.4855 -1.1601 1.000 0.000 0.333 +v 1.8602 -1.3494 -1.0478 1.000 0.000 0.333 +v 1.7920 -1.3116 -0.8660 1.000 0.000 0.333 +v 1.7707 -1.3867 -0.6843 1.000 0.000 0.333 +v 1.8046 -1.5459 -0.5720 1.000 0.000 0.333 +v 1.8807 -1.7284 -0.5720 1.000 0.000 0.333 +v 1.9700 -1.8646 -0.6843 1.000 0.000 0.333 +v 2.4490 -1.6752 -0.7431 1.000 0.000 0.263 +v 2.4639 -1.5896 -0.9208 1.000 0.000 0.263 +v 2.4023 -1.4371 -1.0306 1.000 0.000 0.263 +v 2.2876 -1.2759 -1.0306 1.000 0.000 0.263 +v 2.1638 -1.1677 -0.9208 1.000 0.000 0.263 +v 2.0781 -1.1537 -0.7431 1.000 0.000 0.263 +v 2.0632 -1.2393 -0.5655 1.000 0.000 0.263 +v 2.1248 -1.3918 -0.4557 1.000 0.000 0.263 +v 2.2395 -1.5529 -0.4557 1.000 0.000 0.263 +v 2.3633 -1.6612 -0.5655 1.000 0.000 0.263 +v 2.8065 -1.3539 -0.5878 1.000 0.000 0.200 +v 2.8079 -1.2596 -0.7617 1.000 0.000 0.200 +v 2.7171 -1.1207 -0.8691 1.000 0.000 0.200 +v 2.5686 -0.9901 -0.8691 1.000 0.000 0.200 +v 2.4191 -0.9177 -0.7617 1.000 0.000 0.200 +v 2.3259 -0.9312 -0.5878 1.000 0.000 0.200 +v 2.3244 -1.0254 -0.4139 1.000 0.000 0.200 +v 2.4153 -1.1644 -0.3065 1.000 0.000 0.200 +v 2.5638 -1.2950 -0.3065 1.000 0.000 0.200 +v 2.7132 -1.3674 -0.4139 1.000 0.000 0.200 +v 3.0839 -0.9520 -0.4067 1.000 0.000 0.133 +v 3.0664 -0.8539 -0.5776 1.000 0.000 0.133 +v 2.9475 -0.7364 -0.6832 1.000 0.000 0.133 +v 2.7724 -0.6444 -0.6832 1.000 0.000 0.133 +v 2.6081 -0.6130 -0.5776 1.000 0.000 0.133 +v 2.5174 -0.6542 -0.4067 1.000 0.000 0.133 +v 2.5349 -0.7523 -0.2359 1.000 0.000 0.133 +v 2.6539 -0.8698 -0.1303 1.000 0.000 0.133 +v 2.8290 -0.9618 -0.1303 1.000 0.000 0.133 +v 2.9932 -0.9932 -0.2359 1.000 0.000 0.133 +v 3.2598 -0.4912 -0.2079 1.000 0.000 0.063 +v 3.2203 -0.3962 -0.3768 1.000 0.000 0.063 +v 3.0773 -0.3082 -0.4812 1.000 0.000 0.063 +v 2.8853 -0.2607 -0.4812 1.000 0.000 0.063 +v 2.7177 -0.2720 -0.3768 1.000 0.000 0.063 +v 2.6385 -0.3377 -0.2079 1.000 0.000 0.063 +v 2.6780 -0.4327 -0.0390 1.000 0.000 0.063 +v 2.8210 -0.5208 0.0654 1.000 0.000 0.063 +v 3.0130 -0.5682 0.0654 1.000 0.000 0.063 +v 3.1806 -0.5569 -0.0390 1.000 0.000 0.063 +vn 1.0000 0.0017 -0.0000 +vn 0.8086 0.2643 -0.5257 +vn 0.3083 0.4259 -0.8507 +vn -0.3098 0.4248 -0.8507 +vn -0.8095 0.2614 -0.5257 +vn -1.0000 -0.0017 -0.0000 +vn -0.8086 -0.2643 0.5257 +vn -0.3083 -0.4259 0.8507 +vn 0.3098 -0.4248 0.8507 +vn 0.8095 -0.2614 0.5257 +vn 0.9700 0.2432 -0.0000 +vn 0.7218 0.4476 -0.5279 +vn 0.1980 0.4810 -0.8541 +vn -0.4015 0.3306 -0.8541 +vn -0.8476 0.0540 -0.5279 +vn -0.9700 -0.2432 -0.0000 +vn -0.7218 -0.4476 0.5279 +vn -0.1980 -0.4810 0.8541 +vn 0.4015 -0.3306 0.8541 +vn 0.8476 -0.0540 0.5279 +vn 0.8836 0.4683 -0.0000 +vn 0.5998 0.5959 -0.5340 +vn 0.0869 0.4959 -0.8640 +vn -0.4592 0.2065 -0.8640 +vn -0.8299 -0.1618 -0.5340 +vn -0.8836 -0.4683 -0.0000 +vn -0.5998 -0.5959 0.5340 +vn -0.0869 -0.4959 0.8640 +vn 0.4592 -0.2065 0.8640 +vn 0.8299 0.1618 0.5340 +vn 0.7487 0.6629 -0.0000 +vn 0.4574 0.7039 -0.5435 +vn -0.0087 0.4760 -0.8794 +vn -0.4714 0.0663 -0.8794 +vn -0.7541 -0.3687 -0.5435 +vn -0.7487 -0.6629 -0.0000 +vn -0.4574 -0.7039 0.5435 +vn 0.0087 -0.4760 0.8794 +vn 0.4714 -0.0663 0.8794 +vn 0.7541 0.3687 0.5435 +vn 0.5770 0.8167 -0.0000 +vn 0.3096 0.7718 -0.5554 +vn -0.0760 0.4320 -0.8986 +vn -0.4326 -0.0727 -0.8986 +vn -0.6240 -0.5497 -0.5554 +vn -0.5770 -0.8167 -0.0000 +vn -0.3096 -0.7718 0.5554 +vn 0.0760 -0.4320 0.8986 +vn 0.4326 0.0727 0.8986 +vn 0.6240 0.5497 0.5554 +vn 0.3819 0.9242 -0.0000 +vn 0.1696 0.8053 -0.5681 +vn -0.1076 0.3788 -0.9192 +vn -0.3436 -0.1924 -0.9192 +vn -0.4484 -0.6901 -0.5681 +vn -0.3819 -0.9242 -0.0000 +vn -0.1696 -0.8053 0.5681 +vn 0.1076 -0.3788 0.9192 +vn 0.3436 0.1924 0.9192 +vn 0.4484 0.6901 0.5681 +vn 0.1772 0.9842 -0.0000 +vn 0.0464 0.8137 -0.5795 +vn -0.1022 0.3324 -0.9376 +vn -0.2117 -0.2759 -0.9376 +vn -0.2403 -0.7788 -0.5795 +vn -0.1772 -0.9842 -0.0000 +vn -0.0464 -0.8137 0.5795 +vn 0.1022 -0.3324 0.9376 +vn 0.2117 0.2759 0.9376 +vn 0.2403 0.7788 0.5795 +vn -0.0246 0.9997 0.0000 +vn -0.0551 0.8079 -0.5867 +vn -0.0645 0.3075 -0.9494 +vn -0.0493 -0.3103 -0.9494 +vn -0.0152 -0.8096 -0.5867 +vn 0.0246 -0.9997 -0.0000 +vn 0.0551 -0.8079 0.5867 +vn 0.0645 -0.3075 0.9494 +vn 0.0493 0.3103 0.9494 +vn 0.0152 0.8096 0.5867 +vn -0.2127 0.9771 0.0000 +vn -0.1342 0.7987 -0.5865 +vn -0.0045 0.3153 -0.9490 +vn 0.1269 -0.2886 -0.9490 +vn 0.2099 -0.7823 -0.5865 +vn 0.2127 -0.9771 -0.0000 +vn 0.1342 -0.7987 0.5865 +vn 0.0045 -0.3153 0.9490 +vn -0.1269 0.2886 0.9490 +vn -0.2099 0.7823 0.5865 +vn -0.3782 0.9257 0.0000 +vn -0.1938 0.7948 -0.5752 +vn 0.0646 0.3602 -0.9306 +vn 0.2983 -0.2119 -0.9306 +vn 0.4181 -0.7031 -0.5752 +vn 0.3782 -0.9257 -0.0000 +vn 0.1938 -0.7948 0.5752 +vn -0.0646 -0.3602 0.9306 +vn -0.2983 0.2119 0.9306 +vn -0.4181 0.7031 0.5752 +vn -0.5143 0.8576 0.0000 +vn -0.2373 0.8010 -0.5496 +vn 0.1303 0.4385 -0.8892 +vn 0.4482 -0.0916 -0.8892 +vn 0.5948 -0.5866 -0.5496 +vn 0.5143 -0.8576 -0.0000 +vn 0.2373 -0.8010 0.5496 +vn -0.1303 -0.4385 0.8892 +vn -0.4482 0.0916 0.8892 +vn -0.5948 0.5866 0.5496 +vn -0.6155 0.7881 0.0000 +vn -0.2657 0.8190 -0.5085 +vn 0.1857 0.5371 -0.8228 +vn 0.5661 0.0501 -0.8228 +vn 0.7303 -0.4561 -0.5085 +vn 0.6155 -0.7881 -0.0000 +vn 0.2657 -0.8190 0.5085 +vn -0.1857 -0.5371 0.8228 +vn -0.5661 -0.0501 0.8228 +vn -0.7303 0.4561 0.5085 +vn -0.6760 0.7369 0.0000 +vn -0.2721 0.8483 -0.4543 +vn 0.2357 0.6356 -0.7351 +vn 0.6535 0.1802 -0.7351 +vn 0.8217 -0.3440 -0.4543 +vn 0.6760 -0.7369 -0.0000 +vn 0.2721 -0.8483 0.4543 +vn -0.2357 -0.6356 0.7351 +vn -0.6535 -0.1802 0.7351 +vn -0.8217 0.3440 0.4543 +vn -0.6859 0.7277 0.0000 +vn -0.2382 0.8873 -0.3950 +vn 0.3005 0.7079 -0.6392 +vn 0.7244 0.2582 -0.6392 +vn 0.8716 -0.2902 -0.3950 +vn 0.6859 -0.7277 -0.0000 +vn 0.2382 -0.8873 0.3950 +vn -0.3005 -0.7079 0.6392 +vn -0.7244 -0.2582 0.6392 +vn -0.8716 0.2902 0.3950 +vn -0.6283 0.7780 0.0000 +vn -0.1384 0.9281 -0.3457 +vn 0.4043 0.7236 -0.5594 +vn 0.7925 0.2428 -0.5594 +vn 0.8781 -0.3308 -0.3457 +vn 0.6283 -0.7780 -0.0000 +vn 0.1384 -0.9281 0.3457 +vn -0.4043 -0.7236 0.5594 +vn -0.7925 -0.2428 0.5594 +vn -0.8781 0.3308 0.3457 +vn -0.4989 0.8666 0.0000 +vn 0.0202 0.9451 -0.3260 +vn 0.5316 0.6626 -0.5276 +vn 0.8400 0.1270 -0.5276 +vn 0.8275 -0.4571 -0.3260 +vn 0.4989 -0.8666 -0.0000 +vn -0.0202 -0.9451 0.3260 +vn -0.5316 -0.6626 0.5276 +vn -0.8400 -0.1270 0.5276 +vn -0.8275 0.4571 0.3260 +vn -0.3580 0.9337 0.0000 +vn 0.1539 0.9254 -0.3463 +vn 0.6069 0.5637 -0.5603 +vn 0.8282 -0.0134 -0.5603 +vn 0.7331 -0.5854 -0.3463 +vn 0.3580 -0.9337 -0.0000 +vn -0.1539 -0.9254 0.3463 +vn -0.6069 -0.5637 0.5603 +vn -0.8282 0.0134 0.5603 +vn -0.7331 0.5854 0.3463 +vn -0.2869 0.9580 0.0000 +vn 0.1841 0.8997 -0.3959 +vn 0.5848 0.4977 -0.6405 +vn 0.7621 -0.0943 -0.6405 +vn 0.6483 -0.6504 -0.3959 +vn 0.2869 -0.9580 -0.0000 +vn -0.1841 -0.8997 0.3959 +vn -0.5848 -0.4977 0.6405 +vn -0.7621 0.0943 0.6405 +vn -0.6483 0.6504 0.3959 +vn -0.3009 0.9537 0.0000 +vn 0.1113 0.8834 -0.4552 +vn 0.4809 0.4757 -0.7365 +vn 0.6668 -0.1137 -0.7365 +vn 0.5981 -0.6596 -0.4552 +vn 0.3009 -0.9537 -0.0000 +vn -0.1113 -0.8834 0.4552 +vn -0.4809 -0.4757 0.7365 +vn -0.6668 0.1137 0.7365 +vn -0.5981 0.6596 0.4552 +vn -0.3761 0.9266 0.0000 +vn -0.0323 0.8600 -0.5092 +vn 0.3239 0.4650 -0.8239 +vn 0.5564 -0.1076 -0.8239 +vn 0.5763 -0.6392 -0.5092 +vn 0.3761 -0.9266 -0.0000 +vn 0.0323 -0.8600 0.5092 +vn -0.3239 -0.4650 0.8239 +vn -0.5564 0.1076 0.8239 +vn -0.5763 0.6392 0.5092 +vn -0.4873 0.8732 0.0000 +vn -0.2133 0.8074 -0.5501 +vn 0.1421 0.4332 -0.8900 +vn 0.4433 -0.1065 -0.8900 +vn 0.5752 -0.6055 -0.5501 +vn 0.4873 -0.8732 -0.0000 +vn 0.2133 -0.8074 0.5501 +vn -0.1421 -0.4332 0.8900 +vn -0.4433 0.1065 0.8900 +vn -0.5752 0.6055 0.5501 +vn -0.6145 0.7889 0.0000 +vn -0.4025 0.7119 -0.5754 +vn -0.0368 0.3630 -0.9311 +vn 0.3430 -0.1246 -0.9311 +vn 0.5917 -0.5646 -0.5754 +vn 0.6145 -0.7889 -0.0000 +vn 0.4025 -0.7119 0.5754 +vn 0.0368 -0.3630 0.9311 +vn -0.3430 0.1246 0.9311 +vn -0.5917 0.5646 0.5754 +vn -0.7416 0.6708 0.0000 +vn -0.5748 0.5705 -0.5866 +vn -0.1884 0.2524 -0.9491 +vn 0.2700 -0.1622 -0.9491 +vn 0.6252 -0.5148 -0.5866 +vn 0.7416 -0.6708 -0.0000 +vn 0.5748 -0.5705 0.5866 +vn 0.1884 -0.2524 0.9491 +vn -0.2700 0.1622 0.9491 +vn -0.6252 0.5148 0.5866 +vn -0.8549 0.5188 0.0000 +vn -0.7104 0.3888 -0.5867 +vn -0.2945 0.1103 -0.9493 +vn 0.2338 -0.2103 -0.9493 +vn 0.6729 -0.4506 -0.5867 +vn 0.8549 -0.5188 -0.0000 +vn 0.7104 -0.3888 0.5867 +vn 0.2945 -0.1103 0.9493 +vn -0.2338 0.2103 0.9493 +vn -0.6729 0.4506 0.5867 +vn -0.9419 0.3358 0.0000 +vn -0.7954 0.1781 -0.5793 +vn -0.3451 -0.0477 -0.9374 +vn 0.2371 -0.2552 -0.9374 +vn 0.7287 -0.3653 -0.5793 +vn 0.9419 -0.3358 -0.0000 +vn 0.7954 -0.1781 0.5793 +vn 0.3451 0.0477 0.9374 +vn -0.2371 0.2552 0.9374 +vn -0.7287 0.3653 0.5793 +vn -0.9917 0.1282 0.0000 +vn -0.8218 -0.0466 -0.5679 +vn -0.3379 -0.2035 -0.9189 +vn 0.2750 -0.2828 -0.9189 +vn 0.7829 -0.2540 -0.5679 +vn 0.9917 -0.1282 -0.0000 +vn 0.8218 0.0466 0.5679 +vn 0.3379 0.2035 0.9189 +vn -0.2750 0.2828 0.9189 +vn -0.7829 0.2540 0.5679 +vn -0.9955 -0.0946 0.0000 +vn -0.7871 -0.2686 -0.5552 +vn -0.2781 -0.3400 -0.8984 +vn 0.3372 -0.2816 -0.8984 +vn 0.8236 -0.1156 -0.5552 +vn 0.9955 0.0946 -0.0000 +vn 0.7871 0.2686 0.5552 +vn 0.2781 0.3400 0.8984 +vn -0.3372 0.2816 0.8984 +vn -0.8236 0.1156 0.5552 +vn -0.9474 -0.3201 0.0000 +vn -0.6947 -0.4714 -0.5434 +vn -0.1766 -0.4426 -0.8792 +vn 0.4089 -0.2447 -0.8792 +vn 0.8382 0.0466 -0.5434 +vn 0.9474 0.3201 -0.0000 +vn 0.6947 0.4714 0.5434 +vn 0.1766 0.4426 0.8792 +vn -0.4089 0.2447 0.8792 +vn -0.8382 -0.0466 0.5434 +vn -0.8455 -0.5339 0.0000 +vn -0.5527 -0.6399 -0.5339 +vn -0.0488 -0.5014 -0.8638 +vn 0.4737 -0.1714 -0.8638 +vn 0.8153 0.2240 -0.5339 +vn 0.8455 0.5339 -0.0000 +vn 0.5527 0.6399 0.5339 +vn 0.0488 0.5014 0.8638 +vn -0.4737 0.1714 0.8638 +vn -0.8153 -0.2240 0.5339 +vn -0.6931 -0.7208 0.0000 +vn -0.3743 -0.7625 -0.5278 +vn 0.0875 -0.5129 -0.8540 +vn 0.5159 -0.0674 -0.8540 +vn 0.7472 0.4038 -0.5278 +vn 0.6931 0.7208 -0.0000 +vn 0.3743 0.7625 0.5278 +vn -0.0875 0.5129 0.8540 +vn -0.5159 0.0674 0.8540 +vn -0.7472 -0.4038 0.5278 +vn -0.4985 -0.8669 0.0000 +vn -0.1754 -0.8324 -0.5257 +vn 0.2147 -0.4799 -0.8507 +vn 0.5228 0.0559 -0.8507 +vn 0.6312 0.5703 -0.5257 +vn 0.4985 0.8669 -0.0000 +vn 0.1754 0.8324 0.5257 +vn -0.2147 0.4799 0.8507 +vn -0.5228 -0.0559 0.8507 +vn -0.6312 -0.5703 0.5257 +vn -0.2743 -0.9616 0.0000 +vn 0.0267 -0.8489 -0.5279 +vn 0.3175 -0.4119 -0.8541 +vn 0.4871 0.1824 -0.8541 +vn 0.4706 0.7070 -0.5279 +vn 0.2743 0.9616 -0.0000 +vn -0.0267 0.8489 0.5279 +vn -0.3175 0.4119 0.8541 +vn -0.4871 -0.1824 0.8541 +vn -0.4706 -0.7070 0.5279 +vn -0.0362 -0.9993 0.0000 +vn 0.2162 -0.8174 -0.5340 +vn 0.3860 -0.3232 -0.8640 +vn 0.4084 0.2944 -0.8640 +vn 0.2748 0.7996 -0.5340 +vn 0.0362 0.9993 -0.0000 +vn -0.2162 0.8174 0.5340 +vn -0.3860 0.3232 0.8640 +vn -0.4084 -0.2944 0.8640 +vn -0.2748 -0.7996 0.5340 +vn 0.1997 -0.9798 0.0000 +vn 0.3809 -0.7480 -0.5435 +vn 0.4165 -0.2305 -0.8794 +vn 0.2931 0.3751 -0.8794 +vn 0.0577 0.8374 -0.5435 +vn -0.1997 0.9798 -0.0000 +vn -0.3809 0.7480 0.5435 +vn -0.4165 0.2305 0.8794 +vn -0.2931 -0.3751 0.8794 +vn -0.0577 -0.8374 0.5435 +vn 0.4188 -0.9081 0.0000 +vn 0.5136 -0.6540 -0.5554 +vn 0.4122 -0.1502 -0.8986 +vn 0.1533 0.4110 -0.8986 +vn -0.1641 0.8152 -0.5554 +vn -0.4188 0.9081 -0.0000 +vn -0.5136 0.6540 0.5554 +vn -0.4122 0.1502 0.8986 +vn -0.1533 -0.4110 0.8986 +vn 0.1641 -0.8152 0.5554 +vn 0.6094 -0.7929 0.0000 +vn 0.6126 -0.5495 -0.5681 +vn 0.3819 -0.0962 -0.9192 +vn 0.0052 0.3938 -0.9192 +vn -0.3734 0.7334 -0.5681 +vn -0.6094 0.7929 -0.0000 +vn -0.6126 0.5495 0.5681 +vn -0.3819 0.0962 0.9192 +vn -0.0052 -0.3938 0.9192 +vn 0.3734 -0.7334 0.5681 +vn 0.7637 -0.6455 0.0000 +vn 0.6815 -0.4470 -0.5795 +vn 0.3389 -0.0777 -0.9376 +vn -0.1331 0.3212 -0.9376 +vn -0.5543 0.5975 -0.5795 +vn -0.7637 0.6455 -0.0000 +vn -0.6815 0.4470 0.5795 +vn -0.3389 0.0777 0.9376 +vn 0.1331 -0.3212 0.9376 +vn 0.5543 -0.5975 0.5795 +vn 0.8781 -0.4785 0.0000 +vn 0.7272 -0.3562 -0.5867 +vn 0.2986 -0.0979 -0.9494 +vn -0.2441 0.1978 -0.9494 +vn -0.6936 0.4180 -0.5867 +vn -0.8781 0.4785 -0.0000 +vn -0.7272 0.3562 0.5867 +vn -0.2986 0.0979 0.9494 +vn 0.2441 -0.1978 0.9494 +vn 0.6936 -0.4180 0.5867 +vn 0.9525 -0.3044 0.0000 +vn 0.7588 -0.2831 -0.5865 +vn 0.2753 -0.1537 -0.9490 +vn -0.3134 0.0344 -0.9490 +vn -0.7824 0.2094 -0.5865 +vn -0.9525 0.3044 -0.0000 +vn -0.7588 0.2831 0.5865 +vn -0.2753 0.1537 0.9490 +vn 0.3134 -0.0344 0.9490 +vn 0.7824 -0.2094 0.5865 +vn 0.9908 -0.1354 0.0000 +vn 0.7852 -0.2296 -0.5752 +vn 0.2796 -0.2360 -0.9306 +vn -0.3327 -0.1524 -0.9306 +vn -0.8180 -0.0105 -0.5752 +vn -0.9908 0.1354 -0.0000 +vn -0.7852 0.2296 0.5752 +vn -0.2796 0.2360 0.9306 +vn 0.3327 0.1524 0.9306 +vn 0.8180 0.0105 0.5752 +vn 0.9999 0.0166 -0.0000 +vn 0.8124 -0.1950 -0.5496 +vn 0.3146 -0.3321 -0.8892 +vn -0.3034 -0.3423 -0.8892 +vn -0.8054 -0.2218 -0.5496 +vn -0.9999 -0.0166 -0.0000 +vn -0.8124 0.1950 0.5496 +vn -0.3146 0.3321 0.8892 +vn 0.3034 0.3423 0.8892 +vn 0.8054 0.2218 0.5496 +vn 0.9903 0.1390 -0.0000 +vn 0.8421 -0.1795 -0.5085 +vn 0.3723 -0.4294 -0.8228 +vn -0.2397 -0.5153 -0.8228 +vn -0.7602 -0.4044 -0.5085 +vn -0.9903 -0.1390 -0.0000 +vn -0.8421 0.1795 0.5085 +vn -0.3723 0.4294 0.8228 +vn 0.2397 0.5153 0.8228 +vn 0.7602 0.4044 0.5085 +vn 0.9762 0.2170 -0.0000 +vn 0.8707 -0.1885 -0.4543 +vn 0.4326 -0.5220 -0.7351 +vn -0.1707 -0.6561 -0.7351 +vn -0.7088 -0.5396 -0.4543 +vn -0.9762 -0.2170 -0.0000 +vn -0.8707 0.1885 0.4543 +vn -0.4326 0.5220 0.7351 +vn 0.1707 0.6561 0.7351 +vn 0.7088 0.5396 0.4543 +vn 0.9732 0.2301 -0.0000 +vn 0.8875 -0.2374 -0.3950 +vn 0.4628 -0.6142 -0.6392 +vn -0.1386 -0.7565 -0.6392 +vn -0.6871 -0.6098 -0.3950 +vn -0.9732 -0.2301 -0.0000 +vn -0.8875 0.2374 0.3950 +vn -0.4628 0.6142 0.6392 +vn 0.1386 0.7565 0.6392 +vn 0.6871 0.6098 0.3950 +vn 0.9879 0.1551 -0.0000 +vn 0.8729 -0.3441 -0.3457 +vn 0.4246 -0.7119 -0.5594 +vn -0.1860 -0.8078 -0.5594 +vn -0.7255 -0.5951 -0.3457 +vn -0.9879 -0.1551 -0.0000 +vn -0.8729 0.3441 0.3457 +vn -0.4246 0.7119 0.5594 +vn 0.1860 0.8078 0.5594 +vn 0.7255 0.5951 0.3457 +vn 1.0000 -0.0012 0.0000 +vn 0.8084 -0.4901 -0.3260 +vn 0.3080 -0.7917 -0.5276 +vn -0.3100 -0.7909 -0.5276 +vn -0.8096 -0.4881 -0.3260 +vn -1.0000 0.0012 -0.0000 +vn -0.8084 0.4901 0.3260 +vn -0.3080 0.7917 0.5276 +vn 0.3100 0.7909 0.5276 +vn 0.8096 0.4881 0.3260 +vn 0.9876 -0.1568 0.0000 +vn 0.7245 -0.5960 -0.3463 +vn 0.1847 -0.8075 -0.5603 +vn -0.4257 -0.7105 -0.5603 +vn -0.8735 -0.3422 -0.3463 +vn -0.9876 0.1568 -0.0000 +vn -0.7245 0.5960 0.3463 +vn -0.1847 0.8075 0.5603 +vn 0.4257 0.7105 0.5603 +vn 0.8735 0.3422 0.3463 +vn 0.9731 -0.2305 0.0000 +vn 0.6871 -0.6093 -0.3959 +vn 0.1386 -0.7553 -0.6405 +vn -0.4628 -0.6129 -0.6405 +vn -0.8874 -0.2363 -0.3959 +vn -0.9731 0.2305 -0.0000 +vn -0.6871 0.6093 0.3959 +vn -0.1386 0.7553 0.6405 +vn 0.4628 0.6129 0.6405 +vn 0.8874 0.2363 0.3959 +vn 0.9763 -0.2163 0.0000 +vn 0.7094 -0.5381 -0.4552 +vn 0.1716 -0.6543 -0.7365 +vn -0.4319 -0.5207 -0.7365 +vn -0.8703 -0.1881 -0.4552 +vn -0.9763 0.2163 -0.0000 +vn -0.7094 0.5381 0.4552 +vn -0.1716 0.6543 0.7365 +vn 0.4319 0.5207 0.7365 +vn 0.8703 0.1881 0.4552 +vn 0.9905 -0.1376 0.0000 +vn 0.7609 -0.4021 -0.5092 +vn 0.2407 -0.5130 -0.8239 +vn -0.3714 -0.4280 -0.8239 +vn -0.8417 -0.1795 -0.5092 +vn -0.9905 0.1376 -0.0000 +vn -0.7609 0.4021 0.5092 +vn -0.2407 0.5130 0.8239 +vn 0.3714 0.4280 0.8239 +vn 0.8417 0.1795 0.5092 +vn 0.9999 -0.0146 0.0000 +vn 0.8059 -0.2190 -0.5501 +vn 0.3041 -0.3397 -0.8900 +vn -0.3139 -0.3307 -0.8900 +vn -0.8120 -0.1954 -0.5501 +vn -0.9999 0.0146 -0.0000 +vn -0.8059 0.2190 0.5501 +vn -0.3041 0.3397 0.8900 +vn 0.3139 0.3307 0.8900 +vn 0.8120 0.1954 0.5501 +vn 0.9905 0.1377 -0.0000 +vn 0.8178 -0.0074 -0.5754 +vn 0.3328 -0.1496 -0.9311 +vn -0.2794 -0.2347 -0.9311 +vn -0.7848 -0.2302 -0.5754 +vn -0.9905 -0.1377 -0.0000 +vn -0.8178 0.0074 0.5754 +vn -0.3328 0.1496 0.9311 +vn 0.2794 0.2347 0.9311 +vn 0.7848 0.2302 0.5754 +vn 0.9517 0.3069 -0.0000 +vn 0.7815 0.2125 -0.5866 +vn 0.3128 0.0370 -0.9491 +vn -0.2755 -0.1527 -0.9491 +vn -0.7585 -0.2840 -0.5866 +vn -0.9517 -0.3069 -0.0000 +vn -0.7815 -0.2125 0.5866 +vn -0.3128 -0.0370 0.9491 +vn 0.2755 0.1527 0.9491 +vn 0.7585 0.2840 0.5866 +vn 0.8767 0.4810 -0.0000 +vn 0.6919 0.4208 -0.5867 +vn 0.2428 0.1999 -0.9493 +vn -0.2991 -0.0973 -0.9493 +vn -0.7267 -0.3574 -0.5867 +vn -0.8767 -0.4810 -0.0000 +vn -0.6919 -0.4208 0.5867 +vn -0.2428 -0.1999 0.9493 +vn 0.2991 0.0973 0.9493 +vn 0.7267 0.3574 0.5867 +vn 0.7618 0.6478 -0.0000 +vn 0.5519 0.5998 -0.5793 +vn 0.1313 0.3227 -0.9374 +vn -0.3396 -0.0777 -0.9374 +vn -0.6807 -0.4484 -0.5793 +vn -0.7618 -0.6478 -0.0000 +vn -0.5519 -0.5998 0.5793 +vn -0.1313 -0.3227 0.9374 +vn 0.3396 0.0777 0.9374 +vn 0.6807 0.4484 0.5793 +vn 0.6069 0.7948 -0.0000 +vn 0.3706 0.7349 -0.5679 +vn -0.0073 0.3944 -0.9189 +vn -0.3824 -0.0968 -0.9189 +vn -0.6114 -0.5510 -0.5679 +vn -0.6069 -0.7948 -0.0000 +vn -0.3706 -0.7349 0.5679 +vn 0.0073 -0.3944 0.9189 +vn 0.3824 0.0968 0.9189 +vn 0.6114 0.5510 0.5679 +vn 0.4159 0.9094 -0.0000 +vn 0.1610 0.8160 -0.5552 +vn -0.1554 0.4109 -0.8984 +vn -0.4124 -0.1512 -0.8984 +vn -0.5119 -0.6555 -0.5552 +vn -0.4159 -0.9094 -0.0000 +vn -0.1610 -0.8160 0.5552 +vn 0.1554 -0.4109 0.8984 +vn 0.4124 0.1512 0.8984 +vn 0.5119 0.6555 0.5552 +vn 0.1965 0.9805 -0.0000 +vn -0.0609 0.8373 -0.5434 +vn -0.2950 0.3743 -0.8792 +vn -0.4164 -0.2317 -0.8792 +vn -0.3787 -0.7492 -0.5434 +vn -0.1965 -0.9805 -0.0000 +vn 0.0609 -0.8373 0.5434 +vn 0.2950 -0.3743 0.8792 +vn 0.4164 0.2317 0.8792 +vn 0.3787 0.7492 0.5434 +vn -0.0396 0.9992 0.0000 +vn -0.2778 0.7986 -0.5339 +vn -0.4098 0.2930 -0.8638 +vn -0.3853 -0.3245 -0.8638 +vn -0.2137 -0.8181 -0.5339 +vn 0.0396 -0.9992 -0.0000 +vn 0.2778 -0.7986 0.5339 +vn 0.4098 -0.2930 0.8638 +vn 0.3853 0.3245 0.8638 +vn 0.2137 0.8181 0.5339 +vn -0.2777 0.9607 0.0000 +vn -0.4732 0.7054 -0.5278 +vn -0.4879 0.1806 -0.8540 +vn -0.3163 -0.4131 -0.8540 +vn -0.0239 -0.8490 -0.5278 +vn 0.2777 -0.9607 -0.0000 +vn 0.4732 -0.7054 0.5278 +vn 0.4879 -0.1806 0.8540 +vn 0.3163 0.4131 0.8540 +vn 0.0239 0.8490 0.5278 +vn -0.5015 0.8651 0.0000 +vn -0.6332 0.5681 -0.5257 +vn -0.5229 0.0540 -0.8507 +vn -0.2130 -0.4807 -0.8507 +vn 0.1783 -0.8318 -0.5257 +vn 0.5015 -0.8651 -0.0000 +vn 0.6332 -0.5681 0.5257 +vn 0.5229 -0.0540 0.8507 +vn 0.2130 0.4807 0.8507 +vn -0.1783 0.8318 0.5257 +vn -0.6956 0.7184 0.0000 +vn -0.7485 0.4013 -0.5279 +vn -0.5155 -0.0690 -0.8541 +vn -0.0856 -0.5130 -0.8541 +vn 0.3770 -0.7611 -0.5279 +vn 0.6956 -0.7184 -0.0000 +vn 0.7485 -0.4013 0.5279 +vn 0.5155 0.0690 0.8541 +vn 0.0856 0.5130 0.8541 +vn -0.3770 0.7611 0.5279 +vn -0.8474 0.5310 0.0000 +vn -0.8160 0.2214 -0.5340 +vn -0.4729 -0.1727 -0.8640 +vn 0.0508 -0.5009 -0.8640 +vn 0.5551 -0.6378 -0.5340 +vn 0.8474 -0.5310 -0.0000 +vn 0.8160 -0.2214 0.5340 +vn 0.4729 0.1727 0.8640 +vn -0.0508 0.5009 0.8640 +vn -0.5551 0.6378 0.5340 +vn -0.9484 0.3169 0.0000 +vn -0.8382 0.0441 -0.5435 +vn -0.4079 -0.2455 -0.8794 +vn 0.1783 -0.4414 -0.8794 +vn 0.6964 -0.4687 -0.5435 +vn 0.9484 -0.3169 -0.0000 +vn 0.8382 -0.0441 0.5435 +vn 0.4079 0.2455 0.8794 +vn -0.1783 0.4414 0.8794 +vn -0.6964 0.4687 0.5435 +vn -0.9958 0.0913 0.0000 +vn -0.8232 -0.1177 -0.5554 +vn -0.3362 -0.2818 -0.8986 +vn 0.2793 -0.3383 -0.8986 +vn 0.7881 -0.2655 -0.5554 +vn 0.9958 -0.0913 -0.0000 +vn 0.8232 0.1177 0.5554 +vn 0.3362 0.2818 0.8986 +vn -0.2793 0.3383 0.8986 +vn -0.7881 0.2655 0.5554 +vn -0.9913 -0.1313 0.0000 +vn -0.7822 -0.2558 -0.5681 +vn -0.2743 -0.2826 -0.9192 +vn 0.3384 -0.2014 -0.9192 +vn 0.8218 -0.0433 -0.5681 +vn 0.9913 0.1313 -0.0000 +vn 0.7822 0.2558 0.5681 +vn 0.2743 0.2826 0.9192 +vn -0.3384 0.2014 0.9192 +vn -0.8218 0.0433 0.5681 +vn -0.9409 -0.3387 0.0000 +vn -0.7278 -0.3667 -0.5795 +vn -0.2368 -0.2547 -0.9376 +vn 0.3448 -0.0454 -0.9376 +vn 0.7946 0.1813 -0.5795 +vn 0.9409 0.3387 -0.0000 +vn 0.7278 0.3667 0.5795 +vn 0.2368 0.2547 0.9376 +vn -0.3448 0.0454 0.9376 +vn -0.7946 -0.1813 0.5795 +vn -0.8534 -0.5212 0.0000 +vn -0.6721 -0.4517 -0.5867 +vn -0.2341 -0.2096 -0.9494 +vn 0.2934 0.1125 -0.9494 +vn 0.7088 0.3916 -0.5867 +vn 0.8534 0.5212 -0.0000 +vn 0.6721 0.4517 0.5867 +vn 0.2341 0.2096 0.9494 +vn -0.2934 -0.1125 0.9494 +vn -0.7088 -0.3916 0.5867 +vn -0.7399 -0.6727 0.0000 +vn -0.6246 -0.5156 -0.5865 +vn -0.2708 -0.1616 -0.9490 +vn 0.1865 0.2542 -0.9490 +vn 0.5726 0.5729 -0.5865 +vn 0.7399 0.6727 -0.0000 +vn 0.6246 0.5156 0.5865 +vn 0.2708 0.1616 0.9490 +vn -0.1865 -0.2542 0.9490 +vn -0.5726 -0.5729 0.5865 +vn -0.6126 -0.7904 0.0000 +vn -0.5914 -0.5652 -0.5752 +vn -0.3442 -0.1241 -0.9306 +vn 0.0344 0.3643 -0.9306 +vn 0.3999 0.7136 -0.5752 +vn 0.6126 0.7904 -0.0000 +vn 0.5914 0.5652 0.5752 +vn 0.3442 0.1241 0.9306 +vn -0.0344 -0.3643 0.9306 +vn -0.3999 -0.7136 0.5752 +vn -0.4856 -0.8742 0.0000 +vn -0.5751 -0.6060 -0.5496 +vn -0.4449 -0.1064 -0.8892 +vn -0.1448 0.4339 -0.8892 +vn 0.2106 0.8085 -0.5496 +vn 0.4856 0.8742 -0.0000 +vn 0.5751 0.6060 0.5496 +vn 0.4449 0.1064 0.8892 +vn 0.1448 -0.4339 0.8892 +vn -0.2106 -0.8085 0.5496 +vn -0.3747 -0.9271 0.0000 +vn -0.5765 -0.6396 -0.5085 +vn -0.5580 -0.1078 -0.8228 +vn -0.3264 0.4652 -0.8228 +vn 0.0299 0.8605 -0.5085 +vn 0.3747 0.9271 -0.0000 +vn 0.5765 0.6396 0.5085 +vn 0.5580 0.1078 0.8228 +vn 0.3264 -0.4652 0.8228 +vn -0.0299 -0.8605 0.5085 +vn -0.3002 -0.9539 0.0000 +vn -0.5986 -0.6598 -0.4543 +vn -0.6683 -0.1137 -0.7351 +vn -0.4828 0.4759 -0.7351 +vn -0.1129 0.8836 -0.4543 +vn 0.3002 0.9539 -0.0000 +vn 0.5986 0.6598 0.4543 +vn 0.6683 0.1137 0.7351 +vn 0.4828 -0.4759 0.7351 +vn 0.1129 -0.8836 0.4543 +vn -0.2873 -0.9578 0.0000 +vn -0.6493 -0.6499 -0.3950 +vn -0.7633 -0.0937 -0.6392 +vn -0.5858 0.4983 -0.6392 +vn -0.1845 0.9000 -0.3950 +vn 0.2873 0.9578 -0.0000 +vn 0.6493 0.6499 0.3950 +vn 0.7633 0.0937 0.6392 +vn 0.5858 -0.4983 0.6392 +vn 0.1845 -0.9000 0.3950 +vn -0.3597 -0.9331 0.0000 +vn -0.7345 -0.5839 -0.3457 +vn -0.8288 -0.0117 -0.5594 +vn -0.6065 0.5650 -0.5594 +vn -0.1526 0.9258 -0.3457 +vn 0.3597 0.9331 -0.0000 +vn 0.7345 0.5839 0.3457 +vn 0.8288 0.0117 0.5594 +vn 0.6065 -0.5650 0.5594 +vn 0.1526 -0.9258 0.3457 +vn -0.5011 -0.8654 0.0000 +vn -0.8286 -0.4551 -0.3260 +vn -0.8397 0.1291 -0.5276 +vn -0.5300 0.6639 -0.5276 +vn -0.0179 0.9452 -0.3260 +vn 0.5011 0.8654 -0.0000 +vn 0.8286 0.4551 0.3260 +vn 0.8397 -0.1291 0.5276 +vn 0.5300 -0.6639 0.5276 +vn 0.0179 -0.9452 0.3260 +vn -0.6296 -0.7769 0.0000 +vn -0.8784 -0.3295 -0.3463 +vn -0.7916 0.2438 -0.5603 +vn -0.4025 0.7239 -0.5603 +vn 0.1404 0.9276 -0.3463 +vn 0.6296 0.7769 -0.0000 +vn 0.8784 0.3295 0.3463 +vn 0.7916 -0.2438 0.5603 +vn 0.4025 -0.7239 0.5603 +vn -0.1404 -0.9276 0.3463 +vn -0.6862 -0.7274 0.0000 +vn -0.8712 -0.2904 -0.3959 +vn -0.7235 0.2576 -0.6405 +vn -0.2994 0.7072 -0.6405 +vn 0.2390 0.8867 -0.3959 +vn 0.6862 0.7274 -0.0000 +vn 0.8712 0.2904 0.3959 +vn 0.7235 -0.2576 0.6405 +vn 0.2994 -0.7072 0.6405 +vn -0.2390 -0.8867 0.3959 +vn -0.6755 -0.7374 0.0000 +vn -0.8207 -0.3453 -0.4552 +vn -0.6525 0.1786 -0.7365 +vn -0.2350 0.6343 -0.7365 +vn 0.2722 0.8478 -0.4552 +vn 0.6755 0.7374 -0.0000 +vn 0.8207 0.3453 0.4552 +vn 0.6525 -0.1786 0.7365 +vn 0.2350 -0.6343 0.7365 +vn -0.2722 -0.8478 0.4552 +vn -0.6144 -0.7890 0.0000 +vn -0.7287 -0.4579 -0.5092 +vn -0.5647 0.0480 -0.8239 +vn -0.1850 0.5357 -0.8239 +vn 0.2654 0.8187 -0.5092 +vn 0.6144 0.7890 -0.0000 +vn 0.7287 0.4579 0.5092 +vn 0.5647 -0.0480 0.8239 +vn 0.1850 -0.5357 0.8239 +vn -0.2654 -0.8187 0.5092 +vn -0.5126 -0.8586 0.0000 +vn -0.5926 -0.5885 -0.5501 +vn -0.4462 -0.0935 -0.8900 +vn -0.1294 0.4372 -0.8900 +vn 0.2368 0.8009 -0.5501 +vn 0.5126 0.8586 -0.0000 +vn 0.5926 0.5885 0.5501 +vn 0.4462 0.0935 0.8900 +vn 0.1294 -0.4372 0.8900 +vn -0.2368 -0.8009 0.5501 +vn -0.3760 -0.9266 0.0000 +vn -0.4153 -0.7046 -0.5754 +vn -0.2960 -0.2134 -0.9311 +vn -0.0636 0.3593 -0.9311 +vn 0.1931 0.7947 -0.5754 +vn 0.3760 0.9266 -0.0000 +vn 0.4153 0.7046 0.5754 +vn 0.2960 0.2134 0.9311 +vn 0.0636 -0.3593 0.9311 +vn -0.1931 -0.7947 0.5754 +vn -0.2101 -0.9777 0.0000 +vn -0.2067 -0.7831 -0.5866 +vn -0.1244 -0.2893 -0.9491 +vn 0.0055 0.3149 -0.9491 +vn 0.1333 0.7989 -0.5866 +vn 0.2101 0.9777 -0.0000 +vn 0.2067 0.7831 0.5866 +vn 0.1244 0.2893 0.9491 +vn -0.0055 -0.3149 0.9491 +vn -0.1333 -0.7989 0.5866 +vn -0.0218 -0.9998 0.0000 +vn 0.0185 -0.8096 -0.5867 +vn 0.0517 -0.3102 -0.9493 +vn 0.0652 0.3077 -0.9493 +vn 0.0538 0.8080 -0.5867 +vn 0.0218 0.9998 -0.0000 +vn -0.0185 0.8096 0.5867 +vn -0.0517 0.3102 0.9493 +vn -0.0652 -0.3077 0.9493 +vn -0.0538 -0.8080 0.5867 +vn 0.1801 -0.9836 0.0000 +vn 0.2435 -0.7779 -0.5793 +vn 0.2138 -0.2750 -0.9374 +vn 0.1025 0.3329 -0.9374 +vn -0.0480 0.8137 -0.5793 +vn -0.1801 0.9836 -0.0000 +vn -0.2435 0.7779 0.5793 +vn -0.2138 0.2750 0.9374 +vn -0.1025 -0.3329 0.9374 +vn 0.0480 -0.8137 0.5793 +vn 0.3848 -0.9230 0.0000 +vn 0.4512 -0.6884 -0.5679 +vn 0.3452 -0.1909 -0.9189 +vn 0.1074 0.3796 -0.9189 +vn -0.1715 0.8050 -0.5679 +vn -0.3848 0.9230 -0.0000 +vn -0.4512 0.6884 0.5679 +vn -0.3452 0.1909 0.9189 +vn -0.1074 -0.3796 0.9189 +vn 0.1715 -0.8050 0.5679 +vn 0.5797 -0.8149 0.0000 +vn 0.6262 -0.5474 -0.5552 +vn 0.4335 -0.0708 -0.8984 +vn 0.0753 0.4328 -0.8984 +vn -0.3117 0.7711 -0.5552 +vn -0.5797 0.8149 -0.0000 +vn -0.6262 0.5474 0.5552 +vn -0.4335 0.0708 0.8984 +vn -0.0753 -0.4328 0.8984 +vn 0.3117 -0.7711 0.5552 +vn 0.7509 -0.6604 0.0000 +vn 0.7556 -0.3659 -0.5434 +vn 0.4716 0.0683 -0.8792 +vn 0.0075 0.4765 -0.8792 +vn -0.4595 0.7026 -0.5434 +vn -0.7509 0.6604 -0.0000 +vn -0.7556 0.3659 0.5434 +vn -0.4716 -0.0683 0.8792 +vn -0.0075 -0.4765 0.8792 +vn 0.4595 -0.7026 0.5434 +vn 0.8852 -0.4653 0.0000 +vn 0.8305 -0.1587 -0.5339 +vn 0.4587 0.2084 -0.8638 +vn -0.0884 0.4960 -0.8638 +vn -0.6017 0.5941 -0.5339 +vn -0.8852 0.4653 -0.0000 +vn -0.8305 0.1587 0.5339 +vn -0.4587 -0.2084 0.8638 +vn 0.0884 -0.4960 0.8638 +vn 0.6017 -0.5941 0.5339 +vn 0.9708 -0.2399 0.0000 +vn 0.8474 0.0571 -0.5278 +vn 0.4004 0.3322 -0.8540 +vn -0.1996 0.4805 -0.8540 +vn -0.7234 0.4452 -0.5278 +vn -0.9708 0.2399 -0.0000 +vn -0.8474 -0.0571 0.5278 +vn -0.4004 -0.3322 0.8540 +vn 0.1996 -0.4805 0.8540 +vn 0.7234 -0.4452 0.5278 +f 1//1 2//2 12//12 11//11 +f 2//2 3//3 13//13 12//12 +f 3//3 4//4 14//14 13//13 +f 4//4 5//5 15//15 14//14 +f 5//5 6//6 16//16 15//15 +f 6//6 7//7 17//17 16//16 +f 7//7 8//8 18//18 17//17 +f 8//8 9//9 19//19 18//18 +f 9//9 10//10 20//20 19//19 +f 10//10 1//1 11//11 20//20 +f 11//11 12//12 22//22 21//21 +f 12//12 13//13 23//23 22//22 +f 13//13 14//14 24//24 23//23 +f 14//14 15//15 25//25 24//24 +f 15//15 16//16 26//26 25//25 +f 16//16 17//17 27//27 26//26 +f 17//17 18//18 28//28 27//27 +f 18//18 19//19 29//29 28//28 +f 19//19 20//20 30//30 29//29 +f 20//20 11//11 21//21 30//30 +f 21//21 22//22 32//32 31//31 +f 22//22 23//23 33//33 32//32 +f 23//23 24//24 34//34 33//33 +f 24//24 25//25 35//35 34//34 +f 25//25 26//26 36//36 35//35 +f 26//26 27//27 37//37 36//36 +f 27//27 28//28 38//38 37//37 +f 28//28 29//29 39//39 38//38 +f 29//29 30//30 40//40 39//39 +f 30//30 21//21 31//31 40//40 +f 31//31 32//32 42//42 41//41 +f 32//32 33//33 43//43 42//42 +f 33//33 34//34 44//44 43//43 +f 34//34 35//35 45//45 44//44 +f 35//35 36//36 46//46 45//45 +f 36//36 37//37 47//47 46//46 +f 37//37 38//38 48//48 47//47 +f 38//38 39//39 49//49 48//48 +f 39//39 40//40 50//50 49//49 +f 40//40 31//31 41//41 50//50 +f 41//41 42//42 52//52 51//51 +f 42//42 43//43 53//53 52//52 +f 43//43 44//44 54//54 53//53 +f 44//44 45//45 55//55 54//54 +f 45//45 46//46 56//56 55//55 +f 46//46 47//47 57//57 56//56 +f 47//47 48//48 58//58 57//57 +f 48//48 49//49 59//59 58//58 +f 49//49 50//50 60//60 59//59 +f 50//50 41//41 51//51 60//60 +f 51//51 52//52 62//62 61//61 +f 52//52 53//53 63//63 62//62 +f 53//53 54//54 64//64 63//63 +f 54//54 55//55 65//65 64//64 +f 55//55 56//56 66//66 65//65 +f 56//56 57//57 67//67 66//66 +f 57//57 58//58 68//68 67//67 +f 58//58 59//59 69//69 68//68 +f 59//59 60//60 70//70 69//69 +f 60//60 51//51 61//61 70//70 +f 61//61 62//62 72//72 71//71 +f 62//62 63//63 73//73 72//72 +f 63//63 64//64 74//74 73//73 +f 64//64 65//65 75//75 74//74 +f 65//65 66//66 76//76 75//75 +f 66//66 67//67 77//77 76//76 +f 67//67 68//68 78//78 77//77 +f 68//68 69//69 79//79 78//78 +f 69//69 70//70 80//80 79//79 +f 70//70 61//61 71//71 80//80 +f 71//71 72//72 82//82 81//81 +f 72//72 73//73 83//83 82//82 +f 73//73 74//74 84//84 83//83 +f 74//74 75//75 85//85 84//84 +f 75//75 76//76 86//86 85//85 +f 76//76 77//77 87//87 86//86 +f 77//77 78//78 88//88 87//87 +f 78//78 79//79 89//89 88//88 +f 79//79 80//80 90//90 89//89 +f 80//80 71//71 81//81 90//90 +f 81//81 82//82 92//92 91//91 +f 82//82 83//83 93//93 92//92 +f 83//83 84//84 94//94 93//93 +f 84//84 85//85 95//95 94//94 +f 85//85 86//86 96//96 95//95 +f 86//86 87//87 97//97 96//96 +f 87//87 88//88 98//98 97//97 +f 88//88 89//89 99//99 98//98 +f 89//89 90//90 100//100 99//99 +f 90//90 81//81 91//91 100//100 +f 91//91 92//92 102//102 101//101 +f 92//92 93//93 103//103 102//102 +f 93//93 94//94 104//104 103//103 +f 94//94 95//95 105//105 104//104 +f 95//95 96//96 106//106 105//105 +f 96//96 97//97 107//107 106//106 +f 97//97 98//98 108//108 107//107 +f 98//98 99//99 109//109 108//108 +f 99//99 100//100 110//110 109//109 +f 100//100 91//91 101//101 110//110 +f 101//101 102//102 112//112 111//111 +f 102//102 103//103 113//113 112//112 +f 103//103 104//104 114//114 113//113 +f 104//104 105//105 115//115 114//114 +f 105//105 106//106 116//116 115//115 +f 106//106 107//107 117//117 116//116 +f 107//107 108//108 118//118 117//117 +f 108//108 109//109 119//119 118//118 +f 109//109 110//110 120//120 119//119 +f 110//110 101//101 111//111 120//120 +f 111//111 112//112 122//122 121//121 +f 112//112 113//113 123//123 122//122 +f 113//113 114//114 124//124 123//123 +f 114//114 115//115 125//125 124//124 +f 115//115 116//116 126//126 125//125 +f 116//116 117//117 127//127 126//126 +f 117//117 118//118 128//128 127//127 +f 118//118 119//119 129//129 128//128 +f 119//119 120//120 130//130 129//129 +f 120//120 111//111 121//121 130//130 +f 121//121 122//122 132//132 131//131 +f 122//122 123//123 133//133 132//132 +f 123//123 124//124 134//134 133//133 +f 124//124 125//125 135//135 134//134 +f 125//125 126//126 136//136 135//135 +f 126//126 127//127 137//137 136//136 +f 127//127 128//128 138//138 137//137 +f 128//128 129//129 139//139 138//138 +f 129//129 130//130 140//140 139//139 +f 130//130 121//121 131//131 140//140 +f 131//131 132//132 142//142 141//141 +f 132//132 133//133 143//143 142//142 +f 133//133 134//134 144//144 143//143 +f 134//134 135//135 145//145 144//144 +f 135//135 136//136 146//146 145//145 +f 136//136 137//137 147//147 146//146 +f 137//137 138//138 148//148 147//147 +f 138//138 139//139 149//149 148//148 +f 139//139 140//140 150//150 149//149 +f 140//140 131//131 141//141 150//150 +f 141//141 142//142 152//152 151//151 +f 142//142 143//143 153//153 152//152 +f 143//143 144//144 154//154 153//153 +f 144//144 145//145 155//155 154//154 +f 145//145 146//146 156//156 155//155 +f 146//146 147//147 157//157 156//156 +f 147//147 148//148 158//158 157//157 +f 148//148 149//149 159//159 158//158 +f 149//149 150//150 160//160 159//159 +f 150//150 141//141 151//151 160//160 +f 151//151 152//152 162//162 161//161 +f 152//152 153//153 163//163 162//162 +f 153//153 154//154 164//164 163//163 +f 154//154 155//155 165//165 164//164 +f 155//155 156//156 166//166 165//165 +f 156//156 157//157 167//167 166//166 +f 157//157 158//158 168//168 167//167 +f 158//158 159//159 169//169 168//168 +f 159//159 160//160 170//170 169//169 +f 160//160 151//151 161//161 170//170 +f 161//161 162//162 172//172 171//171 +f 162//162 163//163 173//173 172//172 +f 163//163 164//164 174//174 173//173 +f 164//164 165//165 175//175 174//174 +f 165//165 166//166 176//176 175//175 +f 166//166 167//167 177//177 176//176 +f 167//167 168//168 178//178 177//177 +f 168//168 169//169 179//179 178//178 +f 169//169 170//170 180//180 179//179 +f 170//170 161//161 171//171 180//180 +f 171//171 172//172 182//182 181//181 +f 172//172 173//173 183//183 182//182 +f 173//173 174//174 184//184 183//183 +f 174//174 175//175 185//185 184//184 +f 175//175 176//176 186//186 185//185 +f 176//176 177//177 187//187 186//186 +f 177//177 178//178 188//188 187//187 +f 178//178 179//179 189//189 188//188 +f 179//179 180//180 190//190 189//189 +f 180//180 171//171 181//181 190//190 +f 181//181 182//182 192//192 191//191 +f 182//182 183//183 193//193 192//192 +f 183//183 184//184 194//194 193//193 +f 184//184 185//185 195//195 194//194 +f 185//185 186//186 196//196 195//195 +f 186//186 187//187 197//197 196//196 +f 187//187 188//188 198//198 197//197 +f 188//188 189//189 199//199 198//198 +f 189//189 190//190 200//200 199//199 +f 190//190 181//181 191//191 200//200 +f 191//191 192//192 202//202 201//201 +f 192//192 193//193 203//203 202//202 +f 193//193 194//194 204//204 203//203 +f 194//194 195//195 205//205 204//204 +f 195//195 196//196 206//206 205//205 +f 196//196 197//197 207//207 206//206 +f 197//197 198//198 208//208 207//207 +f 198//198 199//199 209//209 208//208 +f 199//199 200//200 210//210 209//209 +f 200//200 191//191 201//201 210//210 +f 201//201 202//202 212//212 211//211 +f 202//202 203//203 213//213 212//212 +f 203//203 204//204 214//214 213//213 +f 204//204 205//205 215//215 214//214 +f 205//205 206//206 216//216 215//215 +f 206//206 207//207 217//217 216//216 +f 207//207 208//208 218//218 217//217 +f 208//208 209//209 219//219 218//218 +f 209//209 210//210 220//220 219//219 +f 210//210 201//201 211//211 220//220 +f 211//211 212//212 222//222 221//221 +f 212//212 213//213 223//223 222//222 +f 213//213 214//214 224//224 223//223 +f 214//214 215//215 225//225 224//224 +f 215//215 216//216 226//226 225//225 +f 216//216 217//217 227//227 226//226 +f 217//217 218//218 228//228 227//227 +f 218//218 219//219 229//229 228//228 +f 219//219 220//220 230//230 229//229 +f 220//220 211//211 221//221 230//230 +f 221//221 222//222 232//232 231//231 +f 222//222 223//223 233//233 232//232 +f 223//223 224//224 234//234 233//233 +f 224//224 225//225 235//235 234//234 +f 225//225 226//226 236//236 235//235 +f 226//226 227//227 237//237 236//236 +f 227//227 228//228 238//238 237//237 +f 228//228 229//229 239//239 238//238 +f 229//229 230//230 240//240 239//239 +f 230//230 221//221 231//231 240//240 +f 231//231 232//232 242//242 241//241 +f 232//232 233//233 243//243 242//242 +f 233//233 234//234 244//244 243//243 +f 234//234 235//235 245//245 244//244 +f 235//235 236//236 246//246 245//245 +f 236//236 237//237 247//247 246//246 +f 237//237 238//238 248//248 247//247 +f 238//238 239//239 249//249 248//248 +f 239//239 240//240 250//250 249//249 +f 240//240 231//231 241//241 250//250 +f 241//241 242//242 252//252 251//251 +f 242//242 243//243 253//253 252//252 +f 243//243 244//244 254//254 253//253 +f 244//244 245//245 255//255 254//254 +f 245//245 246//246 256//256 255//255 +f 246//246 247//247 257//257 256//256 +f 247//247 248//248 258//258 257//257 +f 248//248 249//249 259//259 258//258 +f 249//249 250//250 260//260 259//259 +f 250//250 241//241 251//251 260//260 +f 251//251 252//252 262//262 261//261 +f 252//252 253//253 263//263 262//262 +f 253//253 254//254 264//264 263//263 +f 254//254 255//255 265//265 264//264 +f 255//255 256//256 266//266 265//265 +f 256//256 257//257 267//267 266//266 +f 257//257 258//258 268//268 267//267 +f 258//258 259//259 269//269 268//268 +f 259//259 260//260 270//270 269//269 +f 260//260 251//251 261//261 270//270 +f 261//261 262//262 272//272 271//271 +f 262//262 263//263 273//273 272//272 +f 263//263 264//264 274//274 273//273 +f 264//264 265//265 275//275 274//274 +f 265//265 266//266 276//276 275//275 +f 266//266 267//267 277//277 276//276 +f 267//267 268//268 278//278 277//277 +f 268//268 269//269 279//279 278//278 +f 269//269 270//270 280//280 279//279 +f 270//270 261//261 271//271 280//280 +f 271//271 272//272 282//282 281//281 +f 272//272 273//273 283//283 282//282 +f 273//273 274//274 284//284 283//283 +f 274//274 275//275 285//285 284//284 +f 275//275 276//276 286//286 285//285 +f 276//276 277//277 287//287 286//286 +f 277//277 278//278 288//288 287//287 +f 278//278 279//279 289//289 288//288 +f 279//279 280//280 290//290 289//289 +f 280//280 271//271 281//281 290//290 +f 281//281 282//282 292//292 291//291 +f 282//282 283//283 293//293 292//292 +f 283//283 284//284 294//294 293//293 +f 284//284 285//285 295//295 294//294 +f 285//285 286//286 296//296 295//295 +f 286//286 287//287 297//297 296//296 +f 287//287 288//288 298//298 297//297 +f 288//288 289//289 299//299 298//298 +f 289//289 290//290 300//300 299//299 +f 290//290 281//281 291//291 300//300 +f 291//291 292//292 302//302 301//301 +f 292//292 293//293 303//303 302//302 +f 293//293 294//294 304//304 303//303 +f 294//294 295//295 305//305 304//304 +f 295//295 296//296 306//306 305//305 +f 296//296 297//297 307//307 306//306 +f 297//297 298//298 308//308 307//307 +f 298//298 299//299 309//309 308//308 +f 299//299 300//300 310//310 309//309 +f 300//300 291//291 301//301 310//310 +f 301//301 302//302 312//312 311//311 +f 302//302 303//303 313//313 312//312 +f 303//303 304//304 314//314 313//313 +f 304//304 305//305 315//315 314//314 +f 305//305 306//306 316//316 315//315 +f 306//306 307//307 317//317 316//316 +f 307//307 308//308 318//318 317//317 +f 308//308 309//309 319//319 318//318 +f 309//309 310//310 320//320 319//319 +f 310//310 301//301 311//311 320//320 +f 311//311 312//312 322//322 321//321 +f 312//312 313//313 323//323 322//322 +f 313//313 314//314 324//324 323//323 +f 314//314 315//315 325//325 324//324 +f 315//315 316//316 326//326 325//325 +f 316//316 317//317 327//327 326//326 +f 317//317 318//318 328//328 327//327 +f 318//318 319//319 329//329 328//328 +f 319//319 320//320 330//330 329//329 +f 320//320 311//311 321//321 330//330 +f 321//321 322//322 332//332 331//331 +f 322//322 323//323 333//333 332//332 +f 323//323 324//324 334//334 333//333 +f 324//324 325//325 335//335 334//334 +f 325//325 326//326 336//336 335//335 +f 326//326 327//327 337//337 336//336 +f 327//327 328//328 338//338 337//337 +f 328//328 329//329 339//339 338//338 +f 329//329 330//330 340//340 339//339 +f 330//330 321//321 331//331 340//340 +f 331//331 332//332 342//342 341//341 +f 332//332 333//333 343//343 342//342 +f 333//333 334//334 344//344 343//343 +f 334//334 335//335 345//345 344//344 +f 335//335 336//336 346//346 345//345 +f 336//336 337//337 347//347 346//346 +f 337//337 338//338 348//348 347//347 +f 338//338 339//339 349//349 348//348 +f 339//339 340//340 350//350 349//349 +f 340//340 331//331 341//341 350//350 +f 341//341 342//342 352//352 351//351 +f 342//342 343//343 353//353 352//352 +f 343//343 344//344 354//354 353//353 +f 344//344 345//345 355//355 354//354 +f 345//345 346//346 356//356 355//355 +f 346//346 347//347 357//357 356//356 +f 347//347 348//348 358//358 357//357 +f 348//348 349//349 359//359 358//358 +f 349//349 350//350 360//360 359//359 +f 350//350 341//341 351//351 360//360 +f 351//351 352//352 362//362 361//361 +f 352//352 353//353 363//363 362//362 +f 353//353 354//354 364//364 363//363 +f 354//354 355//355 365//365 364//364 +f 355//355 356//356 366//366 365//365 +f 356//356 357//357 367//367 366//366 +f 357//357 358//358 368//368 367//367 +f 358//358 359//359 369//369 368//368 +f 359//359 360//360 370//370 369//369 +f 360//360 351//351 361//361 370//370 +f 361//361 362//362 372//372 371//371 +f 362//362 363//363 373//373 372//372 +f 363//363 364//364 374//374 373//373 +f 364//364 365//365 375//375 374//374 +f 365//365 366//366 376//376 375//375 +f 366//366 367//367 377//377 376//376 +f 367//367 368//368 378//378 377//377 +f 368//368 369//369 379//379 378//378 +f 369//369 370//370 380//380 379//379 +f 370//370 361//361 371//371 380//380 +f 371//371 372//372 382//382 381//381 +f 372//372 373//373 383//383 382//382 +f 373//373 374//374 384//384 383//383 +f 374//374 375//375 385//385 384//384 +f 375//375 376//376 386//386 385//385 +f 376//376 377//377 387//387 386//386 +f 377//377 378//378 388//388 387//387 +f 378//378 379//379 389//389 388//388 +f 379//379 380//380 390//390 389//389 +f 380//380 371//371 381//381 390//390 +f 381//381 382//382 392//392 391//391 +f 382//382 383//383 393//393 392//392 +f 383//383 384//384 394//394 393//393 +f 384//384 385//385 395//395 394//394 +f 385//385 386//386 396//396 395//395 +f 386//386 387//387 397//397 396//396 +f 387//387 388//388 398//398 397//397 +f 388//388 389//389 399//399 398//398 +f 389//389 390//390 400//400 399//399 +f 390//390 381//381 391//391 400//400 +f 391//391 392//392 402//402 401//401 +f 392//392 393//393 403//403 402//402 +f 393//393 394//394 404//404 403//403 +f 394//394 395//395 405//405 404//404 +f 395//395 396//396 406//406 405//405 +f 396//396 397//397 407//407 406//406 +f 397//397 398//398 408//408 407//407 +f 398//398 399//399 409//409 408//408 +f 399//399 400//400 410//410 409//409 +f 400//400 391//391 401//401 410//410 +f 401//401 402//402 412//412 411//411 +f 402//402 403//403 413//413 412//412 +f 403//403 404//404 414//414 413//413 +f 404//404 405//405 415//415 414//414 +f 405//405 406//406 416//416 415//415 +f 406//406 407//407 417//417 416//416 +f 407//407 408//408 418//418 417//417 +f 408//408 409//409 419//419 418//418 +f 409//409 410//410 420//420 419//419 +f 410//410 401//401 411//411 420//420 +f 411//411 412//412 422//422 421//421 +f 412//412 413//413 423//423 422//422 +f 413//413 414//414 424//424 423//423 +f 414//414 415//415 425//425 424//424 +f 415//415 416//416 426//426 425//425 +f 416//416 417//417 427//427 426//426 +f 417//417 418//418 428//428 427//427 +f 418//418 419//419 429//429 428//428 +f 419//419 420//420 430//430 429//429 +f 420//420 411//411 421//421 430//430 +f 421//421 422//422 432//432 431//431 +f 422//422 423//423 433//433 432//432 +f 423//423 424//424 434//434 433//433 +f 424//424 425//425 435//435 434//434 +f 425//425 426//426 436//436 435//435 +f 426//426 427//427 437//437 436//436 +f 427//427 428//428 438//438 437//437 +f 428//428 429//429 439//439 438//438 +f 429//429 430//430 440//440 439//439 +f 430//430 421//421 431//431 440//440 +f 431//431 432//432 442//442 441//441 +f 432//432 433//433 443//443 442//442 +f 433//433 434//434 444//444 443//443 +f 434//434 435//435 445//445 444//444 +f 435//435 436//436 446//446 445//445 +f 436//436 437//437 447//447 446//446 +f 437//437 438//438 448//448 447//447 +f 438//438 439//439 449//449 448//448 +f 439//439 440//440 450//450 449//449 +f 440//440 431//431 441//441 450//450 +f 441//441 442//442 452//452 451//451 +f 442//442 443//443 453//453 452//452 +f 443//443 444//444 454//454 453//453 +f 444//444 445//445 455//455 454//454 +f 445//445 446//446 456//456 455//455 +f 446//446 447//447 457//457 456//456 +f 447//447 448//448 458//458 457//457 +f 448//448 449//449 459//459 458//458 +f 449//449 450//450 460//460 459//459 +f 450//450 441//441 451//451 460//460 +f 451//451 452//452 462//462 461//461 +f 452//452 453//453 463//463 462//462 +f 453//453 454//454 464//464 463//463 +f 454//454 455//455 465//465 464//464 +f 455//455 456//456 466//466 465//465 +f 456//456 457//457 467//467 466//466 +f 457//457 458//458 468//468 467//467 +f 458//458 459//459 469//469 468//468 +f 459//459 460//460 470//470 469//469 +f 460//460 451//451 461//461 470//470 +f 461//461 462//462 472//472 471//471 +f 462//462 463//463 473//473 472//472 +f 463//463 464//464 474//474 473//473 +f 464//464 465//465 475//475 474//474 +f 465//465 466//466 476//476 475//475 +f 466//466 467//467 477//477 476//476 +f 467//467 468//468 478//478 477//477 +f 468//468 469//469 479//479 478//478 +f 469//469 470//470 480//480 479//479 +f 470//470 461//461 471//471 480//480 +f 471//471 472//472 482//482 481//481 +f 472//472 473//473 483//483 482//482 +f 473//473 474//474 484//484 483//483 +f 474//474 475//475 485//485 484//484 +f 475//475 476//476 486//486 485//485 +f 476//476 477//477 487//487 486//486 +f 477//477 478//478 488//488 487//487 +f 478//478 479//479 489//489 488//488 +f 479//479 480//480 490//490 489//489 +f 480//480 471//471 481//481 490//490 +f 481//481 482//482 492//492 491//491 +f 482//482 483//483 493//493 492//492 +f 483//483 484//484 494//494 493//493 +f 484//484 485//485 495//495 494//494 +f 485//485 486//486 496//496 495//495 +f 486//486 487//487 497//497 496//496 +f 487//487 488//488 498//498 497//497 +f 488//488 489//489 499//499 498//498 +f 489//489 490//490 500//500 499//499 +f 490//490 481//481 491//491 500//500 +f 491//491 492//492 502//502 501//501 +f 492//492 493//493 503//503 502//502 +f 493//493 494//494 504//504 503//503 +f 494//494 495//495 505//505 504//504 +f 495//495 496//496 506//506 505//505 +f 496//496 497//497 507//507 506//506 +f 497//497 498//498 508//508 507//507 +f 498//498 499//499 509//509 508//508 +f 499//499 500//500 510//510 509//509 +f 500//500 491//491 501//501 510//510 +f 501//501 502//502 512//512 511//511 +f 502//502 503//503 513//513 512//512 +f 503//503 504//504 514//514 513//513 +f 504//504 505//505 515//515 514//514 +f 505//505 506//506 516//516 515//515 +f 506//506 507//507 517//517 516//516 +f 507//507 508//508 518//518 517//517 +f 508//508 509//509 519//519 518//518 +f 509//509 510//510 520//520 519//519 +f 510//510 501//501 511//511 520//520 +f 511//511 512//512 522//522 521//521 +f 512//512 513//513 523//523 522//522 +f 513//513 514//514 524//524 523//523 +f 514//514 515//515 525//525 524//524 +f 515//515 516//516 526//526 525//525 +f 516//516 517//517 527//527 526//526 +f 517//517 518//518 528//528 527//527 +f 518//518 519//519 529//529 528//528 +f 519//519 520//520 530//530 529//529 +f 520//520 511//511 521//521 530//530 +f 521//521 522//522 532//532 531//531 +f 522//522 523//523 533//533 532//532 +f 523//523 524//524 534//534 533//533 +f 524//524 525//525 535//535 534//534 +f 525//525 526//526 536//536 535//535 +f 526//526 527//527 537//537 536//536 +f 527//527 528//528 538//538 537//537 +f 528//528 529//529 539//539 538//538 +f 529//529 530//530 540//540 539//539 +f 530//530 521//521 531//531 540//540 +f 531//531 532//532 542//542 541//541 +f 532//532 533//533 543//543 542//542 +f 533//533 534//534 544//544 543//543 +f 534//534 535//535 545//545 544//544 +f 535//535 536//536 546//546 545//545 +f 536//536 537//537 547//547 546//546 +f 537//537 538//538 548//548 547//547 +f 538//538 539//539 549//549 548//548 +f 539//539 540//540 550//550 549//549 +f 540//540 531//531 541//541 550//550 +f 541//541 542//542 552//552 551//551 +f 542//542 543//543 553//553 552//552 +f 543//543 544//544 554//554 553//553 +f 544//544 545//545 555//555 554//554 +f 545//545 546//546 556//556 555//555 +f 546//546 547//547 557//557 556//556 +f 547//547 548//548 558//558 557//557 +f 548//548 549//549 559//559 558//558 +f 549//549 550//550 560//560 559//559 +f 550//550 541//541 551//551 560//560 +f 551//551 552//552 562//562 561//561 +f 552//552 553//553 563//563 562//562 +f 553//553 554//554 564//564 563//563 +f 554//554 555//555 565//565 564//564 +f 555//555 556//556 566//566 565//565 +f 556//556 557//557 567//567 566//566 +f 557//557 558//558 568//568 567//567 +f 558//558 559//559 569//569 568//568 +f 559//559 560//560 570//570 569//569 +f 560//560 551//551 561//561 570//570 +f 561//561 562//562 572//572 571//571 +f 562//562 563//563 573//573 572//572 +f 563//563 564//564 574//574 573//573 +f 564//564 565//565 575//575 574//574 +f 565//565 566//566 576//576 575//575 +f 566//566 567//567 577//577 576//576 +f 567//567 568//568 578//578 577//577 +f 568//568 569//569 579//579 578//578 +f 569//569 570//570 580//580 579//579 +f 570//570 561//561 571//571 580//580 +f 571//571 572//572 582//582 581//581 +f 572//572 573//573 583//583 582//582 +f 573//573 574//574 584//584 583//583 +f 574//574 575//575 585//585 584//584 +f 575//575 576//576 586//586 585//585 +f 576//576 577//577 587//587 586//586 +f 577//577 578//578 588//588 587//587 +f 578//578 579//579 589//589 588//588 +f 579//579 580//580 590//590 589//589 +f 580//580 571//571 581//581 590//590 +f 581//581 582//582 592//592 591//591 +f 582//582 583//583 593//593 592//592 +f 583//583 584//584 594//594 593//593 +f 584//584 585//585 595//595 594//594 +f 585//585 586//586 596//596 595//595 +f 586//586 587//587 597//597 596//596 +f 587//587 588//588 598//598 597//597 +f 588//588 589//589 599//599 598//598 +f 589//589 590//590 600//600 599//599 +f 590//590 581//581 591//591 600//600 +f 591//591 592//592 602//602 601//601 +f 592//592 593//593 603//603 602//602 +f 593//593 594//594 604//604 603//603 +f 594//594 595//595 605//605 604//604 +f 595//595 596//596 606//606 605//605 +f 596//596 597//597 607//607 606//606 +f 597//597 598//598 608//608 607//607 +f 598//598 599//599 609//609 608//608 +f 599//599 600//600 610//610 609//609 +f 600//600 591//591 601//601 610//610 +f 601//601 602//602 612//612 611//611 +f 602//602 603//603 613//613 612//612 +f 603//603 604//604 614//614 613//613 +f 604//604 605//605 615//615 614//614 +f 605//605 606//606 616//616 615//615 +f 606//606 607//607 617//617 616//616 +f 607//607 608//608 618//618 617//617 +f 608//608 609//609 619//619 618//618 +f 609//609 610//610 620//620 619//619 +f 610//610 601//601 611//611 620//620 +f 611//611 612//612 622//622 621//621 +f 612//612 613//613 623//623 622//622 +f 613//613 614//614 624//624 623//623 +f 614//614 615//615 625//625 624//624 +f 615//615 616//616 626//626 625//625 +f 616//616 617//617 627//627 626//626 +f 617//617 618//618 628//628 627//627 +f 618//618 619//619 629//629 628//628 +f 619//619 620//620 630//630 629//629 +f 620//620 611//611 621//621 630//630 +f 621//621 622//622 632//632 631//631 +f 622//622 623//623 633//633 632//632 +f 623//623 624//624 634//634 633//633 +f 624//624 625//625 635//635 634//634 +f 625//625 626//626 636//636 635//635 +f 626//626 627//627 637//637 636//636 +f 627//627 628//628 638//638 637//637 +f 628//628 629//629 639//639 638//638 +f 629//629 630//630 640//640 639//639 +f 630//630 621//621 631//631 640//640 +f 631//631 632//632 642//642 641//641 +f 632//632 633//633 643//643 642//642 +f 633//633 634//634 644//644 643//643 +f 634//634 635//635 645//645 644//644 +f 635//635 636//636 646//646 645//645 +f 636//636 637//637 647//647 646//646 +f 637//637 638//638 648//648 647//647 +f 638//638 639//639 649//649 648//648 +f 639//639 640//640 650//650 649//649 +f 640//640 631//631 641//641 650//650 +f 641//641 642//642 652//652 651//651 +f 642//642 643//643 653//653 652//652 +f 643//643 644//644 654//654 653//653 +f 644//644 645//645 655//655 654//654 +f 645//645 646//646 656//656 655//655 +f 646//646 647//647 657//657 656//656 +f 647//647 648//648 658//658 657//657 +f 648//648 649//649 659//659 658//658 +f 649//649 650//650 660//660 659//659 +f 650//650 641//641 651//651 660//660 +f 651//651 652//652 662//662 661//661 +f 652//652 653//653 663//663 662//662 +f 653//653 654//654 664//664 663//663 +f 654//654 655//655 665//665 664//664 +f 655//655 656//656 666//666 665//665 +f 656//656 657//657 667//667 666//666 +f 657//657 658//658 668//668 667//667 +f 658//658 659//659 669//669 668//668 +f 659//659 660//660 670//670 669//669 +f 660//660 651//651 661//661 670//670 +f 661//661 662//662 672//672 671//671 +f 662//662 663//663 673//673 672//672 +f 663//663 664//664 674//674 673//673 +f 664//664 665//665 675//675 674//674 +f 665//665 666//666 676//676 675//675 +f 666//666 667//667 677//677 676//676 +f 667//667 668//668 678//678 677//677 +f 668//668 669//669 679//679 678//678 +f 669//669 670//670 680//680 679//679 +f 670//670 661//661 671//671 680//680 +f 671//671 672//672 682//682 681//681 +f 672//672 673//673 683//683 682//682 +f 673//673 674//674 684//684 683//683 +f 674//674 675//675 685//685 684//684 +f 675//675 676//676 686//686 685//685 +f 676//676 677//677 687//687 686//686 +f 677//677 678//678 688//688 687//687 +f 678//678 679//679 689//689 688//688 +f 679//679 680//680 690//690 689//689 +f 680//680 671//671 681//681 690//690 +f 681//681 682//682 692//692 691//691 +f 682//682 683//683 693//693 692//692 +f 683//683 684//684 694//694 693//693 +f 684//684 685//685 695//695 694//694 +f 685//685 686//686 696//696 695//695 +f 686//686 687//687 697//697 696//696 +f 687//687 688//688 698//698 697//697 +f 688//688 689//689 699//699 698//698 +f 689//689 690//690 700//700 699//699 +f 690//690 681//681 691//691 700//700 +f 691//691 692//692 702//702 701//701 +f 692//692 693//693 703//703 702//702 +f 693//693 694//694 704//704 703//703 +f 694//694 695//695 705//705 704//704 +f 695//695 696//696 706//706 705//705 +f 696//696 697//697 707//707 706//706 +f 697//697 698//698 708//708 707//707 +f 698//698 699//699 709//709 708//708 +f 699//699 700//700 710//710 709//709 +f 700//700 691//691 701//701 710//710 +f 701//701 702//702 712//712 711//711 +f 702//702 703//703 713//713 712//712 +f 703//703 704//704 714//714 713//713 +f 704//704 705//705 715//715 714//714 +f 705//705 706//706 716//716 715//715 +f 706//706 707//707 717//717 716//716 +f 707//707 708//708 718//718 717//717 +f 708//708 709//709 719//719 718//718 +f 709//709 710//710 720//720 719//719 +f 710//710 701//701 711//711 720//720 +f 711//711 712//712 722//722 721//721 +f 712//712 713//713 723//723 722//722 +f 713//713 714//714 724//724 723//723 +f 714//714 715//715 725//725 724//724 +f 715//715 716//716 726//726 725//725 +f 716//716 717//717 727//727 726//726 +f 717//717 718//718 728//728 727//727 +f 718//718 719//719 729//729 728//728 +f 719//719 720//720 730//730 729//729 +f 720//720 711//711 721//721 730//730 +f 721//721 722//722 732//732 731//731 +f 722//722 723//723 733//733 732//732 +f 723//723 724//724 734//734 733//733 +f 724//724 725//725 735//735 734//734 +f 725//725 726//726 736//736 735//735 +f 726//726 727//727 737//737 736//736 +f 727//727 728//728 738//738 737//737 +f 728//728 729//729 739//739 738//738 +f 729//729 730//730 740//740 739//739 +f 730//730 721//721 731//731 740//740 +f 731//731 732//732 742//742 741//741 +f 732//732 733//733 743//743 742//742 +f 733//733 734//734 744//744 743//743 +f 734//734 735//735 745//745 744//744 +f 735//735 736//736 746//746 745//745 +f 736//736 737//737 747//747 746//746 +f 737//737 738//738 748//748 747//747 +f 738//738 739//739 749//749 748//748 +f 739//739 740//740 750//750 749//749 +f 740//740 731//731 741//741 750//750 +f 741//741 742//742 752//752 751//751 +f 742//742 743//743 753//753 752//752 +f 743//743 744//744 754//754 753//753 +f 744//744 745//745 755//755 754//754 +f 745//745 746//746 756//756 755//755 +f 746//746 747//747 757//757 756//756 +f 747//747 748//748 758//758 757//757 +f 748//748 749//749 759//759 758//758 +f 749//749 750//750 760//760 759//759 +f 750//750 741//741 751//751 760//760 +f 751//751 752//752 762//762 761//761 +f 752//752 753//753 763//763 762//762 +f 753//753 754//754 764//764 763//763 +f 754//754 755//755 765//765 764//764 +f 755//755 756//756 766//766 765//765 +f 756//756 757//757 767//767 766//766 +f 757//757 758//758 768//768 767//767 +f 758//758 759//759 769//769 768//768 +f 759//759 760//760 770//770 769//769 +f 760//760 751//751 761//761 770//770 +f 761//761 762//762 772//772 771//771 +f 762//762 763//763 773//773 772//772 +f 763//763 764//764 774//774 773//773 +f 764//764 765//765 775//775 774//774 +f 765//765 766//766 776//776 775//775 +f 766//766 767//767 777//777 776//776 +f 767//767 768//768 778//778 777//777 +f 768//768 769//769 779//779 778//778 +f 769//769 770//770 780//780 779//779 +f 770//770 761//761 771//771 780//780 +f 771//771 772//772 782//782 781//781 +f 772//772 773//773 783//783 782//782 +f 773//773 774//774 784//784 783//783 +f 774//774 775//775 785//785 784//784 +f 775//775 776//776 786//786 785//785 +f 776//776 777//777 787//787 786//786 +f 777//777 778//778 788//788 787//787 +f 778//778 779//779 789//789 788//788 +f 779//779 780//780 790//790 789//789 +f 780//780 771//771 781//781 790//790 +f 781//781 782//782 792//792 791//791 +f 782//782 783//783 793//793 792//792 +f 783//783 784//784 794//794 793//793 +f 784//784 785//785 795//795 794//794 +f 785//785 786//786 796//796 795//795 +f 786//786 787//787 797//797 796//796 +f 787//787 788//788 798//798 797//797 +f 788//788 789//789 799//799 798//798 +f 789//789 790//790 800//800 799//799 +f 790//790 781//781 791//791 800//800 +f 791//791 792//792 802//802 801//801 +f 792//792 793//793 803//803 802//802 +f 793//793 794//794 804//804 803//803 +f 794//794 795//795 805//805 804//804 +f 795//795 796//796 806//806 805//805 +f 796//796 797//797 807//807 806//806 +f 797//797 798//798 808//808 807//807 +f 798//798 799//799 809//809 808//808 +f 799//799 800//800 810//810 809//809 +f 800//800 791//791 801//801 810//810 +f 801//801 802//802 812//812 811//811 +f 802//802 803//803 813//813 812//812 +f 803//803 804//804 814//814 813//813 +f 804//804 805//805 815//815 814//814 +f 805//805 806//806 816//816 815//815 +f 806//806 807//807 817//817 816//816 +f 807//807 808//808 818//818 817//817 +f 808//808 809//809 819//819 818//818 +f 809//809 810//810 820//820 819//819 +f 810//810 801//801 811//811 820//820 +f 811//811 812//812 822//822 821//821 +f 812//812 813//813 823//823 822//822 +f 813//813 814//814 824//824 823//823 +f 814//814 815//815 825//825 824//824 +f 815//815 816//816 826//826 825//825 +f 816//816 817//817 827//827 826//826 +f 817//817 818//818 828//828 827//827 +f 818//818 819//819 829//829 828//828 +f 819//819 820//820 830//830 829//829 +f 820//820 811//811 821//821 830//830 +f 821//821 822//822 832//832 831//831 +f 822//822 823//823 833//833 832//832 +f 823//823 824//824 834//834 833//833 +f 824//824 825//825 835//835 834//834 +f 825//825 826//826 836//836 835//835 +f 826//826 827//827 837//837 836//836 +f 827//827 828//828 838//838 837//837 +f 828//828 829//829 839//839 838//838 +f 829//829 830//830 840//840 839//839 +f 830//830 821//821 831//831 840//840 +f 831//831 832//832 842//842 841//841 +f 832//832 833//833 843//843 842//842 +f 833//833 834//834 844//844 843//843 +f 834//834 835//835 845//845 844//844 +f 835//835 836//836 846//846 845//845 +f 836//836 837//837 847//847 846//846 +f 837//837 838//838 848//848 847//847 +f 838//838 839//839 849//849 848//848 +f 839//839 840//840 850//850 849//849 +f 840//840 831//831 841//841 850//850 +f 841//841 842//842 852//852 851//851 +f 842//842 843//843 853//853 852//852 +f 843//843 844//844 854//854 853//853 +f 844//844 845//845 855//855 854//854 +f 845//845 846//846 856//856 855//855 +f 846//846 847//847 857//857 856//856 +f 847//847 848//848 858//858 857//857 +f 848//848 849//849 859//859 858//858 +f 849//849 850//850 860//860 859//859 +f 850//850 841//841 851//851 860//860 +f 851//851 852//852 862//862 861//861 +f 852//852 853//853 863//863 862//862 +f 853//853 854//854 864//864 863//863 +f 854//854 855//855 865//865 864//864 +f 855//855 856//856 866//866 865//865 +f 856//856 857//857 867//867 866//866 +f 857//857 858//858 868//868 867//867 +f 858//858 859//859 869//869 868//868 +f 859//859 860//860 870//870 869//869 +f 860//860 851//851 861//861 870//870 +f 861//861 862//862 872//872 871//871 +f 862//862 863//863 873//873 872//872 +f 863//863 864//864 874//874 873//873 +f 864//864 865//865 875//875 874//874 +f 865//865 866//866 876//876 875//875 +f 866//866 867//867 877//877 876//876 +f 867//867 868//868 878//878 877//877 +f 868//868 869//869 879//879 878//878 +f 869//869 870//870 880//880 879//879 +f 870//870 861//861 871//871 880//880 +f 871//871 872//872 882//882 881//881 +f 872//872 873//873 883//883 882//882 +f 873//873 874//874 884//884 883//883 +f 874//874 875//875 885//885 884//884 +f 875//875 876//876 886//886 885//885 +f 876//876 877//877 887//887 886//886 +f 877//877 878//878 888//888 887//887 +f 878//878 879//879 889//889 888//888 +f 879//879 880//880 890//890 889//889 +f 880//880 871//871 881//881 890//890 +f 881//881 882//882 892//892 891//891 +f 882//882 883//883 893//893 892//892 +f 883//883 884//884 894//894 893//893 +f 884//884 885//885 895//895 894//894 +f 885//885 886//886 896//896 895//895 +f 886//886 887//887 897//897 896//896 +f 887//887 888//888 898//898 897//897 +f 888//888 889//889 899//899 898//898 +f 889//889 890//890 900//900 899//899 +f 890//890 881//881 891//891 900//900 +f 891//891 892//892 2//2 1//1 +f 892//892 893//893 3//3 2//2 +f 893//893 894//894 4//4 3//3 +f 894//894 895//895 5//5 4//4 +f 895//895 896//896 6//6 5//5 +f 896//896 897//897 7//7 6//6 +f 897//897 898//898 8//8 7//7 +f 898//898 899//899 9//9 8//8 +f 899//899 900//900 10//10 9//9 +f 900//900 891//891 1//1 10//10 +o base +v 4.2000 0.0000 -1.6 0.35 0.35 0.4 +v 4.2000 0.0000 -1.2 0.6 0.6 0.65 +v 4.1641 0.5482 -1.6 0.35 0.35 0.4 +v 4.1641 0.5482 -1.2 0.6 0.6 0.65 +v 4.0569 1.0870 -1.6 0.35 0.35 0.4 +v 4.0569 1.0870 -1.2 0.6 0.6 0.65 +v 3.8803 1.6073 -1.6 0.35 0.35 0.4 +v 3.8803 1.6073 -1.2 0.6 0.6 0.65 +v 3.6373 2.1000 -1.6 0.35 0.35 0.4 +v 3.6373 2.1000 -1.2 0.6 0.6 0.65 +v 3.3321 2.5568 -1.6 0.35 0.35 0.4 +v 3.3321 2.5568 -1.2 0.6 0.6 0.65 +v 2.9698 2.9698 -1.6 0.35 0.35 0.4 +v 2.9698 2.9698 -1.2 0.6 0.6 0.65 +v 2.5568 3.3321 -1.6 0.35 0.35 0.4 +v 2.5568 3.3321 -1.2 0.6 0.6 0.65 +v 2.1000 3.6373 -1.6 0.35 0.35 0.4 +v 2.1000 3.6373 -1.2 0.6 0.6 0.65 +v 1.6073 3.8803 -1.6 0.35 0.35 0.4 +v 1.6073 3.8803 -1.2 0.6 0.6 0.65 +v 1.0870 4.0569 -1.6 0.35 0.35 0.4 +v 1.0870 4.0569 -1.2 0.6 0.6 0.65 +v 0.5482 4.1641 -1.6 0.35 0.35 0.4 +v 0.5482 4.1641 -1.2 0.6 0.6 0.65 +v 0.0000 4.2000 -1.6 0.35 0.35 0.4 +v 0.0000 4.2000 -1.2 0.6 0.6 0.65 +v -0.5482 4.1641 -1.6 0.35 0.35 0.4 +v -0.5482 4.1641 -1.2 0.6 0.6 0.65 +v -1.0870 4.0569 -1.6 0.35 0.35 0.4 +v -1.0870 4.0569 -1.2 0.6 0.6 0.65 +v -1.6073 3.8803 -1.6 0.35 0.35 0.4 +v -1.6073 3.8803 -1.2 0.6 0.6 0.65 +v -2.1000 3.6373 -1.6 0.35 0.35 0.4 +v -2.1000 3.6373 -1.2 0.6 0.6 0.65 +v -2.5568 3.3321 -1.6 0.35 0.35 0.4 +v -2.5568 3.3321 -1.2 0.6 0.6 0.65 +v -2.9698 2.9698 -1.6 0.35 0.35 0.4 +v -2.9698 2.9698 -1.2 0.6 0.6 0.65 +v -3.3321 2.5568 -1.6 0.35 0.35 0.4 +v -3.3321 2.5568 -1.2 0.6 0.6 0.65 +v -3.6373 2.1000 -1.6 0.35 0.35 0.4 +v -3.6373 2.1000 -1.2 0.6 0.6 0.65 +v -3.8803 1.6073 -1.6 0.35 0.35 0.4 +v -3.8803 1.6073 -1.2 0.6 0.6 0.65 +v -4.0569 1.0870 -1.6 0.35 0.35 0.4 +v -4.0569 1.0870 -1.2 0.6 0.6 0.65 +v -4.1641 0.5482 -1.6 0.35 0.35 0.4 +v -4.1641 0.5482 -1.2 0.6 0.6 0.65 +v -4.2000 0.0000 -1.6 0.35 0.35 0.4 +v -4.2000 0.0000 -1.2 0.6 0.6 0.65 +v -4.1641 -0.5482 -1.6 0.35 0.35 0.4 +v -4.1641 -0.5482 -1.2 0.6 0.6 0.65 +v -4.0569 -1.0870 -1.6 0.35 0.35 0.4 +v -4.0569 -1.0870 -1.2 0.6 0.6 0.65 +v -3.8803 -1.6073 -1.6 0.35 0.35 0.4 +v -3.8803 -1.6073 -1.2 0.6 0.6 0.65 +v -3.6373 -2.1000 -1.6 0.35 0.35 0.4 +v -3.6373 -2.1000 -1.2 0.6 0.6 0.65 +v -3.3321 -2.5568 -1.6 0.35 0.35 0.4 +v -3.3321 -2.5568 -1.2 0.6 0.6 0.65 +v -2.9698 -2.9698 -1.6 0.35 0.35 0.4 +v -2.9698 -2.9698 -1.2 0.6 0.6 0.65 +v -2.5568 -3.3321 -1.6 0.35 0.35 0.4 +v -2.5568 -3.3321 -1.2 0.6 0.6 0.65 +v -2.1000 -3.6373 -1.6 0.35 0.35 0.4 +v -2.1000 -3.6373 -1.2 0.6 0.6 0.65 +v -1.6073 -3.8803 -1.6 0.35 0.35 0.4 +v -1.6073 -3.8803 -1.2 0.6 0.6 0.65 +v -1.0870 -4.0569 -1.6 0.35 0.35 0.4 +v -1.0870 -4.0569 -1.2 0.6 0.6 0.65 +v -0.5482 -4.1641 -1.6 0.35 0.35 0.4 +v -0.5482 -4.1641 -1.2 0.6 0.6 0.65 +v -0.0000 -4.2000 -1.6 0.35 0.35 0.4 +v -0.0000 -4.2000 -1.2 0.6 0.6 0.65 +v 0.5482 -4.1641 -1.6 0.35 0.35 0.4 +v 0.5482 -4.1641 -1.2 0.6 0.6 0.65 +v 1.0870 -4.0569 -1.6 0.35 0.35 0.4 +v 1.0870 -4.0569 -1.2 0.6 0.6 0.65 +v 1.6073 -3.8803 -1.6 0.35 0.35 0.4 +v 1.6073 -3.8803 -1.2 0.6 0.6 0.65 +v 2.1000 -3.6373 -1.6 0.35 0.35 0.4 +v 2.1000 -3.6373 -1.2 0.6 0.6 0.65 +v 2.5568 -3.3321 -1.6 0.35 0.35 0.4 +v 2.5568 -3.3321 -1.2 0.6 0.6 0.65 +v 2.9698 -2.9698 -1.6 0.35 0.35 0.4 +v 2.9698 -2.9698 -1.2 0.6 0.6 0.65 +v 3.3321 -2.5568 -1.6 0.35 0.35 0.4 +v 3.3321 -2.5568 -1.2 0.6 0.6 0.65 +v 3.6373 -2.1000 -1.6 0.35 0.35 0.4 +v 3.6373 -2.1000 -1.2 0.6 0.6 0.65 +v 3.8803 -1.6073 -1.6 0.35 0.35 0.4 +v 3.8803 -1.6073 -1.2 0.6 0.6 0.65 +v 4.0569 -1.0870 -1.6 0.35 0.35 0.4 +v 4.0569 -1.0870 -1.2 0.6 0.6 0.65 +v 4.1641 -0.5482 -1.6 0.35 0.35 0.4 +v 4.1641 -0.5482 -1.2 0.6 0.6 0.65 +v 0 0 -1.2 0.6 0.6 0.65 +f 901 903 904 902 +f 902 904 997 +f 903 905 906 904 +f 904 906 997 +f 905 907 908 906 +f 906 908 997 +f 907 909 910 908 +f 908 910 997 +f 909 911 912 910 +f 910 912 997 +f 911 913 914 912 +f 912 914 997 +f 913 915 916 914 +f 914 916 997 +f 915 917 918 916 +f 916 918 997 +f 917 919 920 918 +f 918 920 997 +f 919 921 922 920 +f 920 922 997 +f 921 923 924 922 +f 922 924 997 +f 923 925 926 924 +f 924 926 997 +f 925 927 928 926 +f 926 928 997 +f 927 929 930 928 +f 928 930 997 +f 929 931 932 930 +f 930 932 997 +f 931 933 934 932 +f 932 934 997 +f 933 935 936 934 +f 934 936 997 +f 935 937 938 936 +f 936 938 997 +f 937 939 940 938 +f 938 940 997 +f 939 941 942 940 +f 940 942 997 +f 941 943 944 942 +f 942 944 997 +f 943 945 946 944 +f 944 946 997 +f 945 947 948 946 +f 946 948 997 +f 947 949 950 948 +f 948 950 997 +f 949 951 952 950 +f 950 952 997 +f 951 953 954 952 +f 952 954 997 +f 953 955 956 954 +f 954 956 997 +f 955 957 958 956 +f 956 958 997 +f 957 959 960 958 +f 958 960 997 +f 959 961 962 960 +f 960 962 997 +f 961 963 964 962 +f 962 964 997 +f 963 965 966 964 +f 964 966 997 +f 965 967 968 966 +f 966 968 997 +f 967 969 970 968 +f 968 970 997 +f 969 971 972 970 +f 970 972 997 +f 971 973 974 972 +f 972 974 997 +f 973 975 976 974 +f 974 976 997 +f 975 977 978 976 +f 976 978 997 +f 977 979 980 978 +f 978 980 997 +f 979 981 982 980 +f 980 982 997 +f 981 983 984 982 +f 982 984 997 +f 983 985 986 984 +f 984 986 997 +f 985 987 988 986 +f 986 988 997 +f 987 989 990 988 +f 988 990 997 +f 989 991 992 990 +f 990 992 997 +f 991 993 994 992 +f 992 994 997 +f 993 995 996 994 +f 994 996 997 +f 995 901 902 996 +f 996 902 997 +f 903 901 997 +f 905 903 997 +f 907 905 997 +f 909 907 997 +f 911 909 997 +f 913 911 997 +f 915 913 997 +f 917 915 997 +f 919 917 997 +f 921 919 997 +f 923 921 997 +f 925 923 997 +f 927 925 997 +f 929 927 997 +f 931 929 997 +f 933 931 997 +f 935 933 997 +f 937 935 997 +f 939 937 997 +f 941 939 997 +f 943 941 997 +f 945 943 997 +f 947 945 997 +f 949 947 997 +f 951 949 997 +f 953 951 997 +f 955 953 997 +f 957 955 997 +f 959 957 997 +f 961 959 997 +f 963 961 997 +f 965 963 997 +f 967 965 997 +f 969 967 997 +f 971 969 997 +f 973 971 997 +f 975 973 997 +f 977 975 997 +f 979 977 997 +f 981 979 997 +f 983 981 997 +f 985 983 997 +f 987 985 997 +f 989 987 997 +f 991 989 997 +f 993 991 997 +f 995 993 997 +f 901 995 997 diff --git a/crates/exav-viewer/demo/examples/files/models/table.gltf b/crates/exav-viewer/demo/examples/files/models/table.gltf new file mode 100644 index 00000000..76b667c4 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/models/table.gltf @@ -0,0 +1 @@ +{"asset":{"version":"2.0","generator":"exav make-models.py","copyright":"CC0-1.0"},"scene":0,"scenes":[{"nodes":[4,11,18]}],"nodes":[{"mesh":1,"translation":[-0.74,-0.39,-0.38],"name":"leg 0"},{"mesh":1,"translation":[0.74,-0.39,-0.38],"name":"leg 1"},{"mesh":1,"translation":[-0.74,-0.39,0.38],"name":"leg 2"},{"mesh":1,"translation":[0.74,-0.39,0.38],"name":"leg 3"},{"mesh":0,"translation":[0,0.74,0],"name":"table","children":[0,1,2,3]},{"mesh":1,"scale":[0.8,0.6,0.8],"translation":[-0.19,-0.2,-0.19],"name":"chair leg 0"},{"mesh":1,"scale":[0.8,0.6,0.8],"translation":[0.19,-0.2,-0.19],"name":"chair leg 1"},{"mesh":1,"scale":[0.8,0.6,0.8],"translation":[-0.19,-0.2,0.19],"name":"chair leg 2"},{"mesh":1,"scale":[0.8,0.6,0.8],"translation":[0.19,-0.2,0.19],"name":"chair leg 3"},{"mesh":3,"translation":[0,0.28,-0.2],"name":"back"},{"mesh":2,"translation":[0,0.45,0],"name":"seat","children":[5,6,7,8,9]},{"name":"chair a","translation":[-0.4,0,0.85],"children":[10]},{"mesh":3,"translation":[0,0.28,-0.2],"name":"back b"},{"mesh":1,"scale":[0.8,0.6,0.8],"translation":[-0.19,-0.2,-0.19]},{"mesh":1,"scale":[0.8,0.6,0.8],"translation":[0.19,-0.2,-0.19]},{"mesh":1,"scale":[0.8,0.6,0.8],"translation":[-0.19,-0.2,0.19]},{"mesh":1,"scale":[0.8,0.6,0.8],"translation":[0.19,-0.2,0.19]},{"mesh":2,"translation":[0,0.45,0],"name":"seat b","children":[12,13,14,15,16]},{"name":"chair b","translation":[0.5,0,-0.85],"rotation":[0.0,1.0,0.0,6.123233995736766e-17],"children":[17]}],"meshes":[{"name":"top","primitives":[{"attributes":{"POSITION":0,"NORMAL":1},"indices":2,"material":0,"mode":4}]},{"name":"leg","primitives":[{"attributes":{"POSITION":3,"NORMAL":4},"indices":5,"material":0,"mode":4}]},{"name":"seat","primitives":[{"attributes":{"POSITION":6,"NORMAL":7},"indices":8,"material":1,"mode":4}]},{"name":"back","primitives":[{"attributes":{"POSITION":9,"NORMAL":10},"indices":11,"material":1,"mode":4}]}],"materials":[{"name":"wood","pbrMetallicRoughness":{"baseColorFactor":[0.55,0.35,0.18,1],"metallicFactor":0.0,"roughnessFactor":0.7}},{"name":"cloth","pbrMetallicRoughness":{"baseColorFactor":[0.8,0.15,0.15,1],"metallicFactor":0.0,"roughnessFactor":0.9}}],"accessors":[{"bufferView":0,"componentType":5126,"count":24,"type":"VEC3","min":[-0.800000011920929,-0.03999999910593033,-0.44999998807907104],"max":[0.800000011920929,0.03999999910593033,0.44999998807907104]},{"bufferView":1,"componentType":5126,"count":24,"type":"VEC3"},{"bufferView":2,"componentType":5123,"count":36,"type":"SCALAR"},{"bufferView":3,"componentType":5126,"count":24,"type":"VEC3","min":[-0.03999999910593033,-0.3499999940395355,-0.03999999910593033],"max":[0.03999999910593033,0.3499999940395355,0.03999999910593033]},{"bufferView":4,"componentType":5126,"count":24,"type":"VEC3"},{"bufferView":5,"componentType":5123,"count":36,"type":"SCALAR"},{"bufferView":6,"componentType":5126,"count":24,"type":"VEC3","min":[-0.22499999403953552,-0.029999999329447746,-0.22499999403953552],"max":[0.22499999403953552,0.029999999329447746,0.22499999403953552]},{"bufferView":7,"componentType":5126,"count":24,"type":"VEC3"},{"bufferView":8,"componentType":5123,"count":36,"type":"SCALAR"},{"bufferView":9,"componentType":5126,"count":24,"type":"VEC3","min":[-0.22499999403953552,-0.25,-0.02500000037252903],"max":[0.22499999403953552,0.25,0.02500000037252903]},{"bufferView":10,"componentType":5126,"count":24,"type":"VEC3"},{"bufferView":11,"componentType":5123,"count":36,"type":"SCALAR"}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":288,"target":34962},{"buffer":0,"byteOffset":288,"byteLength":288,"target":34962},{"buffer":0,"byteOffset":576,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":648,"byteLength":288,"target":34962},{"buffer":0,"byteOffset":936,"byteLength":288,"target":34962},{"buffer":0,"byteOffset":1224,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":1296,"byteLength":288,"target":34962},{"buffer":0,"byteOffset":1584,"byteLength":288,"target":34962},{"buffer":0,"byteOffset":1872,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":1944,"byteLength":288,"target":34962},{"buffer":0,"byteOffset":2232,"byteLength":288,"target":34962},{"buffer":0,"byteOffset":2520,"byteLength":72,"target":34963}],"buffers":[{"byteLength":2592,"uri":"data:application/octet-stream;base64,zcxMvwrXI71mZua+zcxMvwrXIz1mZua+zcxMvwrXIz1mZuY+zcxMvwrXI71mZuY+zcxMPwrXI71mZua+zcxMPwrXIz1mZua+zcxMPwrXIz1mZuY+zcxMPwrXI71mZuY+zcxMvwrXI71mZua+zcxMvwrXI71mZuY+zcxMPwrXI71mZuY+zcxMPwrXI71mZua+zcxMvwrXIz1mZua+zcxMvwrXIz1mZuY+zcxMPwrXIz1mZuY+zcxMPwrXIz1mZua+zcxMvwrXI71mZua+zcxMPwrXI71mZua+zcxMPwrXIz1mZua+zcxMvwrXIz1mZua+zcxMvwrXI71mZuY+zcxMPwrXI71mZuY+zcxMPwrXIz1mZuY+zcxMvwrXIz1mZuY+AACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcACtcjvTMzs74K1yO9CtcjvTMzsz4K1yO9CtcjvTMzsz4K1yM9CtcjvTMzs74K1yM9CtcjPTMzs74K1yO9CtcjPTMzsz4K1yO9CtcjPTMzsz4K1yM9CtcjPTMzs74K1yM9CtcjvTMzs74K1yO9CtcjvTMzs74K1yM9CtcjPTMzs74K1yM9CtcjPTMzs74K1yO9CtcjvTMzsz4K1yO9CtcjvTMzsz4K1yM9CtcjPTMzsz4K1yM9CtcjPTMzsz4K1yO9CtcjvTMzs74K1yO9CtcjPTMzs74K1yO9CtcjPTMzsz4K1yO9CtcjvTMzsz4K1yO9CtcjvTMzs74K1yM9CtcjPTMzs74K1yM9CtcjPTMzsz4K1yM9CtcjvTMzsz4K1yM9AACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAZmZmvo/C9bxmZma+ZmZmvo/C9TxmZma+ZmZmvo/C9TxmZmY+ZmZmvo/C9bxmZmY+ZmZmPo/C9bxmZma+ZmZmPo/C9TxmZma+ZmZmPo/C9TxmZmY+ZmZmPo/C9bxmZmY+ZmZmvo/C9bxmZma+ZmZmvo/C9bxmZmY+ZmZmPo/C9bxmZmY+ZmZmPo/C9bxmZma+ZmZmvo/C9TxmZma+ZmZmvo/C9TxmZmY+ZmZmPo/C9TxmZmY+ZmZmPo/C9TxmZma+ZmZmvo/C9bxmZma+ZmZmPo/C9bxmZma+ZmZmPo/C9TxmZma+ZmZmvo/C9TxmZma+ZmZmvo/C9bxmZmY+ZmZmPo/C9bxmZmY+ZmZmPo/C9TxmZmY+ZmZmvo/C9TxmZmY+AACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAZmZmvgAAgL7NzMy8ZmZmvgAAgD7NzMy8ZmZmvgAAgD7NzMw8ZmZmvgAAgL7NzMw8ZmZmPgAAgL7NzMy8ZmZmPgAAgD7NzMy8ZmZmPgAAgD7NzMw8ZmZmPgAAgL7NzMw8ZmZmvgAAgL7NzMy8ZmZmvgAAgL7NzMw8ZmZmPgAAgL7NzMw8ZmZmPgAAgL7NzMy8ZmZmvgAAgD7NzMy8ZmZmvgAAgD7NzMw8ZmZmPgAAgD7NzMw8ZmZmPgAAgD7NzMy8ZmZmvgAAgL7NzMy8ZmZmPgAAgL7NzMy8ZmZmPgAAgD7NzMy8ZmZmvgAAgD7NzMy8ZmZmvgAAgL7NzMw8ZmZmPgAAgL7NzMw8ZmZmPgAAgD7NzMw8ZmZmvgAAgD7NzMw8AACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcA"}]} \ No newline at end of file diff --git a/crates/exav-viewer/demo/examples/files/models/terrain.ply b/crates/exav-viewer/demo/examples/files/models/terrain.ply new file mode 100644 index 00000000..0f93c577 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/models/terrain.ply differ diff --git a/crates/exav-viewer/demo/examples/files/office/deck.pptx b/crates/exav-viewer/demo/examples/files/office/deck.pptx new file mode 100644 index 00000000..257d3ec6 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/office/deck.pptx differ diff --git a/crates/exav-viewer/demo/examples/files/office/handbook.docx b/crates/exav-viewer/demo/examples/files/office/handbook.docx new file mode 100644 index 00000000..931b4e2d Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/office/handbook.docx differ diff --git a/crates/exav-viewer/demo/examples/files/office/workbook.xlsx b/crates/exav-viewer/demo/examples/files/office/workbook.xlsx new file mode 100644 index 00000000..75a5e28e Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/office/workbook.xlsx differ diff --git a/crates/exav-viewer/demo/examples/files/pdf/annotations.pdf b/crates/exav-viewer/demo/examples/files/pdf/annotations.pdf new file mode 100644 index 00000000..a5b28199 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/pdf/annotations.pdf differ diff --git a/crates/exav-viewer/demo/examples/files/pdf/application-form.pdf b/crates/exav-viewer/demo/examples/files/pdf/application-form.pdf new file mode 100644 index 00000000..6adc5309 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/pdf/application-form.pdf differ diff --git a/crates/exav-viewer/demo/examples/files/pdf/field-report.pdf b/crates/exav-viewer/demo/examples/files/pdf/field-report.pdf new file mode 100644 index 00000000..0aed9e7b Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/pdf/field-report.pdf differ diff --git a/crates/exav-viewer/demo/examples/files/pdf/handbook-tagged.pdf b/crates/exav-viewer/demo/examples/files/pdf/handbook-tagged.pdf new file mode 100644 index 00000000..fd6c8342 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/pdf/handbook-tagged.pdf differ diff --git a/crates/exav-viewer/demo/examples/files/pdf/scan-mixed.pdf b/crates/exav-viewer/demo/examples/files/pdf/scan-mixed.pdf new file mode 100644 index 00000000..02d8a9ee Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/pdf/scan-mixed.pdf differ diff --git a/crates/exav-viewer/demo/examples/files/pdf/vector-art.pdf b/crates/exav-viewer/demo/examples/files/pdf/vector-art.pdf new file mode 100644 index 00000000..4a51e543 Binary files /dev/null and b/crates/exav-viewer/demo/examples/files/pdf/vector-art.pdf differ diff --git a/crates/exav-viewer/demo/examples/files/text/bits.c b/crates/exav-viewer/demo/examples/files/text/bits.c new file mode 100644 index 00000000..01703964 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/bits.c @@ -0,0 +1,51 @@ +/* Bit tricks on 32-bit words. CC0-1.0, exav project. */ +#include +#include + +/* The number of set bits, by the parallel sum. */ +static unsigned popcount32(uint32_t x) { + x = x - ((x >> 1) & 0x55555555u); + x = (x & 0x33333333u) + ((x >> 2) & 0x33333333u); + x = (x + (x >> 4)) & 0x0f0f0f0fu; + return (x * 0x01010101u) >> 24; +} + +/* The index of the highest set bit, or -1 for zero. */ +static int high_bit(uint32_t x) { + int n = -1; + while (x) { + n++; + x >>= 1; + } + return n; +} + +/* The next power of two that is not below x. */ +static uint32_t next_pow2(uint32_t x) { + if (x <= 1) return 1; + x--; + x |= x >> 1; + x |= x >> 2; + x |= x >> 4; + x |= x >> 8; + x |= x >> 16; + return x + 1; +} + +/* The bits of x in reverse order. */ +static uint32_t reverse32(uint32_t x) { + x = ((x >> 1) & 0x55555555u) | ((x & 0x55555555u) << 1); + x = ((x >> 2) & 0x33333333u) | ((x & 0x33333333u) << 2); + x = ((x >> 4) & 0x0f0f0f0fu) | ((x & 0x0f0f0f0fu) << 4); + x = ((x >> 8) & 0x00ff00ffu) | ((x & 0x00ff00ffu) << 8); + return (x >> 16) | (x << 16); +} + +int main(void) { + const uint32_t samples[] = {0u, 1u, 255u, 1000u, 0xdeadbeefu}; + for (size_t i = 0; i < sizeof samples / sizeof samples[0]; i++) { + uint32_t v = samples[i]; + printf("%10u: bits %2u, high %2d, pow2 %10u, reversed %08x\n", v, popcount32(v), high_bit(v), next_pow2(v), reverse32(v)); + } + return 0; +} diff --git a/crates/exav-viewer/demo/examples/files/text/changes.diff b/crates/exav-viewer/demo/examples/files/text/changes.diff new file mode 100644 index 00000000..9098ae3e --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/changes.diff @@ -0,0 +1,31 @@ +diff --git a/src/berths.py b/src/berths.py +index 3f2a1c4..9b7d0e8 100644 +--- a/src/berths.py ++++ b/src/berths.py +@@ -10,12 +10,16 @@ class Berth: + number: int + length_m: float ++ max_draught_m: float = 1.5 + + def fits(self, boat): +- return boat.length_m <= self.length_m ++ return boat.length_m <= self.length_m and boat.draught_m <= self.max_draught_m + + def label(self): +- return "Berth %d" % self.number ++ return f"Berth {self.number} ({self.length_m:.1f} m)" +@@ -40,7 +44,7 @@ def free_berths(register, boat): + for berth in register: + if berth.fits(boat): + yield berth +- return ++ return None +diff --git a/README.md b/README.md +index 1e2d3f4..5a6b7c8 100644 +--- a/README.md ++++ b/README.md +@@ -1,4 +1,5 @@ + # Harbour register + + The mooring register of the east quay. ++Berths now have a maximum draught. diff --git a/crates/exav-viewer/demo/examples/files/text/config.yaml b/crates/exav-viewer/demo/examples/files/text/config.yaml new file mode 100644 index 00000000..cee2b1da --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/config.yaml @@ -0,0 +1,28 @@ +# A configuration file, to show YAML as text. +service: + name: harbour-register + port: 8080 + debug: false + tags: [boats, berths, "quay east"] + limits: + requests_per_minute: 600 + body_bytes: 1048576 +database: + url: postgres://register@localhost:5432/harbour + pool: {min: 2, max: 10} +berths: + - number: 1 + length_m: 6.5 + vessel: Aurora + - number: 2 + length_m: 8.2 + vessel: Brise +notes: | + A block of text that keeps + its line breaks. +folded: > + A block of text that is + folded into one line. +anchors: + base: &base {retries: 3, timeout_s: 30} + copy: *base diff --git a/crates/exav-viewer/demo/examples/files/text/deploy.sh b/crates/exav-viewer/demo/examples/files/text/deploy.sh new file mode 100644 index 00000000..bf71a01e --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/deploy.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Build, check and copy a static site. CC0-1.0, exav project. +set -euo pipefail + +SITE_DIR="${SITE_DIR:-./public}" +REMOTE="${REMOTE:-deploy@example.invalid:/var/www/site}" +LOG="$(mktemp)" + +cleanup() { + local status=$? + rm -f "$LOG" + if [ "$status" -ne 0 ]; then + echo "deploy failed with status $status" >&2 + fi +} +trap cleanup EXIT + +log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; } + +build() { + log "building" + npm ci --silent >"$LOG" 2>&1 || { cat "$LOG" >&2; return 1; } + npm run build --silent +} + +check() { + local broken=0 + while IFS= read -r -d '' file; do + if ! grep -q '' "$file"; then + log "incomplete page: $file" + broken=$((broken + 1)) + fi + done < <(find "$SITE_DIR" -name '*.html' -print0) + return "$broken" +} + +case "${1:-all}" in + build) build ;; + check) check ;; + all) build && check && rsync -az --delete "$SITE_DIR/" "$REMOTE" && log "done" ;; + *) echo "usage: $0 [build|check|all]" >&2; exit 2 ;; +esac diff --git a/crates/exav-viewer/demo/examples/files/text/feed.xml b/crates/exav-viewer/demo/examples/files/text/feed.xml new file mode 100644 index 00000000..b931bb90 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/feed.xml @@ -0,0 +1,27 @@ + + + + Notes from the east quay + Moorings, tides & weather + + + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + 2000-01-03T12:00:00Z + The harbour officeoffice@example.invalid + + New berths on the east quay + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2000-01-03T12:00:00Z + Twelve berths for visiting boats open on 1 March. +

    The east quay is longer by 40 metres.

    + +
    + + Tide tables for the year + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b + 2000-01-01T08:00:00Z + + +
    diff --git a/crates/exav-viewer/demo/examples/files/text/field-guide.md b/crates/exav-viewer/demo/examples/files/text/field-guide.md new file mode 100644 index 00000000..4488dfa0 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/field-guide.md @@ -0,0 +1,124 @@ +# Field guide to Markdown + +A short document that uses most of what Markdown can say. It is written for the +exav demo and is in the public domain (CC0-1.0). + +## Emphasis and spans + +Text can be **bold**, *italic*, ***both***, ~~struck through~~ or `code`. A line +that ends with two spaces +breaks without starting a paragraph. A [link with a title](https://exav.org/ "exav.org"), +an autolink , and a reference link: [the demo][demo]. + +[demo]: https://exav.org/viewer/demo/ + +Escapes: \*not italic\*, 2 \< 3, a literal backtick \`, and entities: © & é. +Unicode goes through unchanged: café, naïve, Ωmega, Жук, 日本語, مرحبا, ☃. + +## Headings + +### Third level +#### Fourth level +##### Fifth level +###### Sixth level + +Setext heading +============== + +Another one +----------- + +## Lists + +- A bullet +- Another, with a nested list: + - second level + - third level + - back to the second +- And a last one + +1. Numbered +2. Items + 1. Nested numbers + 2. Continue +3. Done + +7. A list that starts at seven +8. and goes on + +- [x] A finished task +- [ ] An open task +- [ ] Another open task, with `code` in it + +Loose list: + +- First paragraph. + + A second paragraph inside the same item. + +- Next item. + +## Quotes + +> A quotation, which can wrap over several lines and holds +> *other* **formatting**. +> +> > A quotation inside it. +> +> - and a list + +## Tables + +| Left | Centre | Right | +| :--- | :----: | ----: | +| a | b | c | +| `code` | **bold** | 1,234.50 | +| A longer cell that holds more words | x | 7 | + +## Code + +Inline `let x = 1;` and a fenced block with a language: + +```rust +fn fib(n: u32) -> u64 { + let (mut a, mut b) = (0u64, 1u64); + for _ in 0..n { + (a, b) = (b, a + b); + } + a +} +``` + +```python +def fib(n): + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a +``` + +```diff +- old line ++ new line +``` + +An indented block: + + four spaces make this code + and it keeps its spacing + +## Rules, HTML and footnotes + +--- + +Inline HTML such as Ctrl + C and sub and sup is +shown as text or ignored, never run: does nothing. + +A sentence with a footnote.[^1] And another one.[^note] + +[^1]: The first footnote. +[^note]: A second, with *formatting*. + +![An image reference](https://example.invalid/not-fetched.png) + +The end. diff --git a/crates/exav-viewer/demo/examples/files/text/field-notes.txt b/crates/exav-viewer/demo/examples/files/text/field-notes.txt new file mode 100644 index 00000000..6c15e686 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/field-notes.txt @@ -0,0 +1,30 @@ +Field notes +=========== + +Plain text is shown as it is written: line breaks, indentation, tabs and +long lines are kept. + + A line that starts with a tab. + A line that starts with eight spaces. + +A very long line, to see how the viewer wraps or scrolls it: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +Pangrams, one for each script: + + English The quick brown fox jumps over the lazy dog. + Français Portez ce vieux whisky au juge blond qui fume. + Deutsch Zwölf Boxkämpfer jagen Viktor quer über den großen Sylter Deich. + Español El pingüino Wenceslao hizo kilómetros bajo exhaustiva lluvia y frío. + Ελληνικά Ξεσκεπάζω την ψυχοφθόÏ�α βδελυγμία. + РуÑ�Ñ�кий Съешь же ещё Ñ�тих мÑ�гких французÑ�ких булок да выпей чаю. + 日本語 ã�„ã‚�ã�¯ã�«ã�»ã�¸ã�¨ ã�¡ã‚Šã�¬ã‚‹ã‚’ ã‚�ã�‹ã‚ˆã�Ÿã‚Œã�� ã�¤ã�­ã�ªã‚‰ã‚€ + العربية نص حكيم له سر قاطع وذو شأن عظيم مكتوب على ثوب أخضر ومزخرÙ� + עברית דג סקרן שט בי×� מ×�וכזב ולפתע מצ×� לו חברה ×�יך הקליטה + +Symbols and box drawing: + + ┌──────┬──────â”� â†� ↑ → ↓ ✓ ✗ ½ â…“ ¾ ± × ÷ ≠ ≤ ≥ ∞ + │ a │ b │ € £ Â¥ â‚¿ © ® â„¢ ☀ â˜� ☂ ☃ + └──────┴──────┘ + +The last line has no newline at the end. \ No newline at end of file diff --git a/crates/exav-viewer/demo/examples/files/text/harbour-log.csv b/crates/exav-viewer/demo/examples/files/text/harbour-log.csv new file mode 100644 index 00000000..d2845fb0 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/harbour-log.csv @@ -0,0 +1,332 @@ +date,vessel,type,length_m,beam_m,berth,nights,fee_eur,notes +2000-01-01,Hirondelle,motor yacht,9.7,3.2,33,5,117.60,"Arrived late, ""quiet"" night" +2000-01-01,Hirondelle,dinghy,6.6,2.18,40,5,92.80, +2000-01-01,Calypso,sloop,5.6,1.85,35,3,50.88, +2000-01-01,Hirondelle,dinghy,11.3,3.73,48,4,104.32, +2000-01-01,Brise,sloop,11.7,3.86,3,5,133.60,"Arrived late, ""quiet"" night" +2000-01-01,Aurora,motor yacht,14.6,4.82,21,5,156.80,Café voucher given +2000-01-01,Iroise,ketch,11.3,3.73,41,2,52.16,Bill sent; paid by cheque +2000-01-01,Hirondelle,dinghy,10.2,3.37,18,1,24.32,"Two nights, +left at dawn" +2000-01-01,La Mouette,trawler,16.0,5.28,21,1,33.60,"Arrived late, ""quiet"" night" +2000-01-01,Brise,catamaran,10.4,3.43,50,1,24.64, +2000-01-02,Goeland,sloop,9.1,3.0,2,3,67.68, +2000-01-02,Hirondelle,motor yacht,7.0,2.31,46,1,19.20,"Two nights, +left at dawn" +2000-01-02,Kerguelen,ketch,9.3,3.07,50,5,114.40,Bill sent; paid by cheque +2000-01-02,Fauvette,sloop,8.4,2.77,27,3,64.32, +2000-01-02,Aurora,sloop,6.0,1.98,30,1,17.60,Café voucher given +2000-01-02,Dorade,motor yacht,6.5,2.15,33,5,92.00,"Arrived late, ""quiet"" night" +2000-01-02,Goeland,dinghy,15.8,5.21,25,2,66.56, +2000-01-02,Aurora,trawler,9.0,2.97,56,2,44.80,Bill sent; paid by cheque +2000-01-02,Dorade,ketch,16.0,5.28,26,1,33.60, +2000-01-02,Dorade,motor yacht,5.0,1.65,17,2,32.00, +2000-01-03,Etoile,motor yacht,13.4,4.42,5,3,88.32, +2000-01-03,Kerguelen,ketch,5.5,1.82,1,5,84.00,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-03,Calypso,catamaran,8.8,2.9,31,4,88.32, +2000-01-03,Kerguelen,ketch,14.5,4.79,20,2,62.40,"Arrived late, ""quiet"" night" +2000-01-03,La Mouette,ketch,13.9,4.59,11,2,60.48, +2000-01-03,Hirondelle,catamaran,6.8,2.24,6,4,75.52,"Two nights, +left at dawn" +2000-01-03,Aurora,catamaran,5.0,1.65,17,1,16.00,"Arrived late, ""quiet"" night" +2000-01-03,Etoile,motor yacht,13.0,4.29,53,4,115.20,Café voucher given +2000-01-03,La Mouette,sloop,7.9,2.61,9,2,41.28,"Arrived late, ""quiet"" night" +2000-01-03,Jolie Brise,sloop,10.0,3.3,18,5,120.00,"Arrived late, ""quiet"" night" +2000-01-04,La Mouette,sloop,15.0,4.95,5,2,64.00,Bill sent; paid by cheque +2000-01-04,Aurora,sloop,9.2,3.04,12,2,45.44,Bill sent; paid by cheque +2000-01-04,Calypso,sloop,8.7,2.87,24,5,109.60, +2000-01-04,Kerguelen,dinghy,14.7,4.85,45,3,94.56, +2000-01-04,Jolie Brise,sloop,11.2,3.7,60,2,51.84, +2000-01-04,La Mouette,trawler,10.0,3.3,3,3,72.00, +2000-01-04,Hirondelle,sloop,11.4,3.76,47,1,26.24,Bill sent; paid by cheque +2000-01-04,Brise,motor yacht,8.2,2.71,35,1,21.12,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-04,Fauvette,trawler,13.0,4.29,6,2,57.60,Café voucher given +2000-01-04,Aurora,motor yacht,14.9,4.92,37,4,127.36, +2000-01-05,Goeland,catamaran,11.7,3.86,1,4,106.88, +2000-01-05,Etoile 3,motor yacht,5.4,1.78,47,1,16.64,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-05,Hirondelle 3,motor yacht,9.0,2.97,30,5,112.00, +2000-01-05,Aurora 3,trawler,5.5,1.82,39,5,84.00, +2000-01-05,La Mouette 3,sloop,10.0,3.3,32,2,48.00,Café voucher given +2000-01-05,Fauvette 3,trawler,7.4,2.44,10,1,19.84,"Arrived late, ""quiet"" night" +2000-01-05,Kerguelen 3,motor yacht,10.5,3.47,32,3,74.40,"Arrived late, ""quiet"" night" +2000-01-05,Dorade 3,dinghy,8.3,2.74,28,3,63.84,"Arrived late, ""quiet"" night" +2000-01-05,Dorade 3,catamaran,14.6,4.82,59,4,125.44,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-05,Hirondelle 3,trawler,6.9,2.28,54,2,38.08, +2000-01-06,Calypso 3,sloop,12.7,4.19,29,3,84.96,Café voucher given +2000-01-06,Goeland 3,motor yacht,7.7,2.54,41,2,40.64, +2000-01-06,La Mouette 3,catamaran,10.2,3.37,29,3,72.96,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-06,Etoile 3,catamaran,5.4,1.78,3,1,16.64,Bill sent; paid by cheque +2000-01-06,Kerguelen 3,catamaran,11.1,3.66,2,3,77.28, +2000-01-06,Aurora 3,trawler,9.2,3.04,16,2,45.44, +2000-01-06,Hirondelle 3,sloop,13.7,4.52,41,1,29.92, +2000-01-06,Brise 3,dinghy,12.0,3.96,13,3,81.60,"Arrived late, ""quiet"" night" +2000-01-06,Calypso 3,dinghy,14.0,4.62,1,3,91.20,Café voucher given +2000-01-06,Calypso 3,ketch,10.7,3.53,18,1,25.12,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-07,Iroise 3,catamaran,10.7,3.53,49,5,125.60, +2000-01-07,Brise 3,motor yacht,9.0,2.97,60,2,44.80,"Two nights, +left at dawn" +2000-01-07,Dorade 3,dinghy,6.0,1.98,57,4,70.40, +2000-01-07,Kerguelen 3,catamaran,8.8,2.9,51,5,110.40,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-07,Brise 3,dinghy,9.8,3.23,56,2,47.36, +2000-01-07,Brise 3,trawler,7.0,2.31,35,4,76.80,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-07,Fauvette 3,catamaran,7.5,2.48,6,1,20.00, +2000-01-07,Goeland 3,trawler,9.6,3.17,32,5,116.80, +2000-01-07,Goeland 3,sloop,7.0,2.31,12,1,19.20, +2000-01-07,Hirondelle 3,ketch,8.4,2.77,34,2,42.88, +2000-01-08,Hirondelle 3,catamaran,14.2,4.69,45,4,122.88, +2000-01-08,Iroise 3,catamaran,13.2,4.36,19,4,116.48, +2000-01-08,Iroise 3,catamaran,11.8,3.89,17,5,134.40,Bill sent; paid by cheque +2000-01-08,Dorade 3,trawler,12.2,4.03,55,5,137.60, +2000-01-08,Etoile 3,catamaran,15.6,5.15,32,5,164.80,"Arrived late, ""quiet"" night" +2000-01-08,Iroise 3,sloop,9.2,3.04,39,1,22.72,"Two nights, +left at dawn" +2000-01-08,Iroise 3,motor yacht,4.8,1.58,35,1,15.68, +2000-01-08,Brise 3,catamaran,10.1,3.33,30,2,48.32,"Two nights, +left at dawn" +2000-01-08,Etoile 3,ketch,15.1,4.98,31,4,128.64,Café voucher given +2000-01-08,Hirondelle 3,catamaran,6.0,1.98,21,4,70.40, +2000-01-09,Aurora 4,trawler,6.7,2.21,9,5,93.60, +2000-01-09,Dorade 4,sloop,4.9,1.62,44,2,31.68,Bill sent; paid by cheque +2000-01-09,Dorade 4,catamaran,8.2,2.71,32,3,63.36, +2000-01-09,Brise 4,catamaran,10.2,3.37,40,5,121.60,Bill sent; paid by cheque +2000-01-09,Goeland 4,sloop,12.8,4.22,25,5,142.40,"Two nights, +left at dawn" +2000-01-09,Calypso 4,catamaran,14.0,4.62,14,5,152.00, +2000-01-09,Dorade 4,catamaran,11.8,3.89,40,5,134.40, +2000-01-09,Calypso 4,trawler,7.2,2.38,39,3,58.56,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-09,Dorade 4,ketch,14.7,4.85,57,2,63.04, +2000-01-09,Calypso 4,dinghy,6.0,1.98,6,2,35.20,Bill sent; paid by cheque +2000-01-10,Goeland 4,catamaran,9.0,2.97,51,4,89.60, +2000-01-10,Brise 4,ketch,6.8,2.24,37,1,18.88,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-10,Brise 4,dinghy,15.0,4.95,33,3,96.00,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-10,Brise 4,motor yacht,10.3,3.4,7,2,48.96, +2000-01-10,Jolie Brise 4,catamaran,4.9,1.62,58,5,79.20,Café voucher given +2000-01-10,Brise 4,ketch,6.2,2.05,12,2,35.84, +2000-01-10,Jolie Brise 4,sloop,15.9,5.25,9,1,33.44,Café voucher given +2000-01-10,Fauvette 4,motor yacht,5.4,1.78,30,1,16.64,"Two nights, +left at dawn" +2000-01-10,Dorade 4,catamaran,10.4,3.43,24,4,98.56, +2000-01-10,Dorade 4,ketch,11.8,3.89,27,1,26.88,Café voucher given +2000-01-11,Goeland 4,ketch,8.6,2.84,39,3,65.28, +2000-01-11,Fauvette 4,sloop,5.6,1.85,52,1,16.96,"Two nights, +left at dawn" +2000-01-11,Iroise 4,catamaran,11.1,3.66,22,2,51.52,Bill sent; paid by cheque +2000-01-11,La Mouette 4,dinghy,7.7,2.54,50,1,20.32, +2000-01-11,Calypso 4,trawler,13.9,4.59,57,4,120.96, +2000-01-11,Calypso 4,motor yacht,12.5,4.12,14,4,112.00, +2000-01-11,Etoile 4,motor yacht,5.3,1.75,7,3,49.44, +2000-01-11,Aurora 4,ketch,14.7,4.85,18,5,157.60,Bill sent; paid by cheque +2000-01-11,Jolie Brise 4,ketch,8.4,2.77,35,4,85.76, +2000-01-11,Kerguelen 4,ketch,9.0,2.97,45,4,89.60, +2000-01-12,Aurora 4,sloop,13.3,4.39,40,5,146.40, +2000-01-12,Hirondelle 4,motor yacht,13.0,4.29,33,3,86.40, +2000-01-12,Aurora 4,sloop,13.3,4.39,17,4,117.12,Bill sent; paid by cheque +2000-01-12,Iroise 4,catamaran,7.7,2.54,33,3,60.96,"Two nights, +left at dawn" +2000-01-12,Brise 4,dinghy,15.6,5.15,29,5,164.80, +2000-01-12,Goeland 4,ketch,10.8,3.56,25,1,25.28,Café voucher given +2000-01-12,Iroise 4,catamaran,15.7,5.18,39,4,132.48, +2000-01-12,Goeland 4,trawler,9.4,3.1,34,4,92.16,"Two nights, +left at dawn" +2000-01-12,Iroise 4,trawler,11.4,3.76,31,3,78.72,Bill sent; paid by cheque +2000-01-12,Kerguelen 4,dinghy,10.9,3.6,48,3,76.32,Bill sent; paid by cheque +2000-01-13,Jolie Brise 5,sloop,4.8,1.58,41,2,31.36, +2000-01-13,Aurora 5,trawler,9.2,3.04,48,4,90.88,"Two nights, +left at dawn" +2000-01-13,Brise 5,ketch,5.1,1.68,28,3,48.48,Café voucher given +2000-01-13,Aurora 5,catamaran,7.4,2.44,7,2,39.68,Café voucher given +2000-01-13,Jolie Brise 5,dinghy,6.2,2.05,8,2,35.84, +2000-01-13,Brise 5,ketch,11.7,3.86,56,4,106.88, +2000-01-13,Brise 5,trawler,8.6,2.84,29,2,43.52, +2000-01-13,Jolie Brise 5,trawler,12.2,4.03,41,4,110.08,"Two nights, +left at dawn" +2000-01-13,Calypso 5,trawler,8.7,2.87,47,5,109.60, +2000-01-13,Iroise 5,trawler,7.2,2.38,51,1,19.52, +2000-01-14,Etoile 5,trawler,11.7,3.86,40,3,80.16,"Arrived late, ""quiet"" night" +2000-01-14,Dorade 5,ketch,11.8,3.89,33,2,53.76,"Arrived late, ""quiet"" night" +2000-01-14,Kerguelen 5,sloop,14.6,4.82,1,1,31.36,Bill sent; paid by cheque +2000-01-14,Jolie Brise 5,sloop,7.5,2.48,7,1,20.00,"Arrived late, ""quiet"" night" +2000-01-14,Brise 5,dinghy,10.7,3.53,11,1,25.12, +2000-01-14,Hirondelle 5,motor yacht,7.2,2.38,23,3,58.56,"Arrived late, ""quiet"" night" +2000-01-14,La Mouette 5,ketch,8.4,2.77,54,4,85.76, +2000-01-14,Jolie Brise 5,ketch,13.7,4.52,29,4,119.68,"Two nights, +left at dawn" +2000-01-14,Calypso 5,trawler,13.1,4.32,10,4,115.84,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-14,Goeland 5,dinghy,7.8,2.57,24,2,40.96, +2000-01-15,Brise 5,catamaran,9.8,3.23,6,3,71.04,"Two nights, +left at dawn" +2000-01-15,Kerguelen 5,motor yacht,11.0,3.63,20,4,102.40, +2000-01-15,Kerguelen 5,catamaran,5.4,1.78,6,2,33.28,Bill sent; paid by cheque +2000-01-15,Etoile 5,ketch,10.2,3.37,15,3,72.96,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-15,Fauvette 5,sloop,12.1,3.99,21,3,82.08,Café voucher given +2000-01-15,Hirondelle 5,catamaran,11.0,3.63,52,3,76.80,Bill sent; paid by cheque +2000-01-15,Goeland 5,dinghy,9.8,3.23,58,2,47.36, +2000-01-15,Fauvette 5,sloop,7.4,2.44,24,3,59.52,"Two nights, +left at dawn" +2000-01-15,La Mouette 5,catamaran,11.2,3.7,24,2,51.84,"Two nights, +left at dawn" +2000-01-15,Jolie Brise 5,ketch,10.7,3.53,12,5,125.60, +2000-01-16,Aurora 5,ketch,13.2,4.36,34,3,87.36, +2000-01-16,Etoile 5,sloop,10.8,3.56,42,5,126.40,"Two nights, +left at dawn" +2000-01-16,Etoile 5,catamaran,5.2,1.72,6,5,81.60,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-16,Fauvette 5,sloop,5.4,1.78,2,1,16.64, +2000-01-16,Dorade 5,dinghy,5.6,1.85,15,4,67.84,Café voucher given +2000-01-16,Fauvette 5,motor yacht,13.3,4.39,26,5,146.40,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-16,Kerguelen 5,ketch,8.4,2.77,32,3,64.32,Café voucher given +2000-01-16,La Mouette 5,catamaran,10.7,3.53,59,1,25.12, +2000-01-16,Aurora 5,motor yacht,9.2,3.04,54,3,68.16, +2000-01-16,Kerguelen 5,sloop,14.7,4.85,34,5,157.60,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-17,Goeland 6,motor yacht,12.7,4.19,59,1,28.32, +2000-01-17,Kerguelen 6,motor yacht,7.3,2.41,44,2,39.36, +2000-01-17,Goeland 6,catamaran,8.2,2.71,19,4,84.48,Bill sent; paid by cheque +2000-01-17,Hirondelle 6,trawler,15.7,5.18,47,1,33.12, +2000-01-17,Calypso 6,sloop,8.0,2.64,49,2,41.60,Café voucher given +2000-01-17,Aurora 6,trawler,4.7,1.55,7,4,62.08,"Arrived late, ""quiet"" night" +2000-01-17,Jolie Brise 6,ketch,6.6,2.18,39,2,37.12,Café voucher given +2000-01-17,Hirondelle 6,ketch,11.3,3.73,58,2,52.16,Bill sent; paid by cheque +2000-01-17,Kerguelen 6,catamaran,9.1,3.0,53,3,67.68, +2000-01-17,Hirondelle 6,motor yacht,10.0,3.3,59,3,72.00,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-18,Hirondelle 6,ketch,12.1,3.99,26,1,27.36, +2000-01-18,Goeland 6,catamaran,7.0,2.31,51,2,38.40, +2000-01-18,Kerguelen 6,ketch,13.4,4.42,58,1,29.44, +2000-01-18,Hirondelle 6,ketch,14.6,4.82,46,3,94.08,"Two nights, +left at dawn" +2000-01-18,Goeland 6,ketch,11.5,3.8,50,2,52.80,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-18,Calypso 6,dinghy,6.2,2.05,48,5,89.60,Bill sent; paid by cheque +2000-01-18,Calypso 6,catamaran,10.4,3.43,50,1,24.64, +2000-01-18,Calypso 6,sloop,4.5,1.49,23,2,30.40,Bill sent; paid by cheque +2000-01-18,Brise 6,motor yacht,11.6,3.83,45,5,132.80,Café voucher given +2000-01-18,Iroise 6,catamaran,5.5,1.82,20,5,84.00, +2000-01-19,Dorade 6,dinghy,13.6,4.49,6,4,119.04,Café voucher given +2000-01-19,Goeland 6,catamaran,6.9,2.28,32,5,95.20, +2000-01-19,Fauvette 6,trawler,15.4,5.08,26,3,97.92, +2000-01-19,Kerguelen 6,dinghy,10.9,3.6,5,4,101.76,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-19,Brise 6,dinghy,5.4,1.78,26,2,33.28,Café voucher given +2000-01-19,Kerguelen 6,motor yacht,13.1,4.32,1,1,28.96,"Two nights, +left at dawn" +2000-01-19,Dorade 6,trawler,12.7,4.19,37,4,113.28, +2000-01-19,La Mouette 6,ketch,9.8,3.23,58,1,23.68, +2000-01-19,Iroise 6,dinghy,7.8,2.57,20,4,81.92,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-19,Kerguelen 6,sloop,13.2,4.36,17,5,145.60,"Arrived late, ""quiet"" night" +2000-01-20,Fauvette 6,trawler,6.8,2.24,18,1,18.88,Café voucher given +2000-01-20,Hirondelle 6,motor yacht,12.8,4.22,6,3,85.44, +2000-01-20,Calypso 6,dinghy,6.1,2.01,41,1,17.76, +2000-01-20,Aurora 6,motor yacht,6.6,2.18,38,4,74.24, +2000-01-20,Calypso 6,sloop,5.7,1.88,28,1,17.12,"Two nights, +left at dawn" +2000-01-20,Goeland 6,catamaran,13.4,4.42,9,3,88.32,"Arrived late, ""quiet"" night" +2000-01-20,Hirondelle 6,trawler,13.3,4.39,59,4,117.12,"Two nights, +left at dawn" +2000-01-20,Jolie Brise 6,dinghy,11.7,3.86,21,4,106.88,"Arrived late, ""quiet"" night" +2000-01-20,Iroise 6,sloop,12.1,3.99,17,5,136.80,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-20,Kerguelen 6,motor yacht,6.7,2.21,7,3,56.16, +2000-01-21,Fauvette 7,trawler,8.8,2.9,53,5,110.40, +2000-01-21,Etoile 7,catamaran,11.5,3.8,58,2,52.80,Bill sent; paid by cheque +2000-01-21,Jolie Brise 7,catamaran,10.8,3.56,33,3,75.84, +2000-01-21,Iroise 7,dinghy,11.1,3.66,46,2,51.52,"Arrived late, ""quiet"" night" +2000-01-21,Kerguelen 7,dinghy,11.0,3.63,13,1,25.60, +2000-01-21,Jolie Brise 7,motor yacht,6.1,2.01,45,4,71.04,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-21,Etoile 7,motor yacht,8.3,2.74,57,3,63.84, +2000-01-21,Dorade 7,catamaran,9.7,3.2,35,2,47.04, +2000-01-21,Etoile 7,ketch,10.9,3.6,59,5,127.20,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-21,Aurora 7,trawler,11.4,3.76,44,1,26.24,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-22,Fauvette 7,ketch,7.3,2.41,12,5,98.40, +2000-01-22,Aurora 7,motor yacht,13.8,4.55,12,4,120.32, +2000-01-22,Jolie Brise 7,catamaran,8.0,2.64,41,4,83.20, +2000-01-22,Hirondelle 7,sloop,6.3,2.08,10,4,72.32, +2000-01-22,Goeland 7,ketch,8.9,2.94,22,5,111.20, +2000-01-22,Jolie Brise 7,trawler,6.0,1.98,4,2,35.20, +2000-01-22,Goeland 7,motor yacht,12.8,4.22,39,2,56.96, +2000-01-22,Jolie Brise 7,dinghy,9.9,3.27,37,4,95.36,"Two nights, +left at dawn" +2000-01-22,Hirondelle 7,dinghy,13.1,4.32,27,4,115.84,"Two nights, +left at dawn" +2000-01-22,Iroise 7,trawler,5.8,1.91,34,1,17.28, +2000-01-23,Fauvette 7,sloop,9.5,3.14,19,1,23.20,"Two nights, +left at dawn" +2000-01-23,Fauvette 7,trawler,12.9,4.26,57,5,143.20, +2000-01-23,Dorade 7,catamaran,7.3,2.41,56,4,78.72,"Two nights, +left at dawn" +2000-01-23,La Mouette 7,sloop,15.7,5.18,34,4,132.48,"Arrived late, ""quiet"" night" +2000-01-23,Dorade 7,sloop,10.8,3.56,59,4,101.12,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-23,Jolie Brise 7,ketch,14.5,4.79,15,4,124.80,"Two nights, +left at dawn" +2000-01-23,Brise 7,catamaran,11.0,3.63,51,1,25.60, +2000-01-23,Jolie Brise 7,motor yacht,5.9,1.95,1,5,87.20,Bill sent; paid by cheque +2000-01-23,Aurora 7,trawler,7.3,2.41,12,3,59.04, +2000-01-23,Iroise 7,motor yacht,9.6,3.17,10,4,93.44,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-24,Aurora 7,trawler,12.5,4.12,2,2,56.00,Café voucher given +2000-01-24,Fauvette 7,motor yacht,14.8,4.88,10,3,95.04,Café voucher given +2000-01-24,Etoile 7,dinghy,14.6,4.82,58,2,62.72, +2000-01-24,Hirondelle 7,sloop,6.7,2.21,12,3,56.16, +2000-01-24,Dorade 7,sloop,6.8,2.24,42,2,37.76, +2000-01-24,Etoile 7,motor yacht,8.0,2.64,46,3,62.40, +2000-01-24,Goeland 7,sloop,8.4,2.77,11,4,85.76,"Arrived late, ""quiet"" night" +2000-01-24,La Mouette 7,dinghy,14.4,4.75,13,1,31.04,Bill sent; paid by cheque +2000-01-24,Iroise 7,dinghy,6.6,2.18,19,2,37.12,Bill sent; paid by cheque +2000-01-24,Brise 7,catamaran,8.8,2.9,34,5,110.40,"Arrived late, ""quiet"" night" +2000-01-25,Kerguelen 8,ketch,15.7,5.18,18,2,66.24, +2000-01-25,Hirondelle 8,sloop,11.9,3.93,29,1,27.04, +2000-01-25,Aurora 8,catamaran,5.3,1.75,38,1,16.48,Bill sent; paid by cheque +2000-01-25,Jolie Brise 8,sloop,10.7,3.53,11,4,100.48,Bill sent; paid by cheque +2000-01-25,Dorade 8,ketch,15.9,5.25,18,2,66.88, +2000-01-25,Aurora 8,catamaran,4.8,1.58,30,2,31.36,"Two nights, +left at dawn" +2000-01-25,Aurora 8,ketch,4.5,1.49,5,4,60.80, +2000-01-25,Fauvette 8,dinghy,13.4,4.42,31,3,88.32, +2000-01-25,Kerguelen 8,dinghy,10.4,3.43,34,5,123.20, +2000-01-25,Brise 8,catamaran,6.5,2.15,28,2,36.80,"Arrived late, ""quiet"" night" +2000-01-26,Etoile 8,dinghy,12.2,4.03,51,5,137.60,"Arrived late, ""quiet"" night" +2000-01-26,Dorade 8,catamaran,7.5,2.48,39,5,100.00,Café voucher given +2000-01-26,Jolie Brise 8,dinghy,7.5,2.48,4,2,40.00,Café voucher given +2000-01-26,Fauvette 8,ketch,14.5,4.79,39,2,62.40, +2000-01-26,Iroise 8,motor yacht,11.5,3.8,47,4,105.60,Bill sent; paid by cheque +2000-01-26,Brise 8,sloop,14.4,4.75,59,1,31.04, +2000-01-26,Jolie Brise 8,dinghy,11.0,3.63,16,5,128.00, +2000-01-26,Aurora 8,dinghy,5.1,1.68,21,4,64.64, +2000-01-26,Hirondelle 8,sloop,13.5,4.46,42,5,148.00, +2000-01-26,Dorade 8,ketch,6.5,2.15,32,3,55.20,Café voucher given +2000-01-27,Hirondelle 8,trawler,5.2,1.72,45,4,65.28,"Arrived late, ""quiet"" night" +2000-01-27,Calypso 8,motor yacht,12.6,4.16,49,1,28.16, +2000-01-27,Iroise 8,dinghy,6.2,2.05,3,4,71.68, +2000-01-27,Calypso 8,sloop,7.3,2.41,26,5,98.40, +2000-01-27,Brise 8,sloop,7.9,2.61,44,4,82.56,"Arrived late, ""quiet"" night" +2000-01-27,Iroise 8,ketch,7.9,2.61,27,1,20.64,Café voucher given +2000-01-27,Fauvette 8,trawler,5.5,1.82,22,2,33.60,"Arrived late, ""quiet"" night" +2000-01-27,Jolie Brise 8,dinghy,5.0,1.65,4,4,64.00,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-27,Calypso 8,motor yacht,11.0,3.63,56,5,128.00,"Two nights, +left at dawn" +2000-01-27,Goeland 8,motor yacht,15.3,5.05,51,2,64.96, +2000-01-28,Dorade 8,ketch,7.0,2.31,51,3,57.60, +2000-01-28,Dorade 8,motor yacht,13.7,4.52,14,4,119.68,Café voucher given +2000-01-28,Hirondelle 8,motor yacht,13.2,4.36,41,4,116.48, +2000-01-28,Iroise 8,motor yacht,14.4,4.75,27,2,62.08, +2000-01-28,Fauvette 8,trawler,5.0,1.65,40,3,48.00, +2000-01-28,Iroise 8,motor yacht,10.5,3.47,37,3,74.40,"Arrived late, ""quiet"" night" +2000-01-28,Aurora 8,ketch,7.6,2.51,47,3,60.48,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-28,La Mouette 8,sloop,6.1,2.01,2,2,35.52,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-01-28,Brise 8,motor yacht,14.1,4.65,21,4,122.24,Bill sent; paid by cheque +2000-01-28,Kerguelen 8,sloop,13.4,4.42,20,5,147.20,"Two nights, +left at dawn" +2000-02-01,Aurora 9,ketch,11.5,3.8,10,4,105.60, +2000-02-01,Dorade 9,trawler,11.3,3.73,27,1,26.08,"Two nights, +left at dawn" +2000-02-01,Iroise 9,dinghy,10.0,3.3,36,2,48.00,Bill sent; paid by cheque +2000-02-01,Kerguelen 9,motor yacht,8.1,2.67,10,4,83.84,"Arrived late, ""quiet"" night" +2000-02-01,La Mouette 9,motor yacht,14.1,4.65,26,5,152.80, +2000-02-01,Dorade 9,sloop,8.9,2.94,19,3,66.72,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-02-01,Calypso 9,sloop,8.5,2.81,55,2,43.20, +2000-02-01,Jolie Brise 9,sloop,13.1,4.32,52,4,115.84,Bill sent; paid by cheque +2000-02-01,Dorade 9,catamaran,12.1,3.99,45,1,27.36,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-02-01,Dorade 9,dinghy,12.2,4.03,17,3,82.56, +2000-02-02,Goeland 9,motor yacht,13.7,4.52,4,1,29.92,Café voucher given +2000-02-02,Etoile 9,motor yacht,8.9,2.94,46,3,66.72,Bill sent; paid by cheque +2000-02-02,Dorade 9,motor yacht,7.0,2.31,50,4,76.80, +2000-02-02,Iroise 9,sloop,8.5,2.81,48,2,43.20,"Owner: M. Le Gall, tel. 02 98 00 00 00" +2000-02-02,Hirondelle 9,catamaran,14.9,4.92,17,2,63.68, +2000-02-02,Goeland 9,sloop,8.0,2.64,24,1,20.80, +2000-02-02,Aurora 9,motor yacht,15.0,4.95,32,3,96.00,Bill sent; paid by cheque +2000-02-02,Etoile 9,sloop,9.9,3.27,2,3,71.52, +2000-02-02,Jolie Brise 9,trawler,8.5,2.81,17,3,64.80, +2000-02-02,Jolie Brise 9,trawler,11.9,3.93,58,4,108.16,"Owner: M. Le Gall, tel. 02 98 00 00 00" diff --git a/crates/exav-viewer/demo/examples/files/text/life.py b/crates/exav-viewer/demo/examples/files/text/life.py new file mode 100644 index 00000000..b5942abc --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/life.py @@ -0,0 +1,34 @@ +"""Conway's Game of Life on a torus, printed as text. CC0-1.0, exav project.""" +import random +import sys + +WIDTH, HEIGHT = 40, 16 + + +def step(cells): + """The next generation of a set of (x, y) cells on a WIDTH by HEIGHT torus.""" + counts = {} + for (x, y) in cells: + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + if dx or dy: + key = ((x + dx) % WIDTH, (y + dy) % HEIGHT) + counts[key] = counts.get(key, 0) + 1 + return {cell for cell, n in counts.items() if n == 3 or (n == 2 and cell in cells)} + + +def show(cells): + return "\n".join("".join("#" if (x, y) in cells else "." for x in range(WIDTH)) for y in range(HEIGHT)) + + +def main(generations=20, seed=7): + rng = random.Random(seed) + cells = {(x, y) for x in range(WIDTH) for y in range(HEIGHT) if rng.random() < 0.25} + for g in range(generations): + print(f"generation {g}: {len(cells)} cells") + print(show(cells)) + cells = step(cells) + + +if __name__ == "__main__": + main(*(int(a) for a in sys.argv[1:3])) diff --git a/crates/exav-viewer/demo/examples/files/text/lru.rs b/crates/exav-viewer/demo/examples/files/text/lru.rs new file mode 100644 index 00000000..da9bfd9d --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/lru.rs @@ -0,0 +1,57 @@ +//! A small least-recently-used cache. CC0-1.0, exav project. +use std::collections::HashMap; +use std::hash::Hash; + +/// A cache that keeps at most `capacity` entries and drops the one used longest ago. +pub struct Lru { + capacity: usize, + tick: u64, + entries: HashMap, +} + +impl Lru { + pub fn new(capacity: usize) -> Self { + assert!(capacity > 0, "a cache holds at least one entry"); + Lru { capacity, tick: 0, entries: HashMap::with_capacity(capacity) } + } + + pub fn get(&mut self, key: &K) -> Option<&V> { + self.tick += 1; + let tick = self.tick; + self.entries.get_mut(key).map(|(v, used)| { + *used = tick; + &*v + }) + } + + pub fn put(&mut self, key: K, value: V) -> Option { + self.tick += 1; + if !self.entries.contains_key(&key) && self.entries.len() == self.capacity { + let oldest = self.entries.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()); + if let Some(k) = oldest { + self.entries.remove(&k); + } + } + self.entries.insert(key, (value, self.tick)).map(|(v, _)| v) + } + + pub fn len(&self) -> usize { + self.entries.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drops_the_least_recently_used() { + let mut c = Lru::new(2); + c.put("a", 1); + c.put("b", 2); + assert_eq!(c.get(&"a"), Some(&1)); + c.put("c", 3); + assert_eq!(c.get(&"b"), None); + assert_eq!(c.len(), 2); + } +} diff --git a/crates/exav-viewer/demo/examples/files/text/page.html b/crates/exav-viewer/demo/examples/files/text/page.html new file mode 100644 index 00000000..566f2406 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/page.html @@ -0,0 +1,28 @@ + + + + + A page, shown as its source + + + + + + +

    Harbour office

    +
    +
    +

    Berths

    + + + +
    BerthLength
    16.5 m
    28.2 m
    +

    Lengths are over all, in metres.

    +
    +
    +
    + + + diff --git a/crates/exav-viewer/demo/examples/files/text/places.kml b/crates/exav-viewer/demo/examples/files/text/places.kml new file mode 100644 index 00000000..503b0380 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/places.kml @@ -0,0 +1,22 @@ + + + + Places (made up) + + + East quay + Moorings
    for 60 boats.]]> + #harbour + -4.4860,48.3830,0 + + + Breakwater + 1-4.4900,48.3790,0 -4.4850,48.3820,0 -4.4800,48.3835,0 + + + Anchorage + #harbour + -4.4950,48.3850,0 -4.4900,48.3850,0 -4.4900,48.3900,0 -4.4950,48.3900,0 -4.4950,48.3850,0 + + + diff --git a/crates/exav-viewer/demo/examples/files/text/prix.csv b/crates/exav-viewer/demo/examples/files/text/prix.csv new file mode 100644 index 00000000..1ceb0b02 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/prix.csv @@ -0,0 +1,7 @@ +Région;Produit;Quantité;Prix unitaire (€);Total (€) +Bretagne;Cidre;120;3,50;420,00 +Bretagne;Galettes;300;1,20;360,00 +Normandie;Cidre;90;3,80;342,00 +Normandie;Camembert;200;4,10;820,00 +Alsace;Riesling;150;7,90;1185,00 +Alsace;Bretzels;500;0,60;300,00 diff --git a/crates/exav-viewer/demo/examples/files/text/pyproject.toml b/crates/exav-viewer/demo/examples/files/text/pyproject.toml new file mode 100644 index 00000000..54ae757b --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/pyproject.toml @@ -0,0 +1,22 @@ +# A project file, to show TOML as text. +[project] +name = "harbour-register" +version = "1.4.0" +description = "The mooring register of the east quay" +requires-python = ">=3.11" +dependencies = ["httpx>=0.27", "pydantic>=2"] + +[project.optional-dependencies] +test = ["pytest", "pytest-cov"] + +[tool.ruff] +line-length = 100 +select = ["E", "F", "I"] + +[[tool.harbour.berths]] +number = 1 +length = 6.5 + +[[tool.harbour.berths]] +number = 2 +length = 8.2 diff --git a/crates/exav-viewer/demo/examples/files/text/recipes.json b/crates/exav-viewer/demo/examples/files/text/recipes.json new file mode 100644 index 00000000..72c9a84e --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/recipes.json @@ -0,0 +1,133 @@ +{ + "title": "A small recipe book", + "version": 3, + "published": true, + "updated": null, + "tags": [ + "cooking", + "demo", + "café", + "日本語" + ], + "numbers": { + "integer": 42, + "negative": -7, + "float": 3.14159, + "exponent": 6.02e+23, + "tiny": 1e-09, + "zero": 0 + }, + "recipes": [ + { + "name": "Crepes", + "serves": 4, + "time": { + "prep_min": 10, + "rest_min": 30, + "cook_min": 20 + }, + "ingredients": [ + { + "item": "flour", + "amount": 250, + "unit": "g" + }, + { + "item": "milk", + "amount": 0.5, + "unit": "l" + }, + { + "item": "eggs", + "amount": 3, + "unit": null + }, + { + "item": "salt", + "amount": "a pinch" + } + ], + "steps": [ + "Whisk the flour and eggs.", + "Add the milk slowly.", + "Rest for 30 minutes.", + "Cook in a hot pan, a minute a side." + ], + "nutrition": { + "kcal": 210, + "protein_g": 8.5, + "vegetarian": true + } + }, + { + "name": "Ratatouille", + "serves": 6, + "time": { + "prep_min": 25, + "cook_min": 60 + }, + "ingredients": [ + { + "item": "aubergine", + "amount": 2 + }, + { + "item": "courgette", + "amount": 3 + }, + { + "item": "tomatoes", + "amount": 500, + "unit": "g" + }, + { + "item": "olive oil", + "amount": 4, + "unit": "tbsp" + } + ], + "steps": [ + "Dice the vegetables.", + "Fry each in turn.", + "Stew together for an hour." + ], + "nutrition": { + "kcal": 160, + "protein_g": 3.1, + "vegetarian": true, + "vegan": true + } + } + ], + "nested": { + "a": { + "b": { + "c": { + "d": { + "e": [ + 1, + [ + 2, + [ + 3, + [ + 4, + [ + 5 + ] + ] + ] + ] + ] + } + } + } + } + }, + "escapes": "quote \" backslash \\ slash / newline \n tab \t unicode é 中 😀", + "empty": { + "object": {}, + "array": [], + "string": "" + } +} diff --git a/crates/exav-viewer/demo/examples/files/text/report.sql b/crates/exav-viewer/demo/examples/files/text/report.sql new file mode 100644 index 00000000..8bc1a099 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/report.sql @@ -0,0 +1,43 @@ +-- Moorings by month, with window functions. CC0-1.0, exav project. +CREATE TABLE vessel ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + length_m REAL NOT NULL CHECK (length_m > 0), + kind TEXT NOT NULL DEFAULT 'sloop' +); + +CREATE TABLE stay ( + id INTEGER PRIMARY KEY, + vessel_id INTEGER NOT NULL REFERENCES vessel (id), + berth INTEGER NOT NULL, + arrived DATE NOT NULL, + nights INTEGER NOT NULL, + fee_eur NUMERIC(8, 2) +); + +CREATE INDEX stay_by_arrival ON stay (arrived); + +WITH monthly AS ( + SELECT strftime('%Y-%m', arrived) AS month, + count(*) AS stays, + sum(nights) AS nights, + sum(fee_eur) AS income + FROM stay + GROUP BY 1 +) +SELECT month, + stays, + income, + income - lag(income) OVER (ORDER BY month) AS change, + round(100.0 * income / sum(income) OVER (), 1) AS share_pct, + rank() OVER (ORDER BY income DESC) AS income_rank +FROM monthly +ORDER BY month; + +-- The ten longest boats that stayed more than three nights. +SELECT v.name, v.length_m, s.nights +FROM vessel AS v +JOIN stay AS s ON s.vessel_id = v.id +WHERE s.nights > 3 AND v.kind IN ('ketch', 'trawler') +ORDER BY v.length_m DESC +LIMIT 10; diff --git a/crates/exav-viewer/demo/examples/files/text/route.gpx b/crates/exav-viewer/demo/examples/files/text/route.gpx new file mode 100644 index 00000000..0daf6321 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/route.gpx @@ -0,0 +1,71 @@ + + + Coastal walk (made up)A made-up track, 60 points. + StartFlag + ViewpointSeals on the rocks, in winter. + + Morning walk + + 20.0 + 22.4 + 24.8 + 27.1 + 29.3 + 31.2 + 32.9 + 34.3 + 35.4 + 36.2 + 36.7 + 36.8 + 36.5 + 35.9 + 35.0 + 33.8 + 32.4 + 30.7 + 28.8 + 26.8 + 24.7 + 22.6 + 20.5 + 18.5 + 16.6 + 14.9 + 13.4 + 12.1 + 11.2 + 10.5 + 10.2 + 10.3 + 10.6 + 11.4 + 12.4 + 13.8 + 15.4 + 17.3 + 19.4 + 21.7 + 24.1 + 26.5 + 29.0 + 31.4 + 33.7 + 35.9 + 37.8 + 39.6 + 41.1 + 42.2 + 43.1 + 43.6 + 43.8 + 43.6 + 43.1 + 42.2 + 41.1 + 39.7 + 38.0 + 36.2 + + + diff --git a/crates/exav-viewer/demo/examples/files/text/server.log b/crates/exav-viewer/demo/examples/files/text/server.log new file mode 100644 index 00000000..85849460 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/server.log @@ -0,0 +1,120 @@ +2000-01-01T08:00:00Z INFO request id=1000 path=/berths/1 status=200 ms=12 +2000-01-01T08:01:07Z INFO request id=1001 path=/berths/2 status=200 ms=49 +2000-01-01T08:02:14Z INFO request id=1002 path=/berths/3 status=304 ms=86 +2000-01-01T08:03:21Z WARN request id=1003 path=/berths/4 status=404 ms=123 +2000-01-01T08:04:28Z ERROR request id=1004 path=/berths/5 status=500 ms=160 +2000-01-01T08:05:35Z INFO request id=1005 path=/berths/6 status=200 ms=197 +2000-01-01T08:06:42Z INFO request id=1006 path=/berths/7 status=200 ms=234 +2000-01-01T08:07:49Z INFO request id=1007 path=/berths/8 status=304 ms=21 +2000-01-01T08:08:56Z WARN request id=1008 path=/berths/9 status=404 ms=58 +2000-01-01T08:09:03Z ERROR request id=1009 path=/berths/10 status=500 ms=95 +2000-01-01T08:10:10Z INFO request id=1010 path=/berths/11 status=200 ms=132 +2000-01-01T08:11:17Z INFO request id=1011 path=/berths/12 status=200 ms=169 +2000-01-01T08:12:24Z INFO request id=1012 path=/berths/13 status=304 ms=206 +2000-01-01T08:13:31Z WARN request id=1013 path=/berths/14 status=404 ms=243 +2000-01-01T08:14:38Z ERROR request id=1014 path=/berths/15 status=500 ms=30 +2000-01-01T08:15:45Z INFO request id=1015 path=/berths/16 status=200 ms=67 +2000-01-01T08:16:52Z INFO request id=1016 path=/berths/17 status=200 ms=104 +2000-01-01T08:17:59Z INFO request id=1017 path=/berths/18 status=304 ms=141 +2000-01-01T08:18:06Z WARN request id=1018 path=/berths/19 status=404 ms=178 +2000-01-01T08:19:13Z ERROR request id=1019 path=/berths/20 status=500 ms=215 +2000-01-01T08:20:20Z INFO request id=1020 path=/berths/21 status=200 ms=252 +2000-01-01T08:21:27Z INFO request id=1021 path=/berths/22 status=200 ms=39 +2000-01-01T08:22:34Z INFO request id=1022 path=/berths/23 status=304 ms=76 +2000-01-01T08:23:41Z WARN request id=1023 path=/berths/24 status=404 ms=113 +2000-01-01T08:24:48Z ERROR request id=1024 path=/berths/25 status=500 ms=150 +2000-01-01T08:25:55Z INFO request id=1025 path=/berths/26 status=200 ms=187 +2000-01-01T08:26:02Z INFO request id=1026 path=/berths/27 status=200 ms=224 +2000-01-01T08:27:09Z INFO request id=1027 path=/berths/28 status=304 ms=261 +2000-01-01T08:28:16Z WARN request id=1028 path=/berths/29 status=404 ms=48 +2000-01-01T08:29:23Z ERROR request id=1029 path=/berths/30 status=500 ms=85 +2000-01-01T08:30:30Z INFO request id=1030 path=/berths/31 status=200 ms=122 +2000-01-01T08:31:37Z INFO request id=1031 path=/berths/32 status=200 ms=159 +2000-01-01T08:32:44Z INFO request id=1032 path=/berths/33 status=304 ms=196 +2000-01-01T08:33:51Z WARN request id=1033 path=/berths/34 status=404 ms=233 +2000-01-01T08:34:58Z ERROR request id=1034 path=/berths/35 status=500 ms=20 +2000-01-01T08:35:05Z INFO request id=1035 path=/berths/36 status=200 ms=57 +2000-01-01T08:36:12Z INFO request id=1036 path=/berths/37 status=200 ms=94 +2000-01-01T08:37:19Z INFO request id=1037 path=/berths/38 status=304 ms=131 +2000-01-01T08:38:26Z WARN request id=1038 path=/berths/39 status=404 ms=168 +2000-01-01T08:39:33Z ERROR request id=1039 path=/berths/40 status=500 ms=205 +2000-01-01T08:40:40Z INFO request id=1040 path=/berths/41 status=200 ms=242 +2000-01-01T08:41:47Z INFO request id=1041 path=/berths/42 status=200 ms=29 +2000-01-01T08:42:54Z INFO request id=1042 path=/berths/43 status=304 ms=66 +2000-01-01T08:43:01Z WARN request id=1043 path=/berths/44 status=404 ms=103 +2000-01-01T08:44:08Z ERROR request id=1044 path=/berths/45 status=500 ms=140 +2000-01-01T08:45:15Z INFO request id=1045 path=/berths/46 status=200 ms=177 +2000-01-01T08:46:22Z INFO request id=1046 path=/berths/47 status=200 ms=214 +2000-01-01T08:47:29Z INFO request id=1047 path=/berths/48 status=304 ms=251 +2000-01-01T08:48:36Z WARN request id=1048 path=/berths/49 status=404 ms=38 +2000-01-01T08:49:43Z ERROR request id=1049 path=/berths/50 status=500 ms=75 +2000-01-01T08:50:50Z INFO request id=1050 path=/berths/51 status=200 ms=112 +2000-01-01T08:51:57Z INFO request id=1051 path=/berths/52 status=200 ms=149 +2000-01-01T08:52:04Z INFO request id=1052 path=/berths/53 status=304 ms=186 +2000-01-01T08:53:11Z WARN request id=1053 path=/berths/54 status=404 ms=223 +2000-01-01T08:54:18Z ERROR request id=1054 path=/berths/55 status=500 ms=260 +2000-01-01T08:55:25Z INFO request id=1055 path=/berths/56 status=200 ms=47 +2000-01-01T08:56:32Z INFO request id=1056 path=/berths/57 status=200 ms=84 +2000-01-01T08:57:39Z INFO request id=1057 path=/berths/58 status=304 ms=121 +2000-01-01T08:58:46Z WARN request id=1058 path=/berths/59 status=404 ms=158 +2000-01-01T08:59:53Z ERROR request id=1059 path=/berths/60 status=500 ms=195 +2000-01-01T09:00:00Z INFO request id=1060 path=/berths/1 status=200 ms=232 +2000-01-01T09:01:07Z INFO request id=1061 path=/berths/2 status=200 ms=19 +2000-01-01T09:02:14Z INFO request id=1062 path=/berths/3 status=304 ms=56 +2000-01-01T09:03:21Z WARN request id=1063 path=/berths/4 status=404 ms=93 +2000-01-01T09:04:28Z ERROR request id=1064 path=/berths/5 status=500 ms=130 +2000-01-01T09:05:35Z INFO request id=1065 path=/berths/6 status=200 ms=167 +2000-01-01T09:06:42Z INFO request id=1066 path=/berths/7 status=200 ms=204 +2000-01-01T09:07:49Z INFO request id=1067 path=/berths/8 status=304 ms=241 +2000-01-01T09:08:56Z WARN request id=1068 path=/berths/9 status=404 ms=28 +2000-01-01T09:09:03Z ERROR request id=1069 path=/berths/10 status=500 ms=65 +2000-01-01T09:10:10Z INFO request id=1070 path=/berths/11 status=200 ms=102 +2000-01-01T09:11:17Z INFO request id=1071 path=/berths/12 status=200 ms=139 +2000-01-01T09:12:24Z INFO request id=1072 path=/berths/13 status=304 ms=176 +2000-01-01T09:13:31Z WARN request id=1073 path=/berths/14 status=404 ms=213 +2000-01-01T09:14:38Z ERROR request id=1074 path=/berths/15 status=500 ms=250 +2000-01-01T09:15:45Z INFO request id=1075 path=/berths/16 status=200 ms=37 +2000-01-01T09:16:52Z INFO request id=1076 path=/berths/17 status=200 ms=74 +2000-01-01T09:17:59Z INFO request id=1077 path=/berths/18 status=304 ms=111 +2000-01-01T09:18:06Z WARN request id=1078 path=/berths/19 status=404 ms=148 +2000-01-01T09:19:13Z ERROR request id=1079 path=/berths/20 status=500 ms=185 +2000-01-01T09:20:20Z INFO request id=1080 path=/berths/21 status=200 ms=222 +2000-01-01T09:21:27Z INFO request id=1081 path=/berths/22 status=200 ms=259 +2000-01-01T09:22:34Z INFO request id=1082 path=/berths/23 status=304 ms=46 +2000-01-01T09:23:41Z WARN request id=1083 path=/berths/24 status=404 ms=83 +2000-01-01T09:24:48Z ERROR request id=1084 path=/berths/25 status=500 ms=120 +2000-01-01T09:25:55Z INFO request id=1085 path=/berths/26 status=200 ms=157 +2000-01-01T09:26:02Z INFO request id=1086 path=/berths/27 status=200 ms=194 +2000-01-01T09:27:09Z INFO request id=1087 path=/berths/28 status=304 ms=231 +2000-01-01T09:28:16Z WARN request id=1088 path=/berths/29 status=404 ms=18 +2000-01-01T09:29:23Z ERROR request id=1089 path=/berths/30 status=500 ms=55 +2000-01-01T09:30:30Z INFO request id=1090 path=/berths/31 status=200 ms=92 +2000-01-01T09:31:37Z INFO request id=1091 path=/berths/32 status=200 ms=129 +2000-01-01T09:32:44Z INFO request id=1092 path=/berths/33 status=304 ms=166 +2000-01-01T09:33:51Z WARN request id=1093 path=/berths/34 status=404 ms=203 +2000-01-01T09:34:58Z ERROR request id=1094 path=/berths/35 status=500 ms=240 +2000-01-01T09:35:05Z INFO request id=1095 path=/berths/36 status=200 ms=27 +2000-01-01T09:36:12Z INFO request id=1096 path=/berths/37 status=200 ms=64 +2000-01-01T09:37:19Z INFO request id=1097 path=/berths/38 status=304 ms=101 +2000-01-01T09:38:26Z WARN request id=1098 path=/berths/39 status=404 ms=138 +2000-01-01T09:39:33Z ERROR request id=1099 path=/berths/40 status=500 ms=175 +2000-01-01T09:40:40Z INFO request id=1100 path=/berths/41 status=200 ms=212 +2000-01-01T09:41:47Z INFO request id=1101 path=/berths/42 status=200 ms=249 +2000-01-01T09:42:54Z INFO request id=1102 path=/berths/43 status=304 ms=36 +2000-01-01T09:43:01Z WARN request id=1103 path=/berths/44 status=404 ms=73 +2000-01-01T09:44:08Z ERROR request id=1104 path=/berths/45 status=500 ms=110 +2000-01-01T09:45:15Z INFO request id=1105 path=/berths/46 status=200 ms=147 +2000-01-01T09:46:22Z INFO request id=1106 path=/berths/47 status=200 ms=184 +2000-01-01T09:47:29Z INFO request id=1107 path=/berths/48 status=304 ms=221 +2000-01-01T09:48:36Z WARN request id=1108 path=/berths/49 status=404 ms=258 +2000-01-01T09:49:43Z ERROR request id=1109 path=/berths/50 status=500 ms=45 +2000-01-01T09:50:50Z INFO request id=1110 path=/berths/51 status=200 ms=82 +2000-01-01T09:51:57Z INFO request id=1111 path=/berths/52 status=200 ms=119 +2000-01-01T09:52:04Z INFO request id=1112 path=/berths/53 status=304 ms=156 +2000-01-01T09:53:11Z WARN request id=1113 path=/berths/54 status=404 ms=193 +2000-01-01T09:54:18Z ERROR request id=1114 path=/berths/55 status=500 ms=230 +2000-01-01T09:55:25Z INFO request id=1115 path=/berths/56 status=200 ms=17 +2000-01-01T09:56:32Z INFO request id=1116 path=/berths/57 status=200 ms=54 +2000-01-01T09:57:39Z INFO request id=1117 path=/berths/58 status=304 ms=91 +2000-01-01T09:58:46Z WARN request id=1118 path=/berths/59 status=404 ms=128 +2000-01-01T09:59:53Z ERROR request id=1119 path=/berths/60 status=500 ms=165 diff --git a/crates/exav-viewer/demo/examples/files/text/style.css b/crates/exav-viewer/demo/examples/files/text/style.css new file mode 100644 index 00000000..013affe1 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/style.css @@ -0,0 +1,50 @@ +/* A small stylesheet with custom properties, grid and a media query. CC0-1.0, exav project. */ +:root { + --ink: #1b2430; + --paper: #fbfaf7; + --accent: #0b7285; + --radius: 0.5rem; + --gap: clamp(0.75rem, 2vw, 1.5rem); + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { --ink: #e7edf3; --paper: #12171d; --accent: #66d9e8; } +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: 1rem/1.55 system-ui, sans-serif; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: var(--gap); + padding: var(--gap); +} + +.card { + padding: 1rem; + border: 1px solid color-mix(in srgb, var(--ink) 20%, transparent); + border-radius: var(--radius); + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.card:hover, +.card:focus-within { + transform: translateY(-2px); + box-shadow: 0 6px 18px rgb(0 0 0 / 0.12); +} + +.card > h2::after { content: " \2192"; color: var(--accent); } + +a:not([class]) { color: var(--accent); text-underline-offset: 0.2em; } + +@media (max-width: 40rem) { + .cards { grid-template-columns: 1fr; } +} diff --git a/crates/exav-viewer/demo/examples/files/text/subtitles.srt b/crates/exav-viewer/demo/examples/files/text/subtitles.srt new file mode 100644 index 00000000..6630bd3e --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/subtitles.srt @@ -0,0 +1,23 @@ +1 +00:00:02,000 --> 00:00:03,800 +The tide is coming in. + +2 +00:00:04,000 --> 00:00:05,800 +Berth fourteen is free. + +3 +00:00:06,000 --> 00:00:07,800 +A gull laughs. + +4 +00:00:08,000 --> 00:00:09,800 +Take the line, please. + +5 +00:00:10,000 --> 00:00:11,800 +Engine off, thank you. + +6 +00:00:12,000 --> 00:00:13,800 +Welcome to the east quay. diff --git a/crates/exav-viewer/demo/examples/files/text/tides.tsv b/crates/exav-viewer/demo/examples/files/text/tides.tsv new file mode 100644 index 00000000..989f0389 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/tides.tsv @@ -0,0 +1,29 @@ +day high_1 height_1_m low_1 height_2_m coefficient +1 05.80 5.01 11.80 1.98 78 +2 06.60 5.49 12.60 1.93 86 +3 07.40 5.93 13.40 1.84 93 +4 08.20 6.30 14.20 1.73 100 +5 09.00 6.60 15.00 1.59 104 +6 09.80 6.80 15.80 1.43 108 +7 10.60 6.89 16.60 1.27 109 +8 11.40 6.88 17.40 1.10 109 +9 12.20 6.76 18.20 0.93 107 +10 13.00 6.54 19.00 0.78 103 +11 13.80 6.22 19.80 0.64 98 +12 14.60 5.83 20.60 0.53 92 +13 15.40 5.38 21.40 0.46 84 +14 16.20 4.89 22.20 0.41 76 +15 17.00 4.38 23.00 0.40 68 +16 17.80 3.88 23.80 0.43 59 +17 18.60 3.40 00.60 0.49 51 +18 19.40 2.98 01.40 0.58 44 +19 20.20 2.62 02.20 0.70 38 +20 21.00 2.35 03.00 0.85 34 +21 21.80 2.17 03.80 1.01 31 +22 22.60 2.10 04.60 1.17 30 +23 23.40 2.14 05.40 1.34 30 +24 00.20 2.28 06.20 1.51 33 +25 01.00 2.53 07.00 1.66 37 +26 01.80 2.86 07.80 1.78 42 +27 02.60 3.27 08.60 1.89 49 +28 03.40 3.73 09.40 1.96 57 diff --git a/crates/exav-viewer/demo/examples/files/text/tokens.ts b/crates/exav-viewer/demo/examples/files/text/tokens.ts new file mode 100644 index 00000000..3756dc21 --- /dev/null +++ b/crates/exav-viewer/demo/examples/files/text/tokens.ts @@ -0,0 +1,54 @@ +// An arithmetic tokenizer and evaluator. CC0-1.0, exav project. +type Token = { kind: "num"; value: number } | { kind: "op"; value: "+" | "-" | "*" | "/" | "^" } | { kind: "paren"; value: "(" | ")" }; + +export function tokenize(source: string): Token[] { + const tokens: Token[] = []; + const re = /\s*(?:(\d+(?:\.\d+)?)|([-+*/^])|([()]))/y; + let match: RegExpExecArray | null; + while (re.lastIndex < source.length && (match = re.exec(source))) { + if (match[1] !== undefined) tokens.push({ kind: "num", value: Number(match[1]) }); + else if (match[2]) tokens.push({ kind: "op", value: match[2] as "+" }); + else tokens.push({ kind: "paren", value: match[3] as "(" }); + } + if (re.lastIndex < source.trimEnd().length) throw new SyntaxError(`unexpected character at ${re.lastIndex}`); + return tokens; +} + +const PRECEDENCE: Record = { "+": [1, "left"], "-": [1, "left"], "*": [2, "left"], "/": [2, "left"], "^": [3, "right"] }; + +/** Evaluates with the shunting-yard algorithm. */ +export function evaluate(source: string): number { + const out: number[] = []; + const ops: string[] = []; + const apply = (op: string) => { + const b = out.pop(); + const a = out.pop(); + if (a === undefined || b === undefined) throw new SyntaxError("missing operand"); + out.push(op === "+" ? a + b : op === "-" ? a - b : op === "*" ? a * b : op === "/" ? a / b : a ** b); + }; + for (const t of tokenize(source)) { + if (t.kind === "num") out.push(t.value); + else if (t.kind === "op") { + const [p, assoc] = PRECEDENCE[t.value]!; + while (ops.length) { + const top = ops[ops.length - 1]!; + const [tp] = PRECEDENCE[top] ?? [-1]; + if (top !== "(" && (tp > p || (tp === p && assoc === "left"))) apply(ops.pop()!); + else break; + } + ops.push(t.value); + } else if (t.value === "(") ops.push("("); + else { + while (ops.length && ops[ops.length - 1] !== "(") apply(ops.pop()!); + if (ops.pop() !== "(") throw new SyntaxError("unbalanced parenthesis"); + } + } + while (ops.length) { + const op = ops.pop()!; + if (op === "(") throw new SyntaxError("unbalanced parenthesis"); + apply(op); + } + return out[0] ?? NaN; +} + +console.log(evaluate("2 + 3 * (4 - 1) ^ 2")); diff --git a/crates/exav-viewer/demo/examples/generate/catalog.json b/crates/exav-viewer/demo/examples/generate/catalog.json new file mode 100644 index 00000000..92d354f6 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/catalog.json @@ -0,0 +1,376 @@ +{ + "refs": { + "pixls": { "author": "Uploader to raw.pixls.us (the site gives no name)", "source": "https://raw.pixls.us/#repo", "evidence": "https://raw.pixls.us/json/getrepository.php?set=all", "evidence_quote": "Creative Commons 0 - Public Domain", "evidence_row": "sha256", "licence": "CC0-1.0", "attribution": "" }, + "bsi": { "author": "buildingSMART International Ltd.", "source": "https://github.com/buildingSMART/Sample-Test-Files", "evidence": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/LICENSE", "evidence_quote": "This work is licensed under the Creative Commons Attribution 4.0 International License.", "licence": "CC-BY-4.0", "attribution": "buildingSMART International, Sample-Test-Files, https://github.com/buildingSMART/Sample-Test-Files, licensed CC BY 4.0. No changes." }, + "barraca": { "author": "Ã’scar Selfa", "source": "https://zenodo.org/records/14293897", "evidence": "https://zenodo.org/records/14293897", "evidence_quote": "Creative Commons Attribution 4.0 International", "licence": "CC-BY-4.0", "attribution": "Ã’scar Selfa, Barraca Valenciana de Llauradors: Model BIM i Estudi de la Tipologia Arquitectònica, Zenodo, doi:10.5281/zenodo.14293897, CC BY 4.0. No changes." }, + "ezdxf": { "author": "Manfred Moitzi (ezdxf)", "source": "https://github.com/mozman/ezdxf", "evidence": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/LICENSE", "evidence_quote": "Copyright (c) 2020 Manfred Moitzi", "licence": "MIT", "attribution": "Copyright (c) 2020 Manfred Moitzi, ezdxf, MIT License. No changes." }, + "khronos": { "author": "Khronos glTF Sample Assets (the model's own README names the author)", "source": "https://github.com/KhronosGroup/glTF-Sample-Assets", "licence": "CC-BY-4.0" }, + "poi": { "author": "Apache POI committers", "source": "https://github.com/apache/poi/tree/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data", "evidence": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/legal/LICENSE", "evidence_quote": "Version 2.0, January 2004", "licence": "Apache-2.0", "attribution": "Apache POI test data, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/poi. No changes." }, + "tika": { "author": "Apache Tika committers", "source": "https://github.com/apache/tika/tree/f58e44c022a9952252f3e094dcc40abd931f7ecd", "evidence": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/LICENSE.txt", "evidence_quote": "Version 2.0, January 2004", "licence": "Apache-2.0", "attribution": "Apache Tika test document, Copyright The Apache Software Foundation, Apache-2.0, https://github.com/apache/tika. No changes." }, + "docx4j": { "author": "Jason Harrop and the docx4j contributors", "source": "https://github.com/plutext/docx4j", "evidence": "https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/legals/LICENSE", "evidence_quote": "Version 2.0, January 2004", "licence": "Apache-2.0", "attribution": "docx4j, Jason Harrop / Plutext, https://github.com/plutext/docx4j, Apache-2.0. No changes." }, + "odfdo": { "author": "Jérôme Dumonteil (odfdo)", "source": "https://github.com/jdum/odfdo", "evidence": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/LICENSE", "evidence_quote": "Version 2.0, January 2004", "licence": "Apache-2.0", "attribution": "Copyright Jérôme Dumonteil, odfdo, Apache-2.0, https://github.com/jdum/odfdo. No changes." }, + "gsa508": { "author": "U.S. General Services Administration and the agencies that wrote the documents (Section508.gov)", "source": "https://github.com/GSA/Assets.Section508.gov", "evidence": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/LICENSE.md", "evidence_quote": "this project is in the public domain within the United States of America. Additionally, we waive copyright and", "licence": "CC0-1.0", "attribution": "" }, + "cleveland": { "author": "Cleveland Museum of Art", "licence": "CC0-1.0", "evidence_quote": "\"share_license_status\":\"CC0\"", "attribution": "" }, + "zenodo": { "licence": "CC-BY-4.0", "evidence_quote": "Creative Commons Attribution 4.0 International" } + }, + "blocks": [ + { + "group": "images", + "source": "generated", + "gen": "make-images.py", + "dir": "images", + "files": [ + { "file": "scene.png", "family": "png", "title": "Dusk landscape, PNG", "features": "8-bit RGB, optimised" }, + { "file": "scene-alpha.png", "family": "png", "title": "Landscape fading to transparent, PNG", "features": "RGBA: the viewer shows its chequerboard through the alpha" }, + { "file": "scene-indexed.png", "family": "png", "title": "Landscape in 32 colours, PNG", "features": "palette (indexed) PNG" }, + { "file": "gradient-grey16.png", "family": "png", "title": "Grey ramp, 16-bit PNG", "features": "16-bit greyscale" }, + { "file": "bounce.png", "family": "apng", "title": "Bouncing ball, animated PNG", "features": "24 frames, APNG" }, + { "file": "scene.jpg", "family": "jpeg", "title": "Dusk landscape, JPEG", "features": "baseline 4:2:0 with EXIF description and copyright" }, + { "file": "scene-progressive.jpg", "family": "jpeg", "title": "Dusk landscape, progressive JPEG", "features": "progressive scans, optimised Huffman tables" }, + { "file": "scene-orientation6.jpg", "family": "jpeg", "title": "JPEG stored on its side with EXIF orientation 6", "features": "shown upright through the EXIF orientation" }, + { "file": "scene-grey.jpg", "family": "jpeg", "title": "Landscape in grey, JPEG", "features": "single-component JPEG" }, + { "file": "scene-cmyk.jpg", "family": "jpeg", "title": "Landscape as a CMYK JPEG", "features": "four components (Adobe inverted CMYK)" }, + { "file": "bounce.gif", "family": "gif", "title": "Bouncing ball, animated GIF", "features": "24 frames with a palette each" }, + { "file": "scene.gif", "family": "gif", "title": "Dusk landscape, dithered GIF", "features": "256 colours, Floyd-Steinberg dithering" }, + { "file": "scene.webp", "family": "webp", "title": "Dusk landscape, lossy WebP", "features": "VP8" }, + { "file": "scene-lossless.webp", "family": "webp", "title": "Dusk landscape, lossless WebP", "features": "VP8L" }, + { "file": "scene-alpha.webp", "family": "webp", "title": "Landscape fading to transparent, WebP", "features": "lossy colour with an alpha plane (VP8X, ALPH)" }, + { "file": "bounce.webp", "family": "webp", "title": "Bouncing ball, animated WebP", "features": "24 frames" }, + { "file": "scene-lzw.tif", "family": "tiff", "title": "Dusk landscape, TIFF with LZW", "features": "RGB, LZW, 300 dpi" }, + { "file": "scene-jpeg.tif", "family": "tiff", "title": "Dusk landscape, TIFF with JPEG compression", "features": "JPEG in TIFF (compression 7)" }, + { "file": "scene-cmyk-packbits.tif", "family": "tiff", "title": "Landscape as a CMYK TIFF", "features": "four inks, PackBits" }, + { "file": "gradient-grey16.tif", "family": "tiff", "title": "Grey ramp, 16-bit TIFF", "features": "16-bit greyscale, Deflate" }, + { "file": "scene-pages.tif", "family": "tiff", "title": "Three-page TIFF", "features": "pages of 512, 256 and 128 pixels, the last in grey, Deflate" }, + { "file": "fax-g4.tif", "family": "tiff-fax", "title": "Fax cover sheet and register, CCITT Group 4 TIFF", "features": "1-bit, 1728 by 2200 at 204 by 196 dpi, T.6" }, + { "file": "fax-g4-3pages.tif", "family": "tiff-fax", "title": "Three-page fax, CCITT Group 4 TIFF", "features": "three pages, T.6" }, + { "file": "fax-g3-1d.tif", "group": "unsupported", "family": "tiff-fax", "title": "Fax page, CCITT Group 3 (T.4, one-dimensional) TIFF", "features": "kept to show the error: the TIFF decoder reads Group 4 only" }, + { "file": "scene-24.bmp", "family": "bmp", "title": "Dusk landscape, 24-bit BMP", "features": "BI_RGB, bottom-up" }, + { "file": "scene-8.bmp", "family": "bmp", "title": "Dusk landscape, 8-bit BMP", "features": "256-colour palette" }, + { "file": "scene-32.bmp", "family": "bmp", "title": "Dusk landscape, 32-bit BMP", "features": "four channels" }, + { "file": "scene-1bit.bmp", "family": "bmp", "title": "Dusk landscape, 1-bit BMP", "features": "two-colour palette" }, + { "file": "scene-rle8.bmp", "family": "bmp", "title": "Dusk landscape, 8-bit BMP with RLE compression", "features": "BI_RLE8 with encoded and absolute runs" }, + { "file": "icon.ico", "family": "ico", "title": "Icon with seven sizes", "features": "16 to 256 pixels, PNG-coded images" }, + { "file": "icon-legacy.ico", "family": "ico", "title": "Icon in the legacy bitmap coding", "features": "32-bit and 8-bit DIBs with AND masks, and one PNG image" }, + { "file": "scene.ppm", "family": "pnm", "title": "Dusk landscape, PPM (P6)", "features": "binary pixmap with a comment" }, + { "file": "scene-ascii.ppm", "family": "pnm", "title": "Dusk landscape, ASCII PPM (P3)", "features": "text pixmap, 48 by 36" }, + { "file": "scene-grey16.pgm", "family": "pnm", "title": "Landscape in grey, 16-bit PGM (P5)", "features": "maxval 65535" }, + { "file": "scene.pbm", "family": "pnm", "title": "Landscape as a bitmap, PBM (P4)", "features": "1 bit a pixel" }, + { "file": "scene-alpha.pam", "family": "pnm", "title": "Landscape with alpha, PAM (P7)", "features": "RGB_ALPHA tuples" }, + { "file": "scene.qoi", "family": "qoi", "title": "Dusk landscape, QOI", "features": "three channels" }, + { "file": "scene-alpha.qoi", "family": "qoi", "title": "Landscape with alpha, QOI", "features": "four channels" }, + { "file": "scene.ff", "family": "farbfeld", "title": "Landscape with alpha, farbfeld", "features": "16-bit RGBA, big-endian" }, + { "file": "sunset.hdr", "family": "hdr", "title": "Sunset with a sun far brighter than the sky, Radiance HDR", "features": "RGBE with run-length scanlines; the sun is 60 times white" }, + { "file": "sunset-flat.hdr", "family": "hdr", "title": "Sunset, uncompressed Radiance HDR", "features": "flat RGBE scanlines" }, + { "file": "scene-bc1.dds", "family": "dds", "title": "Dusk landscape, DDS with BC1 (DXT1)", "features": "full mipmap chain" }, + { "file": "scene-bc3.dds", "family": "dds", "title": "Landscape with alpha, DDS with BC3 (DXT5)", "features": "full mipmap chain, alpha blocks" }, + { "file": "scene.jp2", "family": "jpeg2000", "title": "Dusk landscape, JPEG 2000 (JP2)", "features": "lossy, 3 quality layers, 5 resolutions, 256-pixel tiles, RPCL progression" }, + { "file": "scene.j2k", "family": "jpeg2000", "title": "Dusk landscape, bare JPEG 2000 codestream", "features": "no JP2 boxes" }, + { "file": "scene-grey.jp2", "family": "jpeg2000", "title": "Landscape in grey, JPEG 2000", "features": "one component" }, + { "file": "scene-alpha.jp2", "family": "jpeg2000", "title": "Landscape with alpha, JPEG 2000", "features": "four components with an alpha channel" }, + { "file": "page-generic.jb2", "family": "jbig2", "title": "Fax page as a JBIG2 file, generic region", "features": "1-bit page of 1240 by 1579, arithmetic coding" }, + { "file": "page-symbol.jb2", "family": "jbig2", "title": "Fax page as a JBIG2 file, symbol coding", "features": "text region over a symbol dictionary" }, + { "file": "scene-lossy.jxl", "family": "jpeg-xl", "title": "Dusk landscape, JPEG XL, lossy", "features": "VarDCT at distance 1" }, + { "file": "scene-lossless.jxl", "family": "jpeg-xl", "title": "Dusk landscape, JPEG XL, lossless", "features": "modular mode" }, + { "file": "scene-alpha.jxl", "family": "jpeg-xl", "title": "Landscape with alpha, JPEG XL", "features": "lossless with an extra alpha channel" }, + { "file": "bounce.jxl", "family": "jpeg-xl", "title": "Bouncing ball, animated JPEG XL", "features": "24 frames" } + ] + }, + { + "group": "cad", + "source": "generated", + "gen": "make-cad.py", + "dir": "cad", + "files": [ + { "file": "house-plan.dxf", "family": "dxf", "title": "House, ground floor plan (DXF 2018)", "features": "walls cut at openings with pattern hatches, door blocks with attributes, window and furniture blocks, axes with bubbles, room labels, linear, aligned, radius and angular dimensions, stair, north arrow, 17 layers, paper-space sheet with a viewport and a title block" }, + { "file": "house-plan-r2000.dxf", "family": "dxf", "title": "House, ground floor plan (DXF 2000)", "features": "the same plan written as an AutoCAD 2000 file" }, + { "file": "house-elevations.dxf", "family": "dxf", "title": "House, four elevations and a section in 2D", "features": "gabled roofs with roof-tile hatches, plaster hatches, ground hatching, windows with transparent glass, steps, level marks, height dimensions, a hatched section with a stair" }, + { "file": "hatch-gallery.dxf", "family": "dxf", "title": "Hatch gallery", "features": "about a hundred predefined patterns, nine gradient types, islands in the three hatch styles, boundaries of lines, arcs, ellipses and splines, pattern angle and scale" }, + { "file": "dimensions.dxf", "family": "dxf", "title": "Dimension types", "features": "linear, aligned, diameter, radius, arc length, angular (two lines and three points), ordinate, leaders, a multileader, tolerances, text override, three dimension styles" }, + { "file": "linetypes.dxf", "family": "dxf", "title": "Linetypes, line weights and polylines", "features": "the standard linetypes, custom ones with text inside, 12 line weights, per-entity scale, linetype generation, polylines with widths, circles, arcs, ellipses and splines in dashed styles" }, + { "file": "text-styles.dxf", "family": "dxf", "title": "Text styles and MTEXT", "features": "ten text styles (width and oblique factors, mirrored, upside down), justifications, rotation, special codes, Unicode, MTEXT colours, fonts, stacked fractions, underline, columns, background, nine attachment points" }, + { "file": "blocks.dxf", "family": "dxf", "title": "Blocks, nested inserts and arrays", "features": "a block inside a block inside a block with attributes, mirrored, scaled and rotated inserts, a 3 by 8 array, BYBLOCK colours" }, + { "file": "bracket.dxf", "family": "dxf", "title": "Mechanical part: an L bracket in three views", "features": "centre and hidden lines, a section with hatching, dimensions with tolerances, diameter and radius dimensions, a leader, an A4 sheet with a title block" }, + { "file": "site-plan.dxf", "family": "dxf", "title": "Site plan with contour lines", "features": "closed spline contours, wide polyline roads, parcels with areas, hatched buildings, a gradient pond, spot heights, trees, scale bar, legend and north arrow" }, + { "file": "legacy-r12.dxf", "family": "dxf", "title": "Gear in the R12 format", "features": "POLYLINE, CIRCLE, ARC, TEXT, SOLID, 3DFACE and INSERT entities of the 1992 format" } + ] + }, + { + "group": "3d", + "source": "generated", + "gen": "make-models.py", + "dir": "models", + "files": [ + { "file": "gear.stl", "family": "stl", "title": "Spur gear, binary STL with facet colours", "features": "864 facets, orange teeth and blue hub through the VisCAM colour attribute" }, + { "file": "bracket-ascii.stl", "family": "stl", "title": "L bracket, ASCII STL", "features": "text STL, three overlapping boxes" }, + { "file": "knot.obj", "family": "obj", "title": "Trefoil knot and base, OBJ with vertex colours", "features": "two objects (knot, base), a colour and a normal per vertex, quads, a rainbow along the knot" }, + { "file": "terrain.ply", "family": "ply", "title": "Height field, binary PLY with colours", "features": "2304 vertices with normals and a hypsometric colour each, 4418 triangles" }, + { "file": "icosphere-ascii.ply", "family": "ply", "title": "Icosphere, ASCII PLY with colours", "features": "text PLY with normals and RGBA per vertex" }, + { "file": "arm.glb", "family": "glb", "title": "Articulated arm in a glass dome, GLB", "features": "node tree with rotations and scales, five materials, a metallic and a transparent one, vertex colours on the floor" }, + { "file": "table.gltf", "family": "gltf", "title": "Table and two chairs, glTF with an embedded buffer", "features": "17 nodes, a data URI buffer, shared meshes instanced under several nodes" } + ] + }, + { + "group": "pdf", + "source": "generated", + "gen": "make-pdf.py", + "dir": "pdf", + "files": [ + { "file": "field-report.pdf", "family": "pdf", "title": "Field report, six pages", "features": "a table of contents whose lines are links, a two-level outline, roman and arabic page labels, internal and external links, a table, bar, line and pie charts as vectors, a JPEG, standard and embedded fonts (Latin, Greek, Cyrillic), document information" }, + { "file": "application-form.pdf", "family": "pdf-form", "title": "Berth application form (AcroForm)", "features": "text fields (multiline, maximum length, password, read-only), check boxes, radio buttons, a drop-down list, a list box, some filled in" }, + { "file": "annotations.pdf", "family": "pdf-annotations", "title": "Annotations without appearance streams", "features": "links to a URL, a page and a named destination, highlight, underline, strike-out, squiggly, note, free text, square, circle, line with arrows, polygon, polyline, ink, stamp, file attachment, an outline and a named destination" }, + { "file": "scan-mixed.pdf", "family": "pdf-scan", "title": "Scanned letters, one page for each scanner image filter", "features": "four pages: DCTDecode (JPEG), CCITTFaxDecode (Group 4), JBIG2Decode and JPXDecode (JPEG 2000), each with an invisible OCR text layer; the viewer decodes the last three with its own decoders", "tools": ["opj_compress", "jbig2", "tiffcp"] }, + { "file": "vector-art.pdf", "family": "pdf", "title": "Vector art", "features": "axial and radial shadings, transparency, a clipping path, dashes, caps and joins, Bezier curves, an even-odd fill, text with render modes, rotated and scaled text" } + ] + }, + { + "group": "office", + "source": "generated", + "gen": "make-office.py", + "tools": ["soffice"], + "files": [ + { "file": "handbook.docx", "dir": "office", "family": "docx", "title": "Harbour handbook (Word)", "features": "title page, table of contents with links, outline numbering, character and paragraph styles, bulleted and numbered lists of three levels, tables with merged cells and a repeated header, two pictures (one floating with wrapped text), a shape, footnotes, hyperlinks, bookmarks, fields, a comment, tracked changes, a two-column section, a landscape section with a wide table, headers and footers with page numbers" }, + { "file": "workbook.xlsx", "dir": "office", "family": "xlsx", "title": "Harbour workbook (Excel)", "features": "three sheets: a budget with formulas (SUM, AVERAGE, INDEX, MATCH, COUNTIF, IF, VLOOKUP, ROUND), currency, percent and date formats, merged title, frozen header rows, a validation list, a comment, a hyperlink and named ranges; 120 rows of sales with an autofilter; a bar, a line and a pie chart" }, + { "file": "deck.pptx", "dir": "office", "family": "pptx", "title": "Harbour review (PowerPoint)", "features": "five slides: a gradient title slide, nested bullets, a native bar chart with a callout, a table, a picture with shapes, gradients and arrows; a master with footer and slide number; speaker notes (not shown) and transitions" }, + { "file": "handbook-tagged.pdf", "dir": "pdf", "group": "pdf", "family": "pdf", "title": "Harbour handbook as a tagged PDF/A-2", "features": "bookmarks from the headings, hyperlinks, tags, footnotes, a landscape section, embedded Liberation fonts" }, + { "file": "handbook.doc", "dir": "converted", "group": "converted", "family": "doc", "title": "Harbour handbook (Word 97 to 2003)", "features": "the handbook as a binary .doc" }, + { "file": "handbook.odt", "dir": "converted", "group": "converted", "family": "odt", "title": "Harbour handbook (OpenDocument text)", "features": "the handbook as .odt" }, + { "file": "handbook.fodt", "dir": "converted", "group": "converted", "family": "fodt", "title": "Harbour handbook (flat OpenDocument text)", "features": "one XML file, pictures in base64; written by hand, not converted", "tools": [] }, + { "file": "handbook.rtf", "dir": "converted", "group": "converted", "family": "rtf", "title": "Harbour handbook (Rich Text Format)", "features": "the handbook as .rtf" }, + { "file": "workbook.xls", "dir": "converted", "group": "converted", "family": "xls", "title": "Harbour workbook (Excel 97 to 2003)", "features": "the workbook as a binary .xls" }, + { "file": "workbook.ods", "dir": "converted", "group": "converted", "family": "ods", "title": "Harbour workbook (OpenDocument spreadsheet)", "features": "the workbook as .ods" }, + { "file": "workbook.fods", "dir": "converted", "group": "converted", "family": "fods", "title": "Harbour workbook (flat OpenDocument spreadsheet)", "features": "one XML file; written by hand, not converted", "tools": [] }, + { "file": "deck.ppt", "dir": "converted", "group": "converted", "family": "ppt", "title": "Harbour review (PowerPoint 97 to 2003)", "features": "the deck as a binary .ppt (its thumbnail makes it large)" }, + { "file": "deck.odp", "dir": "converted", "group": "converted", "family": "odp", "title": "Harbour review (OpenDocument presentation)", "features": "the deck as .odp" }, + { "file": "deck.fodp", "dir": "converted", "group": "converted", "family": "fodp", "title": "Harbour review (flat OpenDocument presentation)", "features": "one XML file; written by hand, not converted", "tools": [] }, + { "file": "diagram.odg", "dir": "converted", "group": "converted", "family": "odg", "title": "Flowchart (OpenDocument drawing)", "features": "two pages, layers (one hidden), connectors glued to shapes, groups, gradients, a curve and a polyline, shadows, dashes" }, + { "file": "diagram.fodg", "dir": "converted", "group": "converted", "family": "fodg", "title": "Flowchart (flat OpenDocument drawing)", "features": "one XML file; written by hand, not converted", "tools": [] } + ] + }, + { + "group": "text", + "source": "generated", + "gen": "make-text.py", + "dir": "text", + "files": [ + { "file": "field-guide.md", "family": "markdown", "title": "Field guide to Markdown", "features": "headings of six levels, nested and task lists, quotes, an aligned table, fenced code with languages, footnotes, links and references, inline HTML, Unicode" }, + { "file": "field-notes.txt", "family": "text", "title": "Plain text with tabs, a long line and pangrams in nine scripts", "features": "Latin, Greek, Cyrillic, Japanese, Arabic and Hebrew text, box drawing, symbols, no final newline" }, + { "file": "recipes.json", "family": "json", "title": "A small recipe book as JSON", "features": "nested objects and arrays, numbers of every kind, null, booleans, escapes, empty values" }, + { "file": "config.yaml", "family": "yaml", "title": "Configuration file, YAML", "features": "mappings, lists, block scalars, anchors and aliases, comments" }, + { "file": "pyproject.toml", "family": "toml", "title": "Project file, TOML", "features": "tables, arrays of tables, inline values" }, + { "file": "feed.xml", "family": "xml", "title": "Atom feed", "features": "namespaces, CDATA, an XHTML content element, entities, comments" }, + { "file": "route.gpx", "family": "xml", "title": "Coastal walk, GPX track", "features": "60 track points with elevation and time, two waypoints" }, + { "file": "places.kml", "family": "xml", "title": "Places, KML", "features": "a point, a line and a polygon, a shared style, HTML in CDATA" }, + { "file": "harbour-log.csv", "family": "csv", "title": "Harbour log, 300 rows of CSV", "features": "nine columns, quoted fields with commas, quotes and line breaks, accents" }, + { "file": "tides.tsv", "family": "csv", "title": "Tide table, tab-separated values", "features": "28 rows, six columns" }, + { "file": "prix.csv", "family": "csv", "title": "Price list in the European style", "features": "semicolon separator, decimal commas, accented headers" }, + { "file": "life.py", "family": "source", "title": "Conway's Game of Life, Python", "features": "docstrings, comprehensions, f-strings, a main guard" }, + { "file": "lru.rs", "family": "source", "title": "A least-recently-used cache, Rust", "features": "generics, trait bounds, closures, a test module" }, + { "file": "tokens.ts", "family": "source", "title": "Arithmetic tokenizer and evaluator, TypeScript", "features": "union types, a sticky regular expression, the shunting-yard algorithm" }, + { "file": "bits.c", "family": "source", "title": "Bit tricks on 32-bit words, C", "features": "static functions, hexadecimal literals, printf formats" }, + { "file": "report.sql", "family": "source", "title": "Monthly report with window functions, SQL", "features": "DDL, a CTE, lag, rank and sum over windows, joins" }, + { "file": "deploy.sh", "family": "source", "title": "Build and deploy script, Bash", "features": "strict mode, traps, functions, process substitution, a case statement" }, + { "file": "style.css", "family": "source", "title": "A small stylesheet, CSS", "features": "custom properties, grid, clamp, color-mix, a dark mode and a media query" }, + { "file": "page.html", "family": "html", "title": "A page, shown as its source, HTML", "features": "semantic elements, a table, a form, a style and a script that do not run" }, + { "file": "changes.diff", "family": "diff", "title": "A unified diff of two files", "features": "two files, three hunks, added and removed lines" }, + { "file": "subtitles.srt", "family": "text", "title": "Subtitles, SubRip", "features": "six cues with a tag" }, + { "file": "server.log", "family": "text", "title": "A server log of 120 lines", "features": "INFO, WARN and ERROR lines" } + ] + }, + { + "group": "archives", + "source": "generated", + "gen": "make-archives.py", + "dir": "archives", + "tools": ["7z", "genisoimage", "zstd", "gcab", "dpkg-deb", "cpio", "ar"], + "files": [ + { "file": "sample.zip", "family": "zip", "title": "Zip with folders, stored and deflated members, Unicode names and a comment", "features": "9 files in 5 folders, an empty folder", "tools": [] }, + { "file": "sample.tar", "family": "tar", "title": "Tar (ustar) with folders and a symbolic link", "features": "16 entries", "tools": [] }, + { "file": "sample.tar.gz", "family": "tar.gz", "title": "Tar compressed with gzip", "features": "the tar opens inside the gzip", "tools": [] }, + { "file": "sample.tar.bz2", "family": "tar.bz2", "title": "Tar compressed with bzip2", "features": "the tar opens inside the bzip2 stream", "tools": [] }, + { "file": "sample.tar.xz", "family": "tar.xz", "title": "Tar compressed with xz", "features": "the tar opens inside the xz stream", "tools": [] }, + { "file": "sample.tar.zst", "family": "tar.zst", "title": "Tar compressed with Zstandard", "features": "level 19", "tools": ["zstd"] }, + { "file": "server.log.gz", "family": "gz", "title": "A log compressed with gzip", "features": "one member, no tar", "tools": [] }, + { "file": "server.log.bz2", "family": "bz2", "title": "A log compressed with bzip2", "features": "one member, no tar", "tools": [] }, + { "file": "server.log.xz", "family": "xz", "title": "A log compressed with xz", "features": "one member, no tar", "tools": [] }, + { "file": "sample.7z", "family": "7z", "title": "7z with LZMA2", "features": "a solid archive of 9 files", "tools": ["7z"] }, + { "file": "sample.rar", "family": "rar", "title": "RAR 5 with stored members", "features": "folders, CRC32 and Unix modes, written by the script from the format's documentation (no RAR program)", "tools": [] }, + { "file": "sample.iso", "family": "iso", "title": "ISO 9660 image with Joliet and Rock Ridge", "features": "volume EXAV_EXAMPLE, long and Unicode names", "tools": ["genisoimage"] }, + { "file": "matryoshka.zip", "family": "zip", "title": "Nested archives: a zip holding a tar.gz holding a 7z holding a zip", "features": "four levels, opened in place one after the other", "tools": ["7z"] }, + { "file": "encrypted-aes.zip", "family": "zip", "title": "Zip encrypted with AES-256, password: exav", "features": "names are listed, each member says it is encrypted", "tools": ["7z"] }, + { "file": "encrypted-zipcrypto.zip", "family": "zip", "title": "Zip encrypted with the traditional ZipCrypto, password: exav", "features": "names are listed, each member says it is encrypted", "tools": ["7z"] }, + { "file": "encrypted.7z", "family": "7z", "title": "7z encrypted with AES-256, password: exav", "features": "file names visible, contents encrypted", "tools": ["7z"] }, + { "file": "example.jar", "family": "jar", "title": "A jar (a zip with a manifest)", "features": "META-INF/MANIFEST.MF, a source file, a properties file", "tools": [] }, + { "file": "pages.cbz", "family": "cbz", "title": "Comic book archive (zip of pages)", "features": "two page images and ComicInfo.xml", "tools": [] }, + { "file": "sample.cab", "family": "cab", "title": "Microsoft cabinet", "features": "four files, MSZIP", "tools": ["gcab"] }, + { "file": "exav-example.deb", "family": "deb", "title": "Debian package", "features": "an ar archive of debian-binary, control.tar.xz and data.tar.xz", "tools": ["dpkg-deb"] }, + { "file": "libexample.a", "family": "ar", "title": "Unix ar archive", "features": "two members", "tools": ["ar"] }, + { "file": "sample.cpio", "family": "cpio", "title": "cpio archive (newc)", "features": "four files", "tools": ["cpio"] } + ] + }, + { + "group": "media", + "source": "generated", + "gen": "make-media.py", + "dir": "media", + "tools": ["ffmpeg"], + "files": [ + { "file": "canon.wav", "family": "wav", "title": "A four-voice canon, WAV", "features": "8 kHz mono 16-bit, 8.4 seconds, synthesised" }, + { "file": "canon.mp3", "family": "mp3", "title": "The canon as MP3", "features": "24 kbit/s" }, + { "file": "canon.flac", "family": "flac", "title": "The canon as FLAC", "features": "lossless, level 8" }, + { "file": "canon.ogg", "family": "ogg", "title": "The canon as Ogg Vorbis", "features": "quality 2" }, + { "file": "canon.opus", "family": "opus", "title": "The canon as Opus", "features": "16 kbit/s" }, + { "file": "canon.m4a", "family": "m4a", "title": "The canon as AAC in MP4", "features": "24 kbit/s" }, + { "file": "testcard.mp4", "family": "mp4", "title": "Test card with a clock, H.264 and AAC in MP4", "features": "256 by 192, 12 frames a second, 4 seconds, with the canon as sound" }, + { "file": "testcard.webm", "family": "webm", "title": "Test card with a clock, VP9 and Opus in WebM", "features": "256 by 192, 12 frames a second, 4 seconds, with the canon as sound" } + ] + }, + { + "group": "3d", + "source": "generated", + "gen": "make-ifc.py", + "dir": "ifc", + "files": [ + { "file": "house.ifc", "family": "ifc", "title": "Two-storey house (IFC4)", "features": "walls with openings filled by windows and doors, slabs, a gable roof, porch columns, a stair of eleven steps, a table and chairs sharing a mapped representation, spaces, a property set, a material and colours" } + ] + }, + { + "group": "raw", + "source": "fetched", + "ref": "pixls", + "files": [ + { "file": "canon-eos-40d-sraw.cr2", "family": "raw-cr2", "title": "Canon EOS 40D, sRAW (CR2)", "features": "TIFF-based CR2 with a 1936 by 1288 JPEG preview", "fetched": "https://raw.pixls.us/getfile.php/2102/nice/Canon%20-%20EOS%2040D%20-%20sRAW2%20%28sRAW%29%20%283%3A2%29.CR2", "bytes": 5805950, "sha256": "ba644e7dd2abe74eca260e67f0206ff113bf0f62e710f8130611e964d6be5bf1" }, + { "file": "canon-eos-r6.cr3", "family": "raw-cr3", "title": "Canon EOS R6, C-RAW (CR3)", "features": "ISO base media CR3 with a 3408 by 2272 JPEG in its second track; its EXIF names Johannes Kimmel as artist and states CC0", "author": "Uploader to raw.pixls.us; EXIF artist Johannes Kimmel", "fetched": "https://raw.pixls.us/getfile.php/4659/nice/Canon%20-%20EOS%20R6%20-%203%3A2.CR3", "bytes": 5273174, "sha256": "74abb0a113d075ad9887a058082f40dd2a938c4813a08474d82356f11a027778" }, + { "file": "canon-powershot-g1.crw", "family": "raw-crw", "title": "Canon PowerShot G1 (CRW)", "features": "the 2000 CIFF/HEAP container with a 640 by 480 JPEG", "fetched": "https://raw.pixls.us/getfile.php/2073/nice/Canon%20-%20PowerShot%20G1%20-%20RAW%20%284%3A3%29.crw", "bytes": 2300702, "sha256": "f39be5acf14057ce04527fd8d0574e57ec8ed5fed082d7bfaff763075a44ee8a" }, + { "file": "nikon-d70s.nef", "family": "raw-nef", "title": "Nikon D70s (NEF)", "features": "TIFF-based NEF with a 3008 by 2000 JPEG in a SubIFD", "fetched": "https://raw.pixls.us/getfile.php/1117/nice/Nikon%20-%20D70s%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283%3A2%29.NEF", "bytes": 5193213, "sha256": "6c46f9854033e7623ebbb5de8c951df6762ca44f05d45035ccc5cb7e9f4e5692" }, + { "file": "sony-ilce-7s.arw", "family": "raw-arw", "title": "Sony A7S (ARW)", "features": "TIFF-based ARW with a 1616 by 1080 JPEG preview", "fetched": "https://raw.pixls.us/getfile.php/1582/nice/Sony%20-%20ILCE-7S%20-%2014bit%2014bit%20compressed%20%283%3A2%29.ARW", "bytes": 6160384, "sha256": "a35ebb2fbec929daa5beb20d1ce5c15a8aac7b1a7a231455387f3df8a7442e07" }, + { "file": "olympus-c5060wz.orf", "family": "raw-orf", "title": "Olympus C5060WZ (ORF)", "features": "the only picture inside is a 160 by 120 thumbnail", "fetched": "https://raw.pixls.us/getfile.php/1799/nice/Olympus%20-%20C5060WZ%20-%2012bit%20%284%3A3%29.ORF", "bytes": 7646208, "sha256": "3fc12bc3f6320cfab456111d7805b446e329fdd1907680be55e2e163e5e084bd" }, + { "file": "panasonic-dmc-lx7.rw2", "family": "raw-rw2", "title": "Panasonic Lumix DMC-LX7 (RW2)", "features": "RW2 with a 1920 by 1280 JPEG", "fetched": "https://raw.pixls.us/getfile.php/7010/nice/Panasonic%20-%20DMC-LX7%20-%203%3A2.RW2", "bytes": 3394560, "sha256": "18dfc3877e944020299635a8c2fad61fca2485b1c9b67645a495ee870a86e599" }, + { "file": "fujifilm-finepix-s5000.raf", "family": "raw-raf", "title": "Fujifilm FinePix S5000 (RAF)", "features": "RAF container with a 1280 by 960 JPEG", "fetched": "https://raw.pixls.us/getfile.php/2726/nice/Fujifilm%20-%20FinePix%20S5000%20-%204%3A3.RAF", "bytes": 6851240, "sha256": "dabd5e74521a6980156be9fd4b88d0c37b0fe4d0e0e6f5c12db8cffff1b76297" }, + { "file": "minolta-dimage-5.mrw", "group": "unsupported", "family": "raw-mrw", "title": "Minolta DiMAGE 5 (MRW)", "features": "MRW with a 640 by 480 preview whose first byte is stored as zero: the viewer finds no preview in it (kept to show the message)", "fetched": "https://raw.pixls.us/getfile.php/7795/nice/Minolta%20-%20DiMAGE%205%20-%204%3A3.MRW", "bytes": 6422144, "sha256": "d60bfd80bcb1f7b9c88b14a5e46ea27885bbf92d990ab30b3939288678fea0d9" }, + { "file": "adobe-dng-canon-5d3.dng", "family": "raw-dng", "title": "Canon EOS 5D Mark III converted to DNG (lossy JPEG)", "features": "DNG with previews of 256, 1024 and 3960 pixels; converted by Adobe DNG Converter, not straight from a camera", "author": "Uploader to raw.pixls.us; EXIF artist Simon Harhues", "fetched": "https://raw.pixls.us/getfile.php/1033/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossy%20JPEG%20compression%2C%20rgb%20%283%3A2%29.DNG", "bytes": 2484836, "sha256": "b22f1e36331f679abb8b13e433b9bbde3988723adf10fee7aa0dda6381016a98" } + ] + }, + { + "group": "cad", + "source": "fetched", + "files": [ + { "file": "barraca-valenciana-0100-r2010.dwg", "ref": "barraca", "family": "dwg", "title": "Barraca Valenciana, ground floor plan (DWG 2010, Revit export)", "features": "walls, doors, glazing, stairs, furniture, hatches, areas, 24 layers, 56 blocks, extra layouts with title block and viewports", "fetched": "https://zenodo.org/records/14293897/files/BRC-OSP-XXX-P00-PLA-ARQ-0100-tipologiaGen%C3%A8rica-A1-P2.dwg?download=1", "bytes": 284266, "sha256": "fcd3eef78cbd568e3f8a8262378670495a3ea6b8e091cb162fe8ef9c3c9add13" }, + { "file": "barraca-valenciana-0300-r2010.dwg", "ref": "barraca", "family": "dwg", "title": "Barraca Valenciana, elevations (DWG 2010, Revit export)", "features": "elevations drawn as 2D linework with hatches, topography and planting, no 3D", "fetched": "https://zenodo.org/records/14293897/files/BRC-OSP-XXX-ZZZ-PLA-ARQ-0300-tipologiaGen%C3%A8rica-A1-P2.dwg?download=1", "bytes": 1069565, "sha256": "93a211cfb797525a94b5b41aeae3ecf33e61442eea07cadae056463cc400a3e5" }, + { "file": "barraca-valenciana-0400-r2010.dwg", "ref": "barraca", "family": "dwg", "title": "Barraca Valenciana, sections and details (DWG 2010, Revit export)", "features": "42 layers, hatches, arcs, splines, ellipses, 61 blocks", "fetched": "https://zenodo.org/records/14293897/files/BRC-OSP-XXX-ZZZ-PLA-ARQ-0400-tipologiaGen%C3%A8rica-A1-P2.dwg?download=1", "bytes": 511114, "sha256": "a09c492dc34ead8df619d6d618049298afffb050a8436c470055923a90f4b61c" }, + { "file": "building-survey-plans-r2000.dwg", "ref": "zenodo", "family": "dwg", "title": "Survey of a small building: plans, elevations and details (DWG 2000)", "features": "Italian drawing with room names and areas, over 120 layers, 890 blocks, 567 dimensions, 492 hatches", "author": "Giovanni De Gasperis, Sante Dino Facchini, Asif Saeed", "source": "https://zenodo.org/records/15076037", "evidence": "https://zenodo.org/records/15076037", "attribution": "De Gasperis, G., Facchini, S. D., Saeed, A., Building Ledger Dossier: Seismic Prevention and Building Documentation Tracking through a Digital Twin Approach (4_Architectural_Building_Survey.dwg), Zenodo, doi:10.5281/zenodo.15076037, CC BY 4.0. No changes.", "fetched": "https://zenodo.org/records/15076037/files/4_Architectural_Building_Survey.dwg?download=1", "bytes": 2097350, "sha256": "2f096b44cd5b60ded2ad8b12c2f46116db8b1fda1c980f7bb02660a85782ab1d" }, + { "file": "golden-tile-palace-r2004.dwg", "family": "dwg", "title": "Golden Tile Palace: front elevation and section (DWG 2004)", "features": "elevation and section with level marks and Chinese titles, 4393 polylines, 841 arcs, 328 splines, 26 dimensions, 24 hatches", "author": "Jing Zhang, Yunying Ren, Xiaofan An", "source": "https://figshare.com/articles/dataset/Figure_of_surveying_and_mapping_of_Golden_Tile_Palace_/21374221", "evidence": "https://api.figshare.com/v2/articles/21374221", "evidence_quote": "\"name\": \"CC BY 4.0\"", "licence": "CC-BY-4.0", "attribution": "Jing Zhang, Yunying Ren, Xiaofan An, Figure of surveying and mapping of Golden Tile Palace, supplementary file S1 of PLOS ONE doi:10.1371/journal.pone.0262155, figshare, CC BY 4.0. No changes.", "fetched": "https://ndownloader.figshare.com/files/37930432", "bytes": 763903, "sha256": "14d128397f89c1af652b2cd3c9d523db98ae539b1bc061d4f06fba6d21501ae9" }, + { "file": "residential-site-plan-r2007.dwg", "family": "dwg", "title": "Site plan of an old residential community in Beijing (DWG 2007)", "features": "58 layers, 79 blocks, buildings, roads, 2865 polylines, 656 texts, 125 hatches, 39 dimensions", "author": "Yijun Fan", "source": "https://figshare.com/articles/dataset/Drainage_System_Layout_of_Old_Urban_Residential_Communities_In_Beijing/28188725", "evidence": "https://api.figshare.com/v2/articles/28188725", "evidence_quote": "\"name\": \"CC BY 4.0\"", "licence": "CC-BY-4.0", "attribution": "Yijun Fan, Drainage System Layout of Old Urban Residential Communities In Beijing (Site Plan.dwg), figshare, doi:10.6084/m9.figshare.28188725.v1, CC BY 4.0. No changes.", "fetched": "https://ndownloader.figshare.com/files/51611078", "bytes": 948192, "sha256": "37b03757172e5360a03cc26da473cde84e85dbd61914b67741c94a40f7f0db2f" }, + { "file": "hospital-floor-plan-r2013.dwg", "ref": "zenodo", "family": "dwg", "title": "Furnished hospital floor plan (DWG 2013)", "features": "section and level marks, 109 dimensions, 429 block definitions of furniture and plants, 19 layers", "author": "Toka Ehab", "source": "https://zenodo.org/records/10520508", "evidence": "https://zenodo.org/records/10520508", "attribution": "Toka Ehab, Nanotechnology Applications in Interior Design of hospitals to enhance thermal comfort and decrease the infection spread for the occupants (Elite hospital plan.dwg), Zenodo, doi:10.5281/zenodo.10520508, CC BY 4.0. No changes.", "fetched": "https://zenodo.org/records/10520508/files/Elite%20hospital%20plan.dwg?download=1", "bytes": 1249695, "sha256": "b618207b01a1759142e038e1edf762ab8efbe11804bb2e84fc6f472e7e040fb9" }, + { "file": "commercial-building-hvac-plans-r2018.dwg", "ref": "zenodo", "family": "dwg", "title": "Air-conditioning plans of a commercial building (DWG 2018)", "features": "48 layers, 320 blocks, 316 dimensions, 125 hatches, 112 wipeouts", "author": "Kevin Anthony do Bem", "source": "https://zenodo.org/records/10407941", "evidence": "https://zenodo.org/records/10407941", "attribution": "Kevin Anthony do Bem, Projeto de Climatização de um Edifício de Comércio e Serviços (F04 Plantas- Rede aerolica.dwg), Zenodo, doi:10.5281/zenodo.10407941, CC BY 4.0. No changes.", "fetched": "https://zenodo.org/records/10407941/files/F04%20Plantas-%20Rede%20aerolica.dwg?download=1", "bytes": 506884, "sha256": "bcf3b92e0afdf29bb75e7632e8193f881918ddabb4de67a1331b5eda34ee9be2" }, + { "file": "ezdxf-dim-linear-tutorial-r2013.dwg", "ref": "ezdxf", "family": "dwg", "title": "ezdxf documentation: linear dimension tutorial (DWG 2013)", "features": "16 dimensions, 16 multileaders, 13 dimension styles, 27 text styles", "source": "https://github.com/mozman/ezdxf/blob/748e211c9a0b977b9e15845a6bcad8e1b9743e26/docs/graphics/dim_linear_tutorial.dwg", "fetched": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/docs/graphics/dim_linear_tutorial.dwg", "bytes": 36391, "sha256": "88037b595bd46d81c5c98ce7aa428ce6b8506d1aa4602e976811cfb154005155" }, + { "file": "hospital-ward-wing-plan-section.dxf", "family": "dxf", "title": "Hospital ward wing, plan and section (DXF 2010)", "features": "542 polylines, 17 layers of the A-* scheme, 3 blocks, a paper-space viewport", "author": "Innerscene (original model)", "source": "https://www.innerscene.com/tools/library/drawings/hospital-ward-wing-1200-square-metres-plan-and-section-73a3f4dd", "evidence": "https://www.innerscene.com/tools/library/drawings/hospital-ward-wing-1200-square-metres-plan-and-section-73a3f4dd", "evidence_quote": "Original Innerscene model, released CC0.", "licence": "CC0-1.0", "attribution": "", "fetched": "https://www.innerscene.com/api/library/hospital-ward-wing-1200-square-metres-plan-and-section-73a3f4dd/download", "bytes": 307408, "sha256": "4fabf62d6d7bbc7c8bdc5f3c9d292d4980c3ec504281b0bd77f487d3c6bb9ba3" }, + { "file": "grocery-store-plan-section.dxf", "family": "dxf", "title": "Grocery store, plan and section (DXF 2010)", "features": "234 lines, 150 polylines, 17 layers, 3 blocks, a viewport", "author": "Innerscene (original model)", "source": "https://www.innerscene.com/tools/library/drawings/grocery-store-600-square-metres-plan-and-section-2a67d041", "evidence": "https://www.innerscene.com/tools/library/drawings/grocery-store-600-square-metres-plan-and-section-2a67d041", "evidence_quote": "Original Innerscene model, released CC0.", "licence": "CC0-1.0", "attribution": "", "fetched": "https://www.innerscene.com/api/library/grocery-store-600-square-metres-plan-and-section-2a67d041/download", "bytes": 130840, "sha256": "0b875304e1769e0f7c9377abf270f1fb4d103547cc27a3795a57c34fb4619d30" }, + { "file": "ezdxf-uncommon-entities.dxf", "ref": "ezdxf", "family": "dxf", "title": "ezdxf example: uncommon entities (DXF 2018)", "features": "13 dimensions, 2 multileaders, large radial dimensions, rays, construction lines, splines, 33 blocks, 2 extra layouts", "source": "https://github.com/mozman/ezdxf/blob/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/uncommon.dxf", "fetched": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/uncommon.dxf", "bytes": 343110, "sha256": "5efdd671e3b6d8c45bd0688b1d6479d523f2290f0b390f1584348bf46e34c965" }, + { "file": "ezdxf-hatches-1.dxf", "ref": "ezdxf", "family": "dxf", "title": "ezdxf example: solid hatches (DXF 2018)", "features": "ten solid hatches over polylines and arcs", "source": "https://github.com/mozman/ezdxf/blob/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/hatches_1.dxf", "fetched": "https://raw.githubusercontent.com/mozman/ezdxf/748e211c9a0b977b9e15845a6bcad8e1b9743e26/examples_dxf/hatches_1.dxf", "bytes": 130334, "sha256": "2dc2930b4f7d5fb16698437e4f6972986e580a81b2b5d36a780b4311f85fb326" } + ] + }, + { + "group": "3d", + "source": "fetched", + "files": [ + { "file": "bsi-building-architecture-ifc2x3.ifc", "ref": "bsi", "family": "ifc", "title": "buildingSMART Simple-Scene Building-Architecture (IFC2X3)", "features": "a small SketchUp house: walls, slabs, a roof, spaces, materials with colours, quantity and property sets; faceted breps", "fetched": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/IFC%202.3.0.1%20%28IFC%202x3%20TC1%29/Simple-Scene/Building-Architecture.ifc", "bytes": 92542, "sha256": "c4db65ba847f6b369a95d6c54fa11f4750cbe6d59f021934e8923d8d578e5885" }, + { "file": "bsi-building-architecture-ifc4x3.ifc", "ref": "bsi", "family": "ifc", "title": "buildingSMART Simple-Scene Building-Architecture (IFC4X3_ADD2)", "features": "the same house in IFC 4.3, with triangulated faces", "fetched": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/IFC%204.3.2.0%20%28IFC%204.3%20ADD2%29/Simple-Scene/Building-Architecture.ifc", "bytes": 141965, "sha256": "96e7103812b15b65eb1fd7802d36b9962833485dce5ead4f440bbf529530881d" }, + { "file": "bsi-wall-with-opening-and-window-ifc4.ifc", "ref": "bsi", "family": "ifc", "title": "buildingSMART reference view: a wall with an opening and a window (IFC4)", "features": "a wall with a material layer set, an opening element and a window with its type", "author": "Peter Bonsma, RDF Ltd. (file header), distributed by buildingSMART International Ltd.", "fetched": "https://raw.githubusercontent.com/buildingSMART/Sample-Test-Files/80d976a9b193a26a8e928c3e79bff67af1de68a8/IFC%204.0.2.1%20%28IFC%204%20ADD2%20TC1%29/ISO%20Spec%20-%20ReferenceView_V1.2/wall-with-opening-and-window.ifc", "bytes": 12492, "sha256": "73b0e45d931d5dc13bfee5fdc7bd80f796526445458b2de74c4168d209097832" }, + { "file": "duplex-apartment-ifc2x3.ifc", "family": "ifc", "title": "Duplex Apartment (IFC2X3, Revit export)", "features": "four storeys: 57 walls, 24 windows, 14 doors, 2 stairs, 21 spaces, furniture, 18 materials", "author": "Provided to the US Army Construction Engineering Research Laboratory, republished by buildingSMART International", "source": "https://github.com/buildingsmart-community/Community-Sample-Test-Files/tree/7ddf57a201f88a0c213d5322b02ed15e94a60a40/IFC%202.3.0.1%20(IFC%202x3)/Duplex%20Apartment", "evidence": "https://raw.githubusercontent.com/buildingsmart-community/Community-Sample-Test-Files/7ddf57a201f88a0c213d5322b02ed15e94a60a40/IFC%202.3.0.1%20%28IFC%202x3%29/Duplex%20Apartment/README.md", "evidence_quote": "Creative Commons (CC BY 4.0). You are free (1) to copy and resitribute this material any way you like", "licence": "CC-BY-4.0", "attribution": "BSI (2020) Duplex Apartment Test Files, buildingSMART International, https://github.com/buildingsmart-community/Community-Sample-Test-Files, CC BY 4.0. No changes.", "fetched": "https://media.githubusercontent.com/media/buildingsmart-community/Community-Sample-Test-Files/7ddf57a201f88a0c213d5322b02ed15e94a60a40/IFC%202.3.0.1%20%28IFC%202x3%29/Duplex%20Apartment/Duplex_A_20110907.ifc", "bytes": 2380763, "sha256": "b347a2c8aa8fff6db896a4417a9c50c22ac0ccd7c5cfc22b99b8d29336c606ed" }, + { "file": "barraca-valenciana-ifc4.ifc", "ref": "barraca", "family": "ifc", "title": "Barraca Valenciana de Llauradors, BIM model (IFC4, Revit export)", "features": "a traditional farmhouse: 2 storeys, 16 walls, 5 slabs, 2 roofs, a stair, 27 beams, 28 materials", "fetched": "https://zenodo.org/records/14293897/files/BRC-OSP-XXX-ZZZ-M3D-ARQ-0001-tipologiaGen%C3%A8rica-S3-0001.ifc?download=1", "bytes": 1584183, "sha256": "b6f8828e9bd2491dc452b2ab78f9f9e28f39bd81a5f2f4e2cdb2d81668287193" }, + { "file": "office-archetype-1970-ifc4x3.ifc", "ref": "zenodo", "family": "ifc", "title": "Office archetype of 1970 (IFC4X3_ADD2)", "features": "four storeys: 88 walls, 167 windows, 49 doors, 16 curtain walls, 48 spaces", "author": "Verena Dannapfel, Moritz Müller, Rita Streblow, Dirk Müller et al., RWTH Aachen University", "source": "https://zenodo.org/records/21820756", "evidence": "https://zenodo.org/records/21820756", "attribution": "Dannapfel, V., Müller, M., Streblow, R., Müller, D. et al., Archetypes for German Office Buildings of Construction Years 1970, 1998 and 2026, Zenodo, doi:10.5281/zenodo.21820756, CC BY 4.0. No changes.", "fetched": "https://zenodo.org/records/21820756/files/2026-07_Klassiqua_Office_Archetype_Construction_Year_1970.ifc?download=1", "bytes": 1471699, "sha256": "a5f12304d33fe5da9174c8ea9827d2f322706646b3da2766dff0ab278c65df3c" }, + { "file": "fox.glb", "ref": "khronos", "family": "glb", "title": "Fox, skinned and animated (GLB)", "features": "a low-poly fox with a skin, three animations and a texture", "author": "PixelMannen (model, CC0), tomkranis (rigging and animation, CC BY 4.0), glTF conversion by Asobo Studio and the Khronos Group", "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/Fox", "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/Fox/README.md", "evidence_quote": "© 2014, tomkranis. [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode)", "attribution": "Fox: model by PixelMannen (CC0, https://opengameart.org/content/fox-and-shiba), rigging and animation by tomkranis (CC BY 4.0, https://sketchfab.com/3d-models/low-poly-fox-by-pixelmannen-animated-371dea88d7e04a76af5763f2a36866bc), glTF conversion by Asobo Studio and the Khronos Group. Unmodified.", "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/Fox/glTF-Binary/Fox.glb", "bytes": 162852, "sha256": "d97044e701822bac5a62696459b27d7b375aada5de8574ed4362edbba94771f7" }, + { "file": "riggedfigure.gltf", "ref": "khronos", "family": "gltf", "title": "Rigged Figure (glTF with an embedded buffer)", "features": "a skinned figure, a data URI buffer", "author": "Cesium", "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/RiggedFigure", "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/RiggedFigure/README.md", "evidence_quote": "© 2017, Cesium. [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode)", "attribution": "Rigged Figure by Cesium, CC BY 4.0, https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/RiggedFigure. Unmodified.", "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/RiggedFigure/glTF-Embedded/RiggedFigure.gltf", "bytes": 99902, "sha256": "354572cfef17b136b437ac9749da0fa316fd6e42ee7d62fe50a696b25f5047e9" }, + { "file": "vertexcolortest.glb", "ref": "khronos", "family": "glb", "title": "Vertex Color Test (GLB)", "features": "COLOR_0 vertex colours, tangents, two textures", "author": "Ed Mackey, Analytical Graphics, Inc. (Cesium)", "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/VertexColorTest", "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/VertexColorTest/README.md", "evidence_quote": "© 2018, Analytical Graphics, Inc.. [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode)", "attribution": "Vertex Color Test by Ed Mackey, Copyright 2018 Analytical Graphics, Inc., CC BY 4.0, https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/VertexColorTest. Unmodified.", "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/VertexColorTest/glTF-Binary/VertexColorTest.glb", "bytes": 26220, "sha256": "58006bdcff8084339da0f6e24400160890638c16dbcb83c362ccaf150e8c6e10" }, + { "file": "metalroughspheres.glb", "ref": "khronos", "family": "glb", "title": "Metal-Rough spheres (GLB)", "features": "119 nodes and 98 materials of growing metalness and roughness", "author": "Krill Gavrilov / Draw Harness script (Open CASCADE Technology)", "licence": "CC0-1.0", "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/MetalRoughSpheresNoTextures", "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/MetalRoughSpheresNoTextures/README.md", "evidence_quote": "© 2019, Public. [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode)", "attribution": "", "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/MetalRoughSpheresNoTextures/glTF-Binary/MetalRoughSpheresNoTextures.glb", "bytes": 291316, "sha256": "5e677f260ec0f366967a6e9545de2f3dab2a160e307b9bcb9d050cd2028f63f8" }, + { "file": "iridescencesuzanne.glb", "ref": "khronos", "family": "glb", "title": "Iridescence Suzanne (GLB)", "features": "Suzanne with the iridescence, transmission and volume extensions", "author": "Mathias Kanzler (UX3D), Pascal Schoen", "licence": "CC0-1.0", "source": "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/IridescenceSuzanne", "evidence": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/IridescenceSuzanne/README.md", "evidence_quote": "© 2022, UX3D. [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode)", "attribution": "", "fetched": "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/edc7c9e67c639d230715049ee31f9a96a6babbbe/Models/IridescenceSuzanne/glTF-Binary/IridescenceSuzanne.glb", "bytes": 507608, "sha256": "866762602d60a0942b7608bfabf4e8686a29033636224f4b55c5e56abe8abf8f" }, + { "file": "ratchet-cam.stl", "family": "stl", "title": "Apollo 11 hatch, stationary ratchet cam (binary STL)", "features": "1536 triangles, a mechanical part", "author": "NASA Johnson Space Center, transferred to the Smithsonian Institution; model published on Wikimedia Commons", "source": "https://commons.wikimedia.org/wiki/File:Stationary_Ratchet_Cam_v11_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl", "evidence": "https://commons.wikimedia.org/wiki/File:Stationary_Ratchet_Cam_v11_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl", "evidence_quote": "CC0 1.0 Universal Public Domain Dedication", "licence": "CC0-1.0", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/e/e0/Stationary_Ratchet_Cam_v11_%28Hatch%2C_Crew%2C_Apollo_11_by_NASA_and_Smithsonian_Institution%29.stl", "bytes": 76884, "sha256": "b42d79fb357413b118b973f3fa40b76ae2999451537cd991bebce18b486c581f" }, + { "file": "ratchet-spur-gear.stl", "family": "stl", "title": "Apollo 11 hatch, stationary ratchet spur gear (binary STL)", "features": "3026 triangles, a gear with 19 teeth", "author": "NASA Johnson Space Center, transferred to the Smithsonian Institution; model published on Wikimedia Commons", "source": "https://commons.wikimedia.org/wiki/File:N=19,_P=28_-_Stat._Ratchet_Spur_Gear_v13_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl", "evidence": "https://commons.wikimedia.org/wiki/File:N=19,_P=28_-_Stat._Ratchet_Spur_Gear_v13_(Hatch,_Crew,_Apollo_11_by_NASA_and_Smithsonian_Institution).stl", "evidence_quote": "CC0 1.0 Universal Public Domain Dedication", "licence": "CC0-1.0", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/c/cb/N%3D19%2C_P%3D28_-_Stat._Ratchet_Spur_Gear_v13_%28Hatch%2C_Crew%2C_Apollo_11_by_NASA_and_Smithsonian_Institution%29.stl", "bytes": 151384, "sha256": "c99eca56ab40ed6b2e9dfc49b88c23c6fbef982ff18813ba593d3e138ef1f51d" }, + { "file": "petri-dish-holder-base.stl", "ref": "zenodo", "family": "stl", "title": "Petri dish holder base of a spore capture device (ASCII STL)", "features": "566 facets as text", "author": "Kevin A. Adkins, Kaiyu Li, M. N. Blasko, J. L. Cabrera, B. H. Neal, James et al.", "source": "https://zenodo.org/records/12193898", "evidence": "https://zenodo.org/api/records/12193898", "evidence_quote": "\"license\": {\"id\": \"cc-by-4.0\"}", "attribution": "Components of the spore capture device in A simple mechanism for uncrewed aircraft bioaerosol sampling in the lower atmosphere (Inspire1Petri_Dish_Holder_Base.stl), Kevin A. Adkins et al., Zenodo 2024, doi:10.5281/zenodo.12193898, CC BY 4.0. No changes.", "fetched": "https://zenodo.org/api/records/12193898/files/Inspire1Petri_Dish_Holder_Base.stl/content", "bytes": 151270, "sha256": "d654bf60064e79675acd9694726282c70251905d77391ad45c29d3ba3ecaf93e" }, + { "file": "chickpea-mesh.ply", "ref": "zenodo", "family": "ply", "title": "Photogrammetric mesh of a chickpea plant (binary PLY with colours)", "features": "8788 triangles, RGBA per vertex", "author": "William T. Salter, Arjina Shrestha, Margaret M. Barbour", "source": "https://zenodo.org/records/4018242", "evidence": "https://zenodo.org/api/records/4018242", "evidence_quote": "\"license\": {\"id\": \"cc-by-4.0\"}", "attribution": "3D photogrammetric reconstructions of individual chickpea plants (PBASlasher_S3s_Wk1_meshed.ply), William T. Salter, Arjina Shrestha and Margaret M. Barbour, Zenodo 2020, doi:10.5281/zenodo.4018242, CC BY 4.0. No changes.", "fetched": "https://zenodo.org/api/records/4018242/files/PBASlasher_S3s_Wk1_meshed.ply/content", "bytes": 202701, "sha256": "ef9cd2141897781c8e3e6a72ac316056b991d4d0fae99e053c4695fceea8cb75" }, + { "file": "ruthwell-lamb-of-god.ply", "ref": "zenodo", "family": "ply", "title": "Lamb of God panel of the Ruthwell Cross (binary PLY with normals and colours)", "features": "33020 triangles, a scanned stone carving", "author": "Gurpreet Singh", "source": "https://zenodo.org/records/3348342", "evidence": "https://zenodo.org/api/records/3348342", "evidence_quote": "\"license\": {\"id\": \"cc-by-4.0\"}", "attribution": "Small Panel PLY (Lamb of God panel from the Ruthwell Cross), Gurpreet Singh, Zenodo 2019, doi:10.5281/zenodo.3348342, CC BY 4.0. No changes.", "fetched": "https://zenodo.org/api/records/3348342/files/Small_Lamb_Of_God_North_Ruthwell_Cross.ply/content", "bytes": 893893, "sha256": "0d40a4305885932596afce091891a8f4b212b0c012b742db10d4487351c2457a" }, + { "file": "chickpea-pointcloud-ascii.ply", "group": "unsupported", "ref": "zenodo", "family": "ply", "title": "Point cloud of a chickpea plant (ASCII PLY without faces)", "features": "53195 coloured points and no faces: kept to show what the viewer says about a point cloud", "author": "William T. Salter, Arjina Shrestha, Margaret M. Barbour", "source": "https://zenodo.org/records/4018242", "evidence": "https://zenodo.org/api/records/4018242", "evidence_quote": "\"license\": {\"id\": \"cc-by-4.0\"}", "attribution": "3D photogrammetric reconstructions of individual chickpea plants (SonSla_3_groundtruth_pointcloud.ply), William T. Salter, Arjina Shrestha and Margaret M. Barbour, Zenodo 2020, doi:10.5281/zenodo.4018242, CC BY 4.0. No changes.", "fetched": "https://zenodo.org/api/records/4018242/files/SonSla_3_groundtruth_pointcloud.ply/content", "bytes": 3455582, "sha256": "53a08f6e1c831ac58c42342ee257c97730bcf6997e246c88198478f9aeba4fe0" } + ] + }, + { + "group": "pdf", + "source": "fetched", + "files": [ + { "file": "arxiv-rational-cherednik-torus-knots.pdf", "family": "pdf", "title": "Rational Cherednik Algebras and Torus Knot Invariants (arXiv:2402.18770)", "features": "28 pages of mathematics, 24 outline entries, 256 links, set in LaTeX", "author": "Xinchun Ma", "source": "https://arxiv.org/abs/2402.18770v1", "evidence": "https://arxiv.org/abs/2402.18770v1", "evidence_quote": "href=\"http://creativecommons.org/licenses/by/4.0/\" title=\"Rights to this article\"", "licence": "CC-BY-4.0", "attribution": "Xinchun Ma, Rational Cherednik Algebras and Torus Knot Invariants, arXiv:2402.18770v1, CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Not modified.", "fetched": "https://arxiv.org/pdf/2402.18770v1", "bytes": 430745, "sha256": "2b22841d8a64b271d7def6fa15aa0b5b98f533ae259e4108eb7104320f07cf10" }, + { "file": "nist-sp-800-63-4-digital-identity.pdf", "family": "pdf", "title": "NIST SP 800-63-4, Digital Identity Guidelines", "features": "96 tagged pages, 54 outline entries, 227 links", "author": "David Temoshok, Diana Proud-Madruga, Yee-Yin Choong et al., NIST", "source": "https://csrc.nist.gov/pubs/sp/800/63/4/final", "evidence": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-4.pdf", "evidence_quote": "is not subject to copyright in the United States.", "licence": "public-domain (US federal government work, 17 U.S.C. 105; the PDF says so itself)", "attribution": "", "fetched": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-4.pdf", "bytes": 858054, "sha256": "2f5b107de218a0fc3fe7f7e91b2ece5babb001eda5745b7c568a3cc876002f2d" }, + { "file": "scan-cfa-minutes-1971.pdf", "family": "pdf-scan", "title": "Minutes of the U.S. Commission of Fine Arts, 1970 to 1972 (scan)", "features": "40 scanned pages: JPEG 2000 pictures and JBIG2 masks, an OCR text layer, PDF/A-2B", "author": "U.S. Commission of Fine Arts; scanned by the Internet Archive", "source": "https://archive.org/details/cfaminutes21april1971", "evidence": "https://commons.wikimedia.org/wiki/File:July_8_1970_to_May_19_1972_(IA_cfaminutes21april1971).pdf", "evidence_quote": "This work is in the public domain in the United States because it is a work prepared by an officer o", "licence": "public-domain (US federal government work, 17 U.S.C. 105)", "attribution": "", "fetched": "https://archive.org/download/cfaminutes21april1971/cfaminutes21april1971.pdf", "bytes": 1104628, "sha256": "2fc5edb8a4f303b89449443dcb5a32249c79b2a8d67253348025d0e7eea5f94b" }, + { "file": "scan-blm-land-survey-records.pdf", "family": "pdf-scan", "title": "Land and survey records, Bureau of Land Management (scan)", "features": "40 pages scanned at about 500 ppi: JPEG 2000 and JBIG2 images, an OCR text layer, PDF/A-2B", "author": "United States Bureau of Land Management; scanned by the Internet Archive", "source": "https://archive.org/details/landsurveyrecord00unit", "evidence": "https://commons.wikimedia.org/wiki/File:Land_%26_survey_records_-_record_preservation_%26_service_through_microfilm_(IA_landsurveyrecord00unit).pdf", "evidence_quote": "This work is in the public domain in the United States because it is a work prepared by an officer o", "licence": "public-domain (US federal government work, 17 U.S.C. 105)", "attribution": "", "fetched": "https://archive.org/download/landsurveyrecord00unit/landsurveyrecord00unit.pdf", "bytes": 2192413, "sha256": "d9a9e6583d0afb1688a043ef59a8d2e236e20c58d4ba76ff625d7c11a181c16c" }, + { "file": "form-uscis-i9.pdf", "family": "pdf-form", "title": "USCIS Form I-9, Employment Eligibility Verification", "features": "115 text fields, 5 drop-down lists, 8 check boxes, links; a tagged government form", "author": "U.S. Citizenship and Immigration Services (DHS)", "source": "https://www.uscis.gov/i-9", "evidence": "https://www.uscis.gov/website-policies", "evidence_quote": "Information presented on this WWW site is considered public information and may be distributed or co", "licence": "public-domain (US federal government work, 17 U.S.C. 105)", "attribution": "", "fetched": "https://www.uscis.gov/sites/default/files/document/forms/i-9.pdf", "bytes": 524095, "sha256": "780f348c34df694bb0b4dbbfaf9f22b99b9757b80d16a37ba89aadf069597281" }, + { "file": "map-nps-canyonlands.pdf", "family": "pdf-map", "title": "Canyonlands National Park, Island in the Sky: roads and trails", "features": "two tagged pages of vector map", "author": "National Park Service", "source": "https://home.nps.gov/cany/planyourvisit/maps.htm", "evidence": "https://www.nps.gov/aboutus/disclaimer.htm", "evidence_quote": "material created by the National Park Service and presented on this website, unless otherwise indica", "licence": "public-domain (US federal government work, 17 U.S.C. 105)", "attribution": "", "fetched": "https://home.nps.gov/cany/planyourvisit/upload/IslandRoadsandTrailsUpdated24-508forweb.pdf", "bytes": 349001, "sha256": "b29a639d9bc0d2e37ca41b68c21bcddba7637b1727dce86980c3373715610d55" }, + { "file": "slides-unige-open-access.pdf", "ref": "zenodo", "family": "pdf", "title": "Open Access publishing at no cost thanks to UNIGE agreements (slides)", "features": "ten slides with outline, links, tags and pictures", "author": "Floriane Muller, Anouk Santos (University of Geneva)", "source": "https://zenodo.org/records/23478694", "evidence": "https://zenodo.org/records/23478694", "attribution": "Floriane Muller and Anouk Santos, Open Access publishing at no cost thanks to UNIGE agreements, in 15 minutes, Zenodo, https://doi.org/10.5281/zenodo.23478694, CC BY 4.0. Not modified.", "fetched": "https://zenodo.org/api/records/23478694/files/2026-10-20_Read_and_publish_en15min_EN_zenodo.pdf/content", "bytes": 800339, "sha256": "03356ed569a80f7d7dc1d96fcbfd75a2fbc663c63f6d74f4e89eb2390bb5ee64" }, + { "file": "paper-ja-labor-law.pdf", "ref": "zenodo", "family": "pdf", "title": "A paper in Japanese on labour law and the constitution", "features": "25 pages with embedded MS Mincho fonts", "author": "Takashi Fujiki (藤木 è²´å�²)", "source": "https://zenodo.org/records/23469422", "evidence": "https://zenodo.org/records/23469422", "attribution": "藤木 è²´å�², アメリカ労åƒ�法ã�‹ã‚‰ã�¿ã�Ÿæ†²æ³•ã�®å½±éŸ¿, アメリカ憲法ã�®ç�†è«–ã�¨å®Ÿè·µ 創刊å�·, Zenodo, https://doi.org/10.5281/zenodo.23469422, CC BY 4.0. Not modified.", "fetched": "https://zenodo.org/api/records/23469422/files/%E3%82%A2%E3%83%A1%E3%83%AA%E3%82%AB%E5%8A%B4%E5%83%8D%E6%B3%95%E3%81%8B%E3%82%89%E3%81%BF%E3%81%9F%E6%86%B2%E6%B3%95%E3%81%AE%E5%BD%B1%E9%9F%BF.pdf/content", "bytes": 881101, "sha256": "08b2be3ebc781107f71003344ccf8b230229079e978687f1e0da2329bef1cbe9" } + ] + }, + { + "group": "images", + "source": "fetched", + "files": [ + { "file": "monet-gardeners-house-antibes.jpg", "ref": "cleveland", "family": "jpeg", "title": "Claude Monet, The Gardener's House at Antibes (1888)", "features": "a painting, JPEG from the museum's web size", "author": "Claude Monet, Cleveland Museum of Art (open access)", "source": "https://clevelandart.org/art/1916.1044", "evidence": "https://clevelandart.org/art/1916.1044", "fetched": "https://openaccess-cdn.clevelandart.org/1916.1044/1916.1044_web.jpg", "bytes": 404254, "sha256": "95142eba48d54f01352eb4ef6f04260cb3df3bcb1d9c86000c1284a7817674ca" }, + { "file": "queen-mab-cave.jpg", "ref": "cleveland", "family": "jpeg", "title": "Queen Mab's Cave, in the manner of Turner", "features": "a painting", "author": "Imitator of J. M. W. Turner, Cleveland Museum of Art (open access)", "source": "https://clevelandart.org/art/1916.1036", "evidence": "https://clevelandart.org/art/1916.1036", "fetched": "https://openaccess-cdn.clevelandart.org/1916.1036/1916.1036_web.jpg", "bytes": 218321, "sha256": "b84e005332288229d808172a7610b501d9977c0b4668e333faea5ff780157223" }, + { "file": "rembrandt-st-francis.jpg", "ref": "cleveland", "family": "jpeg", "title": "Rembrandt, Saint Francis Beneath a Tree Praying (etching)", "features": "an etching, fine line work", "author": "Rembrandt van Rijn, Cleveland Museum of Art (open access)", "source": "https://clevelandart.org/art/2024.64", "evidence": "https://clevelandart.org/art/2024.64", "fetched": "https://openaccess-cdn.clevelandart.org/2024.64/2024.64_web.jpg", "bytes": 370411, "sha256": "74b37aa3738914dd66c07533b3758f7694fefbaa918d2f23c263205cf987db7b" }, + { "file": "nolli-rome-plate.jpg", "ref": "cleveland", "family": "jpeg", "title": "Nolli's map of Rome (1748), a plate", "features": "an engraved city plan", "author": "Giovanni Battista Nolli et al., Cleveland Museum of Art (open access)", "source": "https://clevelandart.org/art/2020.276.3", "evidence": "https://clevelandart.org/art/2020.276.3", "fetched": "https://openaccess-cdn.clevelandart.org/2020.276.3/2020.276.3_web.jpg", "bytes": 322943, "sha256": "7643c9e59f7706ebe62518a51daf7d72129acc8092dde7afc65516d3d11b6c77" }, + { "file": "pallid-swift.jpg", "family": "jpeg", "title": "Pallid swift in flight (photograph)", "features": "a camera JPEG with EXIF data (Canon EOS R6 Mark II)", "author": "Hobbyfotowiki", "source": "https://commons.wikimedia.org/wiki/File:Pallid_swift_(in_flight).jpg", "evidence": "https://commons.wikimedia.org/wiki/File:Pallid_swift_(in_flight).jpg", "evidence_quote": "This file is made available under the Creative Commons CC0 1.0 Universal Public Domain Dedication.", "licence": "CC0-1.0", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/d/df/Pallid_swift_%28in_flight%29.jpg", "bytes": 356572, "sha256": "9d0f79f12dcc236f549cdefb66dc468f25335d444ebd26e98805efdd94afecbd" }, + { "file": "cyclotron-patent.png", "family": "png", "title": "Drawing from Lawrence's cyclotron patent", "features": "a black and white technical drawing", "author": "Ernest O. Lawrence (US Patent Office)", "source": "https://commons.wikimedia.org/wiki/File:Cyclotron_patent_p1.png", "evidence": "https://commons.wikimedia.org/wiki/File:Cyclotron_patent_p1.png", "evidence_quote": "The text and illustrations of US patents published before March 1, 1989 are in the public domain unl", "licence": "public-domain (US patent text and drawings published before 1989)", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/4/42/Cyclotron_patent_p1.png", "bytes": 207304, "sha256": "0be9b59ae21bbadeb8a13fafde5e0bcfb6d470ba516244eab0ec1c74afd7394f" }, + { "file": "mature-flower-diagram.svg", "family": "svg", "title": "Diagram of a mature flower", "features": "85 texts, about 90 gradient references, many paths", "author": "Mariana Ruiz Villarreal (LadyofHats)", "source": "https://commons.wikimedia.org/wiki/File:Mature_flower_diagram.svg", "evidence": "https://commons.wikimedia.org/wiki/File:Mature_flower_diagram.svg", "evidence_quote": "This work has been released into the public domain by its author, LadyofHats.", "licence": "public-domain (released by its author)", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/7/7f/Mature_flower_diagram.svg", "bytes": 123527, "sha256": "b40bd4e188c0a99e51e994b4f39f931d3fca28093ecb6454aa2bd93c575ea999" }, + { "file": "entamoeba-life-cycle.svg", "family": "svg", "title": "Life cycle of Entamoeba histolytica", "features": "547 paths, text and arrows", "author": "Mariana Ruiz Villarreal (LadyofHats)", "source": "https://commons.wikimedia.org/wiki/File:Entamoeba_histolytica_life_cycle-en.svg", "evidence": "https://commons.wikimedia.org/wiki/File:Entamoeba_histolytica_life_cycle-en.svg", "evidence_quote": "This work has been released into the public domain by its author, LadyofHats.", "licence": "public-domain (released by its author)", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/a/a1/Entamoeba_histolytica_life_cycle-en.svg", "bytes": 273234, "sha256": "68a4a92289d81f0e7e9361abbba2da9a8c8b4e440e3ad9de5c420646eb133eaa" }, + { "file": "sieve-of-eratosthenes.svg", "family": "svg", "title": "Sieve of Eratosthenes, animated", "features": "SMIL animation with 179 animate elements", "author": "Ricordisamoa, with later edits by Jc86035, HereToHelp and Glrx", "source": "https://commons.wikimedia.org/wiki/File:Sieve_of_Eratosthenes_animation.svg", "evidence": "https://commons.wikimedia.org/wiki/File:Sieve_of_Eratosthenes_animation.svg", "evidence_quote": "This work is ineligible for copyright and therefore in the public domain because it consists entirel", "licence": "public-domain (ineligible for copyright, Wikimedia Commons PD-ineligible)", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/0/0b/Sieve_of_Eratosthenes_animation.svg", "bytes": 22054, "sha256": "d8f1d6d0a4b5710ebe1e7766eab30b766b10dc9380c1058ccb39baccd7d51586" }, + { "file": "muybridge-horse.gif", "family": "gif", "title": "Muybridge's horse in motion, animated GIF", "features": "15 frames", "author": "Eadweard Muybridge (photographs, 1887); animation by Waugsberg", "source": "https://commons.wikimedia.org/wiki/File:Muybridge_race_horse_animated.gif", "evidence": "https://commons.wikimedia.org/wiki/File:Muybridge_race_horse_animated.gif", "evidence_quote": "The author died in 1904, so this work is in the public domain in its country of origin and other cou", "licence": "public-domain (the author died in 1904)", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/d/dd/Muybridge_race_horse_animated.gif", "bytes": 568203, "sha256": "eb40bebc1f039e7c2a121673c3b55d4aa73582f858053dbbcca9ebce9e2b479d" }, + { "file": "geneva-mechanism.gif", "family": "gif", "title": "Geneva mechanism with six spokes, animated GIF", "features": "101 frames", "author": "Mike1024", "source": "https://commons.wikimedia.org/wiki/File:Geneva_mechanism_6spoke_animation.gif", "evidence": "https://commons.wikimedia.org/wiki/File:Geneva_mechanism_6spoke_animation.gif", "evidence_quote": "I, the copyright holder of this work, release this work into the public domain.", "licence": "public-domain (released by its author)", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/9/9b/Geneva_mechanism_6spoke_animation.gif", "bytes": 229346, "sha256": "d7df5c9984109c2ca60f7aab1a51db1f0b578cddcdede5d43be9f67536e2a1ee" }, + { "file": "abundance-nemo-g4-6pages.tif", "family": "tiff-fax", "title": "Les grans et merveilleux faictz du seigneur Nemo (scan, six pages, CCITT Group 4)", "features": "a multi-page bilevel TIFF scanned from a 16th-century book", "author": "Jean d'Abundance (16th century); scanned by the Bibliothèque nationale de France", "source": "https://commons.wikimedia.org/wiki/File:Abundance_-_Les_grans_et_merveilleux_faictz_du_seigneur_Nemo%2C_avec_les_privil%C3%A8ges_qu%E2%80%99il_a.tif", "evidence": "https://commons.wikimedia.org/wiki/File:Abundance_-_Les_grans_et_merveilleux_faictz_du_seigneur_Nemo%2C_avec_les_privil%C3%A8ges_qu%E2%80%99il_a.tif", "evidence_quote": "This file is in the public domain because it is a mere mechanical scan or photocopy of a public doma", "licence": "public-domain (a mechanical scan of a public domain book, Wikimedia Commons PD-scan)", "attribution": "", "fetched": "https://upload.wikimedia.org/wikipedia/commons/b/b5/Abundance_-_Les_grans_et_merveilleux_faictz_du_seigneur_Nemo%2C_avec_les_privil%C3%A8ges_qu%E2%80%99il_a.tif", "bytes": 330420, "sha256": "38bab27c6d559e11468d6c7e4222f0bb1bee4ba0044540adcebfc012930e00d1" } + ] + }, + { + "group": "office", + "source": "fetched", + "files": [ + { "file": "docx4j-getting-started.docx", "ref": "docx4j", "family": "docx", "title": "docx4j Getting Started (Word)", "features": "a manual with styles, tables, pictures and links", "source": "https://github.com/plutext/docx4j/blob/c95b55be6832ff716131ffdbd7006755db92f696/docs/Docx4j_GettingStarted.docx", "fetched": "https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/docs/Docx4j_GettingStarted.docx", "bytes": 156287, "sha256": "a518cffcbef0b60cf5c95930acd9af9a56484e47376c5f0c4d6e3a15a0799941" }, + { "file": "docx4j-two-charts.docx", "ref": "docx4j", "family": "docx", "title": "docx4j chart test: two charts (Word)", "features": "two native charts in a document", "source": "https://github.com/plutext/docx4j/blob/c95b55be6832ff716131ffdbd7006755db92f696/docx4j-chart-render/src/test/resources/charts/render/office/p3-two-charts.docx", "fetched": "https://raw.githubusercontent.com/plutext/docx4j/c95b55be6832ff716131ffdbd7006755db92f696/docx4j-chart-render/src/test/resources/charts/render/office/p3-two-charts.docx", "bytes": 41590, "sha256": "e0bc29916de415c53c03dcee14817bbd1e3eeea8f54a2b76629fe6754e6c641e" }, + { "file": "gsa-policy-review-template.xlsx", "ref": "gsa508", "family": "xlsx", "title": "Section508.gov Policy Review Template (Excel)", "features": "a government workbook with formatted tables", "source": "https://github.com/GSA/Assets.Section508.gov/blob/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Policy%20Review%20Template.xlsx", "fetched": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Policy%20Review%20Template.xlsx", "bytes": 222164, "sha256": "840661eba4653879dca24f209bca1619f7b168ef1232b595df5713741b2465bd" }, + { "file": "closedxml-pivot-tables.xlsx", "family": "xlsx", "title": "ClosedXML example: pivot tables (Excel)", "features": "pivot tables over a data sheet", "author": "ClosedXML contributors", "source": "https://github.com/ClosedXML/ClosedXML/blob/09f2f21a60e821ce7fb18ca3576b272e5dd6d921/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx", "evidence": "https://raw.githubusercontent.com/ClosedXML/ClosedXML/09f2f21a60e821ce7fb18ca3576b272e5dd6d921/LICENSE", "evidence_quote": "MIT License Copyright (c) 2016 ClosedXML", "licence": "MIT", "attribution": "Copyright (c) 2016 ClosedXML, MIT, https://github.com/ClosedXML/ClosedXML. No changes.", "fetched": "https://raw.githubusercontent.com/ClosedXML/ClosedXML/09f2f21a60e821ce7fb18ca3576b272e5dd6d921/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx", "bytes": 30912, "sha256": "d13f149091c804cfe9b02c81301ea7ce13d20d1f3311cac799151541d0467d02" }, + { "file": "xlsxwriter-bar-chart.xlsx", "family": "xlsx", "title": "XlsxWriter test file: a bar chart (Excel)", "features": "a native bar chart saved in Excel", "author": "John McNamara (XlsxWriter)", "source": "https://github.com/jmcnamara/XlsxWriter/blob/5d4606d89a955226d2d0825a0f44309043ae7251/xlsxwriter/test/comparison/xlsx_files/chart_bar14.xlsx", "evidence": "https://raw.githubusercontent.com/jmcnamara/XlsxWriter/5d4606d89a955226d2d0825a0f44309043ae7251/LICENSE.txt", "evidence_quote": "BSD 2-Clause License Copyright (c) 2013-2025, John McNamara", "licence": "BSD-2-Clause", "attribution": "Copyright (c) 2013-2025, John McNamara, BSD-2-Clause, https://github.com/jmcnamara/XlsxWriter. No changes.", "fetched": "https://raw.githubusercontent.com/jmcnamara/XlsxWriter/5d4606d89a955226d2d0825a0f44309043ae7251/xlsxwriter/test/comparison/xlsx_files/chart_bar14.xlsx", "bytes": 13879, "sha256": "c4ed1e8117b4a0e014b95f42361c7e060728b398d4ea650395d902557429cdd2" }, + { "file": "python-pptx-chart-types.pptx", "family": "pptx", "title": "python-pptx test file: one slide for each chart type (PowerPoint)", "features": "many native chart types", "author": "Steve Canny (python-pptx)", "source": "https://github.com/scanny/python-pptx/blob/278b47b1dedd5b46ee84c286e77cdfb0bf4594be/features/steps/test_files/cht-chart-type.pptx", "evidence": "https://raw.githubusercontent.com/scanny/python-pptx/278b47b1dedd5b46ee84c286e77cdfb0bf4594be/LICENSE", "evidence_quote": "The MIT License (MIT) Copyright (c) 2013 Steve Canny", "licence": "MIT", "attribution": "Copyright (c) 2013 Steve Canny, MIT, https://github.com/scanny/python-pptx. No changes.", "fetched": "https://raw.githubusercontent.com/scanny/python-pptx/278b47b1dedd5b46ee84c286e77cdfb0bf4594be/features/steps/test_files/cht-chart-type.pptx", "bytes": 1393564, "sha256": "d0a7979d05970aa83fd8406f0829cd0019853a7bc4e16f8cda814586fc2eaccc" }, + { "file": "cdc-lifecycle-508.pptx", "ref": "gsa508", "family": "pptx", "title": "Integrating Section 508 Requirements in the Development Lifecycle (PowerPoint)", "features": "a government deck with pictures and diagrams", "author": "Mark D. Urban, CDC Section 508 Coordinator (US federal government)", "source": "https://github.com/GSA/Assets.Section508.gov/blob/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Integrating%20Section%20508%20Requirements%20in%20the%20Development%20Lifecycle.pptx", "fetched": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Integrating%20Section%20508%20Requirements%20in%20the%20Development%20Lifecycle.pptx", "bytes": 611924, "sha256": "51c7d6af2e6682e5ee66204b813a45488e62b0e6e13cecc9207a982fdeb1720a" } + ] + }, + { + "group": "converted", + "source": "fetched", + "files": [ + { "file": "poi-sample.doc", "ref": "poi", "family": "doc", "title": "Apache POI test document (Word 97)", "features": "a binary .doc", "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/SampleDoc.doc", "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/SampleDoc.doc", "bytes": 27136, "sha256": "717585c0a88f20862c86220163dd03da87cd743970354c6b29add32725f2e833" }, + { "file": "poi-word95.doc", "ref": "poi", "family": "doc", "title": "Apache POI test document (Word 6 and 95 format)", "features": "the pre-1997 Word format", "author": "Nick Burch (Apache POI)", "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/Word95.doc", "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/document/Word95.doc", "bytes": 102400, "sha256": "a7b9557e917daf29cfee6baaa90226fbfa7900ffa05cc35bdc03aafdefef5789" }, + { "file": "dhs-directive-139-02.doc", "ref": "gsa508", "family": "doc", "title": "DHS Directive 139-02 (Word 97)", "features": "a government directive in the binary .doc format", "author": "U.S. Department of Homeland Security", "source": "https://github.com/GSA/Assets.Section508.gov/blob/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Directive_139.2_Final_10-28-2008.doc", "fetched": "https://raw.githubusercontent.com/GSA/Assets.Section508.gov/c1d8f46b9cca483e744772229b91bbfcb8665381/assets/files/Directive_139.2_Final_10-28-2008.doc", "bytes": 82432, "sha256": "48d1c85456d6b515ce8e3db6b43024f90bac69be86a3e78c1ae08406d4cad493" }, + { "file": "poi-formula-eval.xls", "ref": "poi", "family": "xls", "title": "Apache POI test workbook of formulas (Excel 97)", "features": "hundreds of formula cases", "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/FormulaEvalTestData.xls", "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/FormulaEvalTestData.xls", "bytes": 178176, "sha256": "c2040c1fac1088f1e112abef197ae1f9bcb03547861783b89c1d4281a42049d1" }, + { "file": "poi-conditional-formats.xls", "ref": "poi", "family": "xls", "title": "Apache POI test workbook of conditional formats (Excel 97)", "features": "colour scales, icon sets and data bars", "author": "Nick Burch (Apache POI)", "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/NewStyleConditionalFormattings.xls", "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/NewStyleConditionalFormattings.xls", "bytes": 44544, "sha256": "899a29aa6ce35fee0268847ff8b5d9459e6ce34d5e2ff462fd42ee9b80837b87" }, + { "file": "poi-excel5.xls", "ref": "poi", "family": "xls", "title": "Apache POI test workbook (Excel 5 and 95 format)", "features": "the BIFF5 format of 1993 to 1995", "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/testEXCEL_5.xls", "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/spreadsheet/testEXCEL_5.xls", "bytes": 7680, "sha256": "ac0ce3a2eff43e32e56d6b789df6aaa1645ae846d22fa3a2fe3057391d0f1cae" }, + { "file": "poi-sample-show.ppt", "ref": "poi", "family": "ppt", "title": "Apache POI test presentation (PowerPoint 97)", "features": "a binary .ppt", "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/slideshow/SampleShow.ppt", "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/slideshow/SampleShow.ppt", "bytes": 125440, "sha256": "64275a6685b8f267827178037dcc59de927475e56385725916f4c6039c7ce7f6" }, + { "file": "tika-rtf-various.rtf", "ref": "tika", "family": "rtf", "title": "Apache Tika test document: various RTF features", "features": "fonts, colours, tables and lists in RTF", "author": "Michael McCandless (Apache Tika)", "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testRTFVarious.rtf", "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testRTFVarious.rtf", "bytes": 65517, "sha256": "cf00c56fe7099d14b820a0d751324e415f2c09af580f2c96f08614a2aec42c1e" }, + { "file": "tdf-thesis-template.odt", "family": "odt", "title": "Thesis template in Spanish (OpenDocument text)", "features": "styles, a table of contents, headers", "author": "Phorious (Document Foundation wiki user)", "source": "https://wiki.documentfoundation.org/File:Tesis-v1.0.4.odt", "evidence": "https://wiki.documentfoundation.org/File:Tesis-v1.0.4.odt", "evidence_quote": "This file is made available under the Creative Commons CC0 1.0 Universal Public Domain Ded", "licence": "CC0-1.0", "attribution": "", "fetched": "https://wiki.documentfoundation.org/images/5/54/Tesis-v1.0.4.odt", "bytes": 67249, "sha256": "ec323e9fde67103a7f0039d8a59625bd7e2c5afa9e057563f5064c6b68d442a1" }, + { "file": "tika-odt-ooo3.odt", "ref": "tika", "family": "odt", "title": "Apache Tika test document written with OpenOffice 3 (OpenDocument text)", "features": "an .odt of 2009", "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testODFwithOOo3.odt", "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testODFwithOOo3.odt", "bytes": 24286, "sha256": "b6cd74b570a98fcb6d838dd85ab869604e0711812d9533d5ecde0000b24ba7ef" }, + { "file": "tdf-household-budget.ods", "family": "ods", "title": "Household budget book (OpenDocument spreadsheet)", "features": "formulas, charts and several sheets", "author": "Hrbrgr (Document Foundation wiki user)", "source": "https://wiki.documentfoundation.org/File:Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods", "evidence": "https://wiki.documentfoundation.org/File:Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods", "evidence_quote": "This work is licensed under the Attribution 3.0 Unported License", "licence": "CC-BY-3.0", "attribution": "Hrbrgr (Document Foundation wiki user), Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods, https://wiki.documentfoundation.org/File:Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods, CC BY 3.0. No changes.", "fetched": "https://wiki.documentfoundation.org/images/b/bc/Housekeeping_book_-_Haushaltsbuch_-_in_Calc_final.ods", "bytes": 21595, "sha256": "aacd76666860f7bf879e1518a07b90d535815ce92d076ea26c835061b8aff854" }, + { "file": "odfdo-flat.fods", "ref": "odfdo", "family": "fods", "title": "odfdo test sample (flat OpenDocument spreadsheet)", "features": "one XML file", "source": "https://github.com/jdum/odfdo/blob/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/test_flat_lo.fods", "fetched": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/test_flat_lo.fods", "bytes": 33177, "sha256": "4966023bf427dbc9c975dc9b11816b4a90158f7dab452c59104b8800fb27ec36" }, + { "file": "tdf-presentation-template.odp", "family": "odp", "title": "Presentation template (OpenDocument presentation)", "features": "master slides, pictures and layouts", "author": "Phorious (Document Foundation wiki user)", "source": "https://wiki.documentfoundation.org/File:Presenta.odp", "evidence": "https://wiki.documentfoundation.org/File:Presenta.odp", "evidence_quote": "This file is made available under the Creative Commons CC0 1.0 Universal Public Domain Ded", "licence": "CC0-1.0", "attribution": "", "fetched": "https://wiki.documentfoundation.org/images/5/51/Presenta.odp", "bytes": 412768, "sha256": "147ca9c6dbcdd45470627b84c04824c7f3e07de57485a8190018eea5f23c64b3" }, + { "file": "odfdo-example.odp", "ref": "odfdo", "family": "odp", "title": "odfdo test sample (OpenDocument presentation)", "features": "slides with text and shapes", "source": "https://github.com/jdum/odfdo/blob/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/example.odp", "fetched": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/example.odp", "bytes": 55687, "sha256": "9075b87e36bb4618bdc9a8a6138bcaaf31b2b81e089e9006ac906e66a94f912b" }, + { "file": "tdf-diagram-gallery.odg", "family": "odg", "title": "A gallery of diagrams (OpenDocument drawing)", "features": "several diagram types", "author": "Hrbrgr (Document Foundation wiki user)", "source": "https://wiki.documentfoundation.org/File:Diagramme.odg", "evidence": "https://wiki.documentfoundation.org/File:Diagramme.odg", "evidence_quote": "This work is licensed under the Attribution 3.0 Unported License", "licence": "CC-BY-3.0", "attribution": "Hrbrgr (Document Foundation wiki user), Diagramme.odg, https://wiki.documentfoundation.org/File:Diagramme.odg, CC BY 3.0. No changes.", "fetched": "https://wiki.documentfoundation.org/images/0/07/Diagramme.odg", "bytes": 91383, "sha256": "2e5f43734db7c600256e0670f6ec9da9428eb6b7a074c4742fd506e32c833a5a" }, + { "file": "odfdo-shapes.odg", "ref": "odfdo", "family": "odg", "title": "odfdo test sample: basic shapes (OpenDocument drawing)", "features": "rectangles, ellipses, lines and polygons", "source": "https://github.com/jdum/odfdo/blob/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/base_shapes.odg", "fetched": "https://raw.githubusercontent.com/jdum/odfdo/39e89938bd9df704c5e3760f845f91db513fd3e5/tests/samples/base_shapes.odg", "bytes": 10351, "sha256": "dfe6a6c83ba5caec340889d6c77a899e420060d55f4b08fd9a1fb7b36e0b799f" }, + { "file": "tika-keynote09.key", "ref": "tika", "family": "keynote", "title": "Apache Tika test presentation (Keynote '09)", "features": "an XML Keynote document (index.apxl)", "author": "Jukka Zitting (Apache Tika)", "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote.key", "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote.key", "bytes": 221745, "sha256": "4a270c2427f11d77f82fae696e3fd6eda945bae0422ae87c5fac6cf2bd06015c" }, + { "file": "tika-keynote2013.key", "ref": "tika", "family": "keynote", "title": "Apache Tika test presentation (Keynote 2013, IWA format)", "features": "a Keynote document in the iWork archive format", "author": "Tim Allison (Apache Tika)", "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote2013.key", "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testKeynote2013.key", "bytes": 274397, "sha256": "8da93f6b566bc9eb22f4791bbfd5f2179162f39bf80968187b164c33d31824db" }, + { "file": "tika-numbers09-charts.numbers", "ref": "tika", "family": "numbers", "title": "Apache Tika test spreadsheet with charts (Numbers '09)", "features": "an XML Numbers document with charts", "author": "Michael McCandless (Apache Tika)", "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testNumbersCharts.numbers", "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testNumbersCharts.numbers", "bytes": 104547, "sha256": "f4430f2aa0ddab186e7e32f15552b58b7b03abada7a978dfbcd51e5548757789" }, + { "file": "numbers-formulas.numbers", "family": "numbers", "title": "numbers-parser test spreadsheet of formulas (Numbers)", "features": "formulas across tables", "author": "Jon Connell (numbers-parser)", "source": "https://github.com/masaccio/numbers-parser/blob/a6affe5599c8373f575192ac5777d0c6426da696/tests/data/create-formulas.numbers", "evidence": "https://raw.githubusercontent.com/masaccio/numbers-parser/a6affe5599c8373f575192ac5777d0c6426da696/LICENSE.rst", "evidence_quote": "Permission is hereby granted, free of charge, to any person obtaining a copy of this softw", "licence": "MIT", "attribution": "Copyright 2021 Jon Connell, numbers-parser, MIT, https://github.com/masaccio/numbers-parser. No changes.", "fetched": "https://raw.githubusercontent.com/masaccio/numbers-parser/a6affe5599c8373f575192ac5777d0c6426da696/tests/data/create-formulas.numbers", "bytes": 330282, "sha256": "9371c5b1d6ee4dfa17569097f064eba9c67f804d88b48638efbbeeb459d07dd4" } + ] + }, + { + "group": "unsupported", + "source": "fetched", + "files": [ + { "file": "poi-network.vsdx", "group": "converted", "ref": "poi", "family": "visio", "title": "Apache POI test diagram (Visio): a network", "features": "a use-case diagram: actors, ovals and connecting lines", "author": "A reporter of Apache POI bug 60489 (committed by Tim Allison)", "source": "https://github.com/apache/poi/blob/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/diagram/60489.vsdx", "fetched": "https://raw.githubusercontent.com/apache/poi/d91a76448f0963a93b6cd95fb97fbe1d8bad82c6/test-data/diagram/60489.vsdx", "bytes": 44426, "sha256": "15494702ecb5554c1dfaee2c09ac35e2ea656e2eed7ba4ba0cf1039e6125fbe1" }, + { "file": "vsdx-house.vsdx", "group": "converted", "family": "visio", "title": "vsdx library test drawing (Visio): a house", "features": "boxes and house shapes with text", "author": "Dave Howard (vsdx library)", "source": "https://github.com/dave-howard/vsdx/blob/6703e6c2c906bea4051f43525ec9af9dcf735c13/tests/test3_house.vsdx", "evidence": "https://raw.githubusercontent.com/dave-howard/vsdx/6703e6c2c906bea4051f43525ec9af9dcf735c13/LICENSE", "evidence_quote": "BSD 3-Clause License Copyright (c) 2020, Dave Howard", "licence": "BSD-3-Clause", "attribution": "Copyright (c) 2020, Dave Howard, BSD-3-Clause, https://github.com/dave-howard/vsdx. No changes.", "fetched": "https://raw.githubusercontent.com/dave-howard/vsdx/6703e6c2c906bea4051f43525ec9af9dcf735c13/tests/test3_house.vsdx", "bytes": 20914, "sha256": "e454217e49d1ec2aaa7c9b8f38b148ffc0d028ce44ba096fe3386e9894b1775f" }, + { "file": "tika-pages09-layout.pages", "ref": "tika", "family": "pages", "title": "Apache Tika test document (Pages '09)", "features": "kept to show what the viewer says about Pages files, which it does not list yet", "author": "Michael McCandless (Apache Tika)", "source": "https://github.com/apache/tika/blob/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testPagesLayout.pages", "fetched": "https://raw.githubusercontent.com/apache/tika/f58e44c022a9952252f3e094dcc40abd931f7ecd/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/resources/test-documents/testPagesLayout.pages", "bytes": 66480, "sha256": "6157fe7a95f7bc7d42251872f990d2f2d589f7f5e25a52b58e25c14cc447a4b2" } + ] + } + ] +} diff --git a/crates/exav-viewer/demo/examples/generate/make-archives.py b/crates/exav-viewer/demo/examples/generate/make-archives.py new file mode 100644 index 00000000..635e26f2 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/make-archives.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Writes the demo's archive examples into ../files/archives/. + + python3 -I make-archives.py [OUT_DIR] + +Output: CC0-1.0, a public domain dedication by the exav project. The members +are files of this demo (../files/text and ../files/images, so run make-text.py +and make-images.py first) and short texts written here. + +Needs: Python 3, and the programs 7z (p7zip-full), genisoimage, zstd, gcab, +dpkg-deb, cpio and ar. A file whose program is missing is skipped. + +Written by Python alone: the zip, the tar family (tar, gz, bz2, xz), the jar, +the comic book archive (cbz), and the RAR 5 file, which is built here from the +published format (stored members: no program writes RAR without the +proprietary rar). The encrypted ones use 7z; their password is in the file +name's credit: "exav". + +Same bytes on every run, except the ISO image (genisoimage stamps the time) and +the encrypted files (7z picks a random salt). +""" +import bz2 +import gzip +import io +import lzma +import os +import shutil +import struct +import subprocess +import sys +import tarfile +import tempfile +import time +import zipfile +import zlib + +HERE = os.path.dirname(os.path.abspath(__file__)) +FILES = os.path.join(HERE, "..", "files") +OUT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(FILES, "archives") +os.makedirs(OUT, exist_ok=True) +EPOCH = 946684800 # 2000-01-01 00:00:00 UTC +DATE = (2000, 1, 1, 0, 0, 0) +PASSWORD = "exav" + + +def have(tool): + return shutil.which(tool) is not None + + +def read(*parts): + with open(os.path.join(FILES, *parts), "rb") as f: + return f.read() + + +README = """Example archive +=============== + +This archive holds a few files of the exav demo. It is in the public domain +(CC0-1.0). Look at the folders: docs, data, images, src, an empty one, and a +folder with accented and Japanese names. +""".encode() + +MEMBERS = [ + ("README.txt", README, True), + ("docs/field-guide.md", read("text", "field-guide.md"), True), + ("docs/field-notes.txt", read("text", "field-notes.txt"), True), + ("data/harbour-log.csv", read("text", "harbour-log.csv"), True), + ("data/recipes.json", read("text", "recipes.json"), True), + ("images/scene-indexed.png", read("images", "scene-indexed.png"), False), + ("src/life.py", read("text", "life.py"), True), + ("src/lru.rs", read("text", "lru.rs"), True), + ("café/日本語.txt", "Names with accents and Japanese characters.\n".encode(), True), +] +DIRS = ["docs", "data", "images", "src", "src/empty", "café"] + + +def stage(root): + """The members as files on disk with fixed times, for the programs that read a folder.""" + for d in DIRS: + os.makedirs(os.path.join(root, d), exist_ok=True) + for name, data, _ in MEMBERS: + path = os.path.join(root, name) + with open(path, "wb") as f: + f.write(data) + os.chmod(path, 0o644) + for p, dirs, files in os.walk(root): + for n in dirs + files: + os.utime(os.path.join(p, n), (EPOCH, EPOCH)) + os.utime(p, (EPOCH, EPOCH)) + + +# ---------------------------------------------------------------------- zip + + +def make_zip(path, members=MEMBERS, dirs=DIRS, comment=b"exav example archive, CC0-1.0"): + with zipfile.ZipFile(path, "w") as z: + z.comment = comment + for d in dirs: + zi = zipfile.ZipInfo(d + "/", DATE) + zi.external_attr = (0o40755 << 16) | 0x10 + zi.create_system = 3 + z.writestr(zi, b"") + for name, data, deflate in members: + zi = zipfile.ZipInfo(name, DATE) + zi.external_attr = 0o100644 << 16 + zi.create_system = 3 + zi.compress_type = zipfile.ZIP_DEFLATED if deflate else zipfile.ZIP_STORED + z.writestr(zi, data, compresslevel=9) + + +def zip_bytes(members, dirs=()): + buf = io.BytesIO() + make_zip(buf, members, dirs, comment=b"") + return buf.getvalue() + + +# ---------------------------------------------------------------------- tar + + +def tar_bytes(): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as t: + def info(name, kind=tarfile.REGTYPE, size=0, mode=0o644, link=""): + ti = tarfile.TarInfo(name) + ti.type, ti.size, ti.mode, ti.mtime, ti.uid, ti.gid, ti.uname, ti.gname, ti.linkname = kind, size, mode, EPOCH, 0, 0, "", "", link + return ti + + for d in DIRS: + t.addfile(info(d + "/", tarfile.DIRTYPE, mode=0o755)) + for name, data, _ in MEMBERS: + t.addfile(info(name, size=len(data)), io.BytesIO(data)) + t.addfile(info("link-to-readme", tarfile.SYMTYPE, link="README.txt", mode=0o777)) + return buf.getvalue() + + +def gz(data): + buf = io.BytesIO() + with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0, compresslevel=9) as f: + f.write(data) + return buf.getvalue() + + +def write(name, data): + with open(os.path.join(OUT, name), "wb") as f: + f.write(data) + + +# ------------------------------------------------------------------ RAR 5 + + +def vint(n): + out = bytearray() + while True: + b = n & 0x7F + n >>= 7 + if n: + out.append(b | 0x80) + else: + out.append(b) + return bytes(out) + + +def rar_block(htype, fields, data=b"", flags=0): + """One RAR 5 block: CRC32, header size, type, flags, [data size], fields, then the data.""" + body = vint(htype) + vint(flags | (2 if data else 0)) + (vint(len(data)) if data else b"") + fields + size = vint(len(body)) + return struct.pack("\nTwo pagesexav examples2\n', True)] +make_zip(os.path.join(OUT, "pages.cbz"), comic, [], comment=b"") + +# The nested one: a zip holding a tar.gz holding a 7z holding a zip. +with tempfile.TemporaryDirectory() as tmp: + stage_dir = os.path.join(tmp, "stage") + stage(stage_dir) + inner_zip = zip_bytes([("deep.txt", b"Four archives down.\n", True), ("field-guide.md", read("text", "field-guide.md"), True), ("scene-indexed.png", read("images", "scene-indexed.png"), False)]) + if have("7z"): + work = os.path.join(tmp, "inner") + os.makedirs(work) + with open(os.path.join(work, "inner.zip"), "wb") as f: + f.write(inner_zip) + os.utime(os.path.join(work, "inner.zip"), (EPOCH, EPOCH)) + subprocess.run(["7z", "a", "-t7z", "-mx=9", "-bd", "-y", os.path.join(tmp, "inner.7z"), "inner.zip"], cwd=work, check=True, capture_output=True) + with open(os.path.join(tmp, "inner.7z"), "rb") as f: + inner_7z = f.read() + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as t: + ti = tarfile.TarInfo("inner.7z") + ti.size, ti.mtime, ti.mode = len(inner_7z), EPOCH, 0o644 + t.addfile(ti, io.BytesIO(inner_7z)) + ti = tarfile.TarInfo("note.txt") + note = b"This tar.gz holds a 7z that holds a zip.\n" + ti.size, ti.mtime, ti.mode = len(note), EPOCH, 0o644 + t.addfile(ti, io.BytesIO(note)) + outer = gz(buf.getvalue()) + make_zip(os.path.join(OUT, "matryoshka.zip"), [("README.txt", b"A zip, holding a tar.gz, holding a 7z, holding a zip. Open them in turn.\n", True), ("inner.tar.gz", outer, False)], [], comment=b"") + + # Programs that read a folder. + if have("7z"): + for name, args in [ + ("sample.7z", ["-t7z", "-mx=9"]), + ("encrypted-aes.zip", ["-tzip", f"-p{PASSWORD}", "-mem=AES256"]), + ("encrypted-zipcrypto.zip", ["-tzip", f"-p{PASSWORD}", "-mem=ZipCrypto"]), + ("encrypted.7z", ["-t7z", "-mx=5", f"-p{PASSWORD}", "-mhe=off"]), + ]: + target = os.path.join(OUT, name) + if os.path.exists(target): + os.remove(target) + subprocess.run(["7z", "a", *args, "-bd", "-y", target, "."], cwd=stage_dir, check=True, capture_output=True) + if have("genisoimage"): + subprocess.run(["genisoimage", "-quiet", "-no-pad", "-R", "-J", "-joliet-long", "-V", "EXAV_EXAMPLE", "-input-charset", "utf-8", "-o", os.path.join(OUT, "sample.iso"), stage_dir], check=True, capture_output=True) + if have("zstd"): + subprocess.run(["zstd", "-q", "-19", "-f", "-o", os.path.join(OUT, "sample.tar.zst"), "-"], input=tar, check=True, capture_output=True) + if have("gcab"): + target = os.path.join(OUT, "sample.cab") + if os.path.exists(target): + os.remove(target) + files = ["README.txt", "docs/field-guide.md", "data/recipes.json", "src/life.py"] + subprocess.run(["gcab", "-c", "-n", target, *files], cwd=stage_dir, check=True, capture_output=True) + if have("ar"): + target = os.path.join(OUT, "libexample.a") + if os.path.exists(target): + os.remove(target) + subprocess.run(["ar", "rcsD", target, "README.txt"], cwd=stage_dir, check=True, capture_output=True) + for name in ("docs/field-notes.txt",): + subprocess.run(["ar", "rD", target, name], cwd=stage_dir, check=True, capture_output=True) + if have("cpio"): + names = "\n".join(["README.txt", "docs/field-guide.md", "data/recipes.json", "src/life.py"]) + "\n" + data = subprocess.run(["cpio", "-o", "-H", "newc", "--quiet", "--reproducible"], input=names.encode(), cwd=stage_dir, check=True, capture_output=True).stdout + write("sample.cpio", data) + if have("dpkg-deb"): + pkg = os.path.join(tmp, "pkg") + os.makedirs(os.path.join(pkg, "DEBIAN")) + os.makedirs(os.path.join(pkg, "usr", "share", "doc", "exav-example")) + with open(os.path.join(pkg, "DEBIAN", "control"), "w") as f: + f.write("Package: exav-example\nVersion: 1.0\nSection: misc\nPriority: optional\nArchitecture: all\nMaintainer: exav project \nDescription: an example package\n A package that holds a README, for the exav demo (CC0-1.0).\n") + shutil.copy(os.path.join(stage_dir, "README.txt"), os.path.join(pkg, "usr", "share", "doc", "exav-example", "README")) + shutil.copy(os.path.join(stage_dir, "docs", "field-guide.md"), os.path.join(pkg, "usr", "share", "doc", "exav-example", "field-guide.md")) + for p, dirs, files in os.walk(pkg): + for n in dirs + files: + os.utime(os.path.join(p, n), (EPOCH, EPOCH)) + env = dict(os.environ, SOURCE_DATE_EPOCH=str(EPOCH)) + subprocess.run(["dpkg-deb", "--root-owner-group", "-Zxz", "-b", pkg, os.path.join(OUT, "exav-example.deb")], check=True, capture_output=True, env=env) diff --git a/crates/exav-viewer/demo/examples/generate/make-attributions.mjs b/crates/exav-viewer/demo/examples/generate/make-attributions.mjs new file mode 100644 index 00000000..bf6a3b64 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/make-attributions.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// Writes the credits of the demo's examples from examples.json: +// +// - ../NOTICE.md, the list with every fact of the manifest, and +// - www/src/content/docs/viewer/examples.md, the page of the site. +// +// node generate/make-attributions.mjs # write both +// node generate/make-attributions.mjs --check # fail if either is out of date +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(here, ".."); +const repo = path.join(root, "..", "..", "..", ".."); +const check = process.argv.includes("--check"); + +const { examples } = JSON.parse(fs.readFileSync(path.join(root, "examples.json"), "utf8")); +const showcase = fs.readFileSync(path.join(root, "..", "src", "showcase.ts"), "utf8"); +const groups = [...showcase.matchAll(/^\s+(?:"([\w-]+)"|(\w+)): \{ en: "([^"]+)"/gm)].map((m) => ({ id: m[1] ?? m[2], label: m[3] })); + +const kb = (n) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`); +const cell = (s) => String(s).replace(/\|/g, "\\|").replace(/\n/g, " "); +const host = (u) => new URL(u).hostname.replace(/^www\./, ""); +const SPDX_URL = (id) => { + if (id === "CC0-1.0") return "https://creativecommons.org/publicdomain/zero/1.0/"; + const m = /^CC-BY(-SA)?-(\d\.\d)$/.exec(id); + if (m) return `https://creativecommons.org/licenses/by${m[1] ? "-sa" : ""}/${m[2]}/`; + if (/^(MIT|BSD-2-Clause|BSD-3-Clause|Apache-2\.0)$/.test(id)) return `https://spdx.org/licenses/${id}.html`; + return null; +}; +const origin = (e) => { + if (e.generated) return `generated by \`${e.generated.script}\`${e.generated.args.length ? ` ${e.generated.args.join(" ")}` : ""}${e.generated.tools ? ` (with ${e.generated.tools.join(", ")})` : ""}`; + if (e.fixture) return `a fixture of this repository, \`${e.fixture}\``; + return `downloaded from ${e.fetched}`; +}; + +const ordered = [...groups.map((g) => g.id), ...new Set(examples.map((e) => e.group))].filter((g, i, a) => a.indexOf(g) === i); +const byGroup = (id) => examples.filter((e) => e.group === id); +const label = (id) => groups.find((g) => g.id === id)?.label ?? id; + +const total = (list) => list.reduce((n, e) => n + e.bytes, 0); +const committed = examples.filter((e) => e.committed); + +// ----------------------------------------------------------------- NOTICE.md +const notice = [ + "# Notices of the example files", + "", + "The demo of `@exav/viewer` lists the files of this directory's `examples.json`. Each has a free licence", + "that was read at the page named under \"Evidence\", or is made by a script of this repository and", + "given to the public domain (CC0-1.0). This file is written by `generate/make-attributions.mjs`; do not edit it.", + "", + `${examples.length} files: ${committed.length} are committed under \`files/\` (${kb(total(committed))}); ${examples.length - committed.length} are downloaded by \`npm run demo:examples\` (${kb(total(examples.filter((e) => !e.committed)))}).`, + "", + "To rebuild: run the scripts of `generate/` (each lists what it needs; `python3 -I generate/make-images.py`, `make-cad.py`,", + "`make-models.py`, `make-ifc.py`, `make-text.py`, `make-archives.py`, `make-media.py`, `make-pdf.py`, `make-office.py`), then", + "`node generate/make-manifest.mjs` and `node generate/make-attributions.mjs`. `node check.mjs` checks the manifest and the", + "files, `node verify-open.mjs` opens every example with the viewer's own engines, and `node generate/verify-evidence.mjs`", + "reads the licence pages again. `catalog.json` is where each example is described by hand.", + "", + "Where the pictures made here hold text, it is set in Aileron, the font Pillow bundles, which its author gives to the", + "public domain (CC0-1.0, \"No Rights Reserved\": https://dotcolon.net/font/aileron/). Drawings name their fonts and", + "embed none; documents that embed fonts embed the Liberation fonts (SIL OFL 1.1, which lets documents embed them).", + "", +]; +for (const id of ordered) { + const list = byGroup(id); + if (!list.length) continue; + notice.push(`## ${label(id)}`, ""); + for (const e of list) { + notice.push(`### ${e.file}`, ""); + notice.push(`- What: ${e.title} (${e.family}, ${kb(e.bytes)})`); + if (e.features) notice.push(`- Shows: ${e.features}`); + notice.push(`- Author: ${e.author}`); + notice.push(`- Source: ${e.source}`); + notice.push(`- Licence: ${e.licence}`); + notice.push(`- Evidence: ${e.evidence}`); + notice.push(`- Words read there: "${e.evidence_quote}"`); + if (e.attribution) notice.push(`- Attribution: ${e.attribution}`); + if (e.derived) notice.push(`- Changes: ${e.derived}`); + notice.push(`- Origin: ${origin(e)}`); + notice.push(`- SHA-256: ${e.sha256}`, ""); + } +} + +// ------------------------------------------------------------------ the page +const page = [ + "---", + "title: Example files", + "description: \"The files of the @exav/viewer demo: what each is, who made it, where it comes from and its licence.\"", + "---", + "", + "", + "", + `The [demo](/viewer/demo/) lists ${examples.length} example files, one or more for each format the viewer reads. Each is public domain, CC0,`, + "CC-BY, or an MIT, BSD or Apache test file, and its licence was read at the source page named below.", + "Most are made for the demo by scripts of the repository and given to the public domain (CC0-1.0); the others", + "are credited here and in the demo's licence notice. A file is downloaded only when it is opened.", + "", + "The facts are in [`examples.json`](https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/examples.json)", + "and [`NOTICE.md`](https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples/NOTICE.md): the page", + "that states each licence, the words read there, the SHA-256 of the file, and how it was made or fetched.", + "", +]; +for (const id of ordered) { + const list = byGroup(id); + if (!list.length) continue; + page.push(`## ${label(id)}`, "", "| File | Format | Size | Author | Source | Licence |", "| --- | --- | ---: | --- | --- | --- |"); + for (const e of list) { + const lic = SPDX_URL(e.licence) ? `[${cell(e.licence)}](${SPDX_URL(e.licence)})` : cell(e.licence); + const what = e.generated ? "made for the demo" : `[${host(e.source)}](${e.source})`; + page.push(`| \`${e.file}\` | ${cell(e.family)} | ${kb(e.bytes)} | ${cell(e.author)} | ${what} | ${lic} |`); + } + page.push(""); +} +const credited = examples.filter((e) => e.attribution); +if (credited.length) { + page.push("## Attributions", "", "The lines the licences ask to keep:", ""); + for (const e of credited) page.push(`- \`${e.file}\`: ${e.attribution}`); + page.push(""); +} + +const targets = [ + [path.join(root, "NOTICE.md"), `${notice.join("\n")}\n`], + [path.join(repo, "www", "src", "content", "docs", "viewer", "examples.md"), `${page.join("\n")}\n`], +]; +let stale = false; +for (const [file, text] of targets) { + const now = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; + if (check) { + if (now !== text) { + console.error(`${path.relative(repo, file)} is out of date: run node generate/make-attributions.mjs`); + stale = true; + } + } else if (now !== text) { + fs.writeFileSync(file, text); + console.log(`wrote ${path.relative(repo, file)}`); + } +} +if (stale) process.exit(1); diff --git a/crates/exav-viewer/demo/examples/generate/make-cad.py b/crates/exav-viewer/demo/examples/generate/make-cad.py new file mode 100644 index 00000000..88f90574 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/make-cad.py @@ -0,0 +1,970 @@ +#!/usr/bin/env python3 +"""Writes the demo's DXF drawing examples into ../files/cad/. + + python3 -I make-cad.py [OUT_DIR] + +Output: CC0-1.0, a public domain dedication by the exav project. Every +drawing is made here from scratch with ezdxf (MIT licence; tested with +1.4.1), with no outside drawing or block library. Two runs draw the same +drawing; the bytes differ in the order of a few table objects (ezdxf's +dictionaries follow the hash seed). The dates and GUIDs are fixed. + +Needs: Python 3 and ezdxf 1.4. + +- house-plan.dxf (2018), house-plan-r2000.dxf: a two-room-wide house plan, + walls with openings, door and window blocks with attributes, furniture + blocks, axes with bubbles, room labels, linear, aligned, radius and angular + dimensions, hatches, a paper-space sheet with a title block and a viewport. +- house-elevations.dxf: four elevations and a section in 2D, with ground + hatching, roof, windows, levels and height dimensions. +- hatch-gallery.dxf: the predefined patterns, gradients, solid fills, islands + and boundaries made of arcs, ellipses and splines. +- dimensions.dxf: every dimension type, in two dimension styles. +- linetypes.dxf: the standard and custom linetypes (text and shapes inside), + line weights, line type scales, wide and generated polylines. +- text-styles.dxf: fonts, width and oblique factors, mirrored and rotated + text, MTEXT formatting, columns, background, special characters. +- blocks.dxf: nested blocks, attributes, arrays, mirrored and scaled inserts. +- bracket.dxf: a mechanical part with centre and hidden lines, a section, + dimensions with tolerances and a title block. +- site-plan.dxf: contour lines (splines and polylines), parcels, points with + heights, a legend and a north arrow. +- legacy-r12.dxf: a drawing in the R12 format. +""" +import math +import os +import sys + +import ezdxf +from ezdxf import colors, zoom +from ezdxf.enums import TextEntityAlignment +from ezdxf.math import Vec2 +from ezdxf.render.mleader import ConnectionSide +from ezdxf.tools import pattern + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(HERE, "..", "files", "cad") +os.makedirs(OUT, exist_ok=True) + +# Fixed dates, GUIDs and creator line in the files, so that two runs give the same bytes. +ezdxf.options.write_fixed_meta_data_for_testing = True + + +def new(version="R2018", units=4): + doc = ezdxf.new(version, setup=["linetypes"]) + doc.units = units + return doc + + +def save(doc, name, extents=True): + if extents: + zoom.extents(doc.modelspace(), factor=1.05) + doc.saveas(os.path.join(OUT, name)) + + +def layer(doc, name, color=7, linetype="Continuous", weight=25, **kw): + return doc.layers.add(name, color=color, linetype=linetype, lineweight=weight, **kw) + + +def text_style(doc, name, font, **kw): + style = doc.styles.add(name, font=font) + for key, value in kw.items(): + setattr(style.dxf, key, value) + return style + + +def dimstyle(doc, name, scale_text=125, **over): + ds = doc.dimstyles.new(name) + ds.dxf.dimtxt = scale_text + ds.dxf.dimasz = scale_text * 0.8 + ds.dxf.dimexe = scale_text * 0.6 + ds.dxf.dimexo = scale_text * 0.8 + ds.dxf.dimgap = scale_text * 0.3 + ds.dxf.dimdec = 0 + ds.dxf.dimtad = 1 + ds.dxf.dimclrd = 3 + ds.dxf.dimclre = 3 + ds.dxf.dimclrt = 2 + for k, v in over.items(): + setattr(ds.dxf, k, v) + return ds + + +def rect(msp, x0, y0, x1, y1, **attr): + return msp.add_lwpolyline([(x0, y0), (x1, y0), (x1, y1), (x0, y1)], close=True, dxfattribs=attr) + + +# ----------------------------------------------------------------- blocks + + +def make_blocks(doc): + door = doc.blocks.new("DOOR") + # A leaf 900 wide, open to 90 degrees, with its swing. + door.add_line((0, 0), (0, 900), dxfattribs={"layer": "A-DOOR"}) + door.add_arc((0, 0), 900, 0, 90, dxfattribs={"layer": "A-DOOR", "linetype": "DASHED"}) + door.add_lwpolyline([(0, 0), (900, 0), (900, 40), (0, 40)], close=True, dxfattribs={"layer": "A-DOOR"}) + door.add_attdef("TAG", (350, -330), text="D-000", height=110, dxfattribs={"layer": "A-ANNO"}) + win = doc.blocks.new("WINDOW") + # Unit width; the frame is scaled in x to the opening and in y to the wall. + for y in (0.0, 0.35, 0.65, 1.0): + win.add_line((0, y), (1, y), dxfattribs={"layer": "A-WIND"}) + win.add_line((0, 0), (0, 1), dxfattribs={"layer": "A-WIND"}) + win.add_line((1, 0), (1, 1), dxfattribs={"layer": "A-WIND"}) + bed = doc.blocks.new("BED") + rect(bed, 0, 0, 1600, 2000, layer="A-FURN") + rect(bed, 80, 1300, 760, 1920, layer="A-FURN") + rect(bed, 840, 1300, 1520, 1920, layer="A-FURN") + bed.add_line((0, 1200), (1600, 1200), dxfattribs={"layer": "A-FURN"}) + table = doc.blocks.new("TABLE") + rect(table, 0, 0, 1800, 900, layer="A-FURN") + for i in range(4): + x = 150 + i * 450 + rect(table, x, -450, x + 400, -50, layer="A-FURN") + rect(table, x, 950, x + 400, 1350, layer="A-FURN") + sofa = doc.blocks.new("SOFA") + rect(sofa, 0, 0, 2200, 950, layer="A-FURN") + rect(sofa, 0, 0, 2200, 250, layer="A-FURN") + for i in range(3): + rect(sofa, 120 + i * 650, 300, 120 + i * 650 + 600, 900, layer="A-FURN") + wc = doc.blocks.new("WC") + rect(wc, 0, 0, 400, 200, layer="A-FIXT") + wc.add_ellipse((200, -250), major_axis=(0, 230), ratio=0.8, dxfattribs={"layer": "A-FIXT"}) + sink = doc.blocks.new("SINK") + rect(sink, 0, 0, 600, 450, layer="A-FIXT") + sink.add_circle((300, 225), 160, dxfattribs={"layer": "A-FIXT"}) + sink.add_circle((300, 225), 25, dxfattribs={"layer": "A-FIXT"}) + bath = doc.blocks.new("BATH") + rect(bath, 0, 0, 1700, 750, layer="A-FIXT") + bath.add_lwpolyline([(100, 100), (1500, 100), (1600, 375), (1500, 650), (100, 650)], close=True, dxfattribs={"layer": "A-FIXT"}) + bubble = doc.blocks.new("AXIS") + bubble.add_circle((0, 0), 300, dxfattribs={"layer": "A-GRID"}) + bubble.add_attdef("ID", (0, 0), text="A", height=300, dxfattribs={"layer": "A-GRID", "halign": 4, "valign": 0, "align_point": (0, 0)}) + north = doc.blocks.new("NORTH") + north.add_circle((0, 0), 500, dxfattribs={"layer": "A-ANNO"}) + north.add_solid([(0, 650), (-170, -400), (0, -200), (170, -400)], dxfattribs={"layer": "A-ANNO", "color": 7}) + north.add_text("N", height=300, dxfattribs={"layer": "A-ANNO"}).set_placement((0, 800), align=TextEntityAlignment.BOTTOM_CENTER) + level = doc.blocks.new("LEVEL") + level.add_lwpolyline([(-250, 250), (0, 0), (250, 250)], close=True, dxfattribs={"layer": "A-ANNO"}) + level.add_line((-250, 250), (900, 250), dxfattribs={"layer": "A-ANNO"}) + level.add_attdef("H", (320, 290), text="+0.00", height=120, dxfattribs={"layer": "A-ANNO"}) + + +def wall(msp, p1, p2, t, openings=(), layer="A-WALL", hatch=True): + """A wall along p1 to p2 of thickness t (centre line), cut at the openings + [(offset, width)]. Each piece is a closed polyline with its hatch.""" + v = Vec2(p2) - Vec2(p1) + length = v.magnitude + d = v.normalize() + n = Vec2(-d.y, d.x) * (t / 2) + cuts = sorted(openings) + pos = 0.0 + pieces = [] + for off, width in cuts: + pieces.append((pos, off)) + pos = off + width + pieces.append((pos, length)) + for a, b in pieces: + if b - a < 1: + continue + pts = [Vec2(p1) + d * a + n, Vec2(p1) + d * b + n, Vec2(p1) + d * b - n, Vec2(p1) + d * a - n] + msp.add_lwpolyline(pts, close=True, dxfattribs={"layer": layer}) + if hatch: + h = msp.add_hatch(color=8, dxfattribs={"layer": "A-WALL-PAT"}) + h.set_pattern_fill("ANSI31", color=8, scale=40) + h.paths.add_polyline_path([(p.x, p.y) for p in pts], is_closed=True) + # Door and window jambs. + for off, width in cuts: + for o in (off, off + width): + msp.add_line(Vec2(p1) + d * o + n, Vec2(p1) + d * o - n, dxfattribs={"layer": layer}) + + +# ------------------------------------------------------------- the house + + +W, D = 10000, 8000 # outer size (centre lines of the outer walls) +EXT, INT = 300, 150 + + +def house_layers(doc): + layer(doc, "A-WALL", 7, weight=50) + layer(doc, "A-WALL-PAT", 8, weight=9) + layer(doc, "A-DOOR", 3, weight=25) + layer(doc, "A-WIND", 5, weight=25) + layer(doc, "A-FURN", 9, weight=13) + layer(doc, "A-FIXT", 4, weight=18) + layer(doc, "A-GRID", 1, linetype="CENTER", weight=9) + layer(doc, "A-ANNO", 2, weight=18) + layer(doc, "A-DIMS", 3, weight=13) + layer(doc, "A-AREA", 6, weight=9) + layer(doc, "A-FLOR", 252, weight=9) + layer(doc, "A-SLAB", 254, weight=9) + layer(doc, "A-STAIR", 30, weight=18) + layer(doc, "A-SHEET", 7, weight=35) + layer(doc, "A-VPORT", 7, weight=9, plot=False) + + +def house_plan(version, name): + doc = new(version) + house_layers(doc) + doc.ltscale = 50 + text_style(doc, "ARCH-TEXT", "arial.ttf") + text_style(doc, "ARCH-TITLE", "times.ttf", width=1.0) + text_style(doc, "ARCH-NOTE", "arial.ttf", oblique=15, width=0.9) + dimstyle(doc, "PLAN50", 220, dimtxsty="ARCH-TEXT") + make_blocks(doc) + msp = doc.modelspace() + + ow, od = W, D + # Floor slab, under the walls. + h = msp.add_hatch(color=254, dxfattribs={"layer": "A-SLAB"}) + h.set_solid_fill(color=254) + h.paths.add_polyline_path([(0, 0), (ow, 0), (ow, od), (0, od)], is_closed=True) + + # Outer walls with the front door and windows. + wall(msp, (0, 0), (ow, 0), EXT, [(1400, 1500), (3600, 900), (6200, 900), (8200, 1200)]) + wall(msp, (ow, 0), (ow, od), EXT, [(1500, 1300), (4800, 1300)]) + wall(msp, (ow, od), (0, od), EXT, [(1200, 1400), (3600, 1600), (6600, 1200)]) + wall(msp, (0, od), (0, 0), EXT, [(1600, 1400), (5000, 1400)]) + # Inner walls: a hall in the middle, a bathroom and two bedrooms. + wall(msp, (0, 4200), (6200, 4200), INT, [(600, 900), (2700, 900), (4800, 900)]) + wall(msp, (6200, 0), (6200, 4200), INT, [(1700, 900)]) + wall(msp, (6200, 4200), (6200, od), INT, [(6000 - 4200 + 300, 900)]) + wall(msp, (3500, 0), (3500, 4200), INT, [(2400, 900)]) + wall(msp, (6200, 4200), (ow, 4200), INT, []) + wall(msp, (8200, 4200), (8200, od), INT, [(1600, 900)]) + + # Doors: block, rotated and mirrored to swing the right way. + msp.add_blockref("DOOR", (1700, 150), dxfattribs={"layer": "A-DOOR", "rotation": 0}).add_auto_attribs({"TAG": "D-101"}) + msp.add_blockref("DOOR", (6275, 1700), dxfattribs={"layer": "A-DOOR", "rotation": 90}).add_auto_attribs({"TAG": "D-102"}) + msp.add_blockref("DOOR", (3575, 2400), dxfattribs={"layer": "A-DOOR", "rotation": 90}).add_auto_attribs({"TAG": "D-103"}) + msp.add_blockref("DOOR", (700, 4275), dxfattribs={"layer": "A-DOOR", "rotation": 0, "yscale": -1.0}).add_auto_attribs({"TAG": "D-104"}) + msp.add_blockref("DOOR", (2800, 4275), dxfattribs={"layer": "A-DOOR", "rotation": 0}).add_auto_attribs({"TAG": "D-105"}) + msp.add_blockref("DOOR", (4900, 4275), dxfattribs={"layer": "A-DOOR", "rotation": 0, "yscale": -1.0}).add_auto_attribs({"TAG": "D-106"}) + msp.add_blockref("DOOR", (8275, 5800), dxfattribs={"layer": "A-DOOR", "rotation": 90}).add_auto_attribs({"TAG": "D-107"}) + + # Windows: the unit block stretched over each opening and the wall's thickness. + def window(p, width, vertical, thick=EXT): + x, y = p + if vertical: + msp.add_blockref("WINDOW", (x + thick / 2, y), dxfattribs={"layer": "A-WIND", "xscale": width, "yscale": thick, "rotation": 90}) + else: + msp.add_blockref("WINDOW", (x, y - thick / 2), dxfattribs={"layer": "A-WIND", "xscale": width, "yscale": thick}) + + for x, w in [(3600, 900), (6200, 900), (8200, 1200)]: + window((x, 0), w, False) + for y, w in [(4800, 1300)]: + window((ow, y), w, True) + for x, w in [(1200, 1400), (3600, 1600), (6600, 1200)]: + window((x, od), w, False) + for y, w in [(5000, 1400)]: + window((0, y), w, True) + + # Furniture and fixtures. + msp.add_blockref("SOFA", (1200, 6900), dxfattribs={"layer": "A-FURN"}) + msp.add_blockref("TABLE", (3900, 5300), dxfattribs={"layer": "A-FURN", "rotation": 0}) + msp.add_blockref("BED", (4300, 400), dxfattribs={"layer": "A-FURN", "xscale": 1.0}) + msp.add_blockref("BED", (500, 600), dxfattribs={"layer": "A-FURN", "rotation": 0, "xscale": 0.8, "yscale": 0.9}) + msp.add_blockref("BATH", (7000 - 200, 300), dxfattribs={"layer": "A-FIXT"}) + msp.add_blockref("WC", (8300, 3200), dxfattribs={"layer": "A-FIXT", "rotation": 90}) + msp.add_blockref("SINK", (6500, 3350), dxfattribs={"layer": "A-FIXT"}) + + # A straight stair in the hall: treads, a walking line and the cut line. + sx, sy = 6400, 4400 + for i in range(12): + msp.add_line((sx, sy + i * 280), (sx + 1000, sy + i * 280), dxfattribs={"layer": "A-STAIR"}) + msp.add_lwpolyline([(sx, sy), (sx + 1000, sy), (sx + 1000, sy + 12 * 280), (sx, sy + 12 * 280)], close=True, dxfattribs={"layer": "A-STAIR"}) + msp.add_lwpolyline([(sx + 500, sy + 100), (sx + 500, sy + 3300)], dxfattribs={"layer": "A-STAIR"}) + msp.add_leader([(sx + 500, sy + 3300), (sx + 420, sy + 3000), (sx + 580, sy + 3000)], dxfattribs={"layer": "A-STAIR"}) + msp.add_text("UP", height=150, dxfattribs={"layer": "A-STAIR", "style": "ARCH-TEXT"}).set_placement((sx + 550, sy + 200)) + + # Axes and bubbles. + for i, x in enumerate((0, 3500, 6200, ow)): + msp.add_line((x, -1700), (x, od + 1200), dxfattribs={"layer": "A-GRID"}) + msp.add_blockref("AXIS", (x, -2000), dxfattribs={"layer": "A-GRID"}).add_auto_attribs({"ID": str(i + 1)}) + for i, y in enumerate((0, 4200, od)): + msp.add_line((-1700, y), (ow + 1200, y), dxfattribs={"layer": "A-GRID"}) + msp.add_blockref("AXIS", (-2000, y), dxfattribs={"layer": "A-GRID"}).add_auto_attribs({"ID": "ABC"[i]}) + + # Room names and areas. + rooms = [("Bedroom 1", (1750, 2100), 3.5 * 4.2), ("Bedroom 2", (4850, 2100), 2.7 * 4.2), ("Bath", (7500, 2100), 3.8 * 4.2), + ("Living room", (3100, 6100), 6.2 * 3.8), ("Kitchen", (7150, 6100), 3.8 * 3.8), ("Hall", (1500, 4650), 0.0)] + for label, (x, y), area in rooms: + t = r"\fArial|b1;" + label.upper() + (r"\P\fArial|b0;" + f"{area:.1f} m²" if area else "") + m = msp.add_mtext(t, dxfattribs={"layer": "A-AREA", "style": "ARCH-TEXT", "char_height": 260, "attachment_point": 5}) + m.set_location((x, y)) + m.dxf.width = 2600 + + # Dimensions. + msp.add_linear_dim(base=(0, -3100), p1=(0, 0), p2=(ow, 0), dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_linear_dim(base=(0, -2600), p1=(0, 0), p2=(3500, 0), dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_linear_dim(base=(0, -2600), p1=(3500, 0), p2=(6200, 0), dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_linear_dim(base=(0, -2600), p1=(6200, 0), p2=(ow, 0), dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_linear_dim(base=(-3100, 0), p1=(0, 0), p2=(0, od), angle=90, dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_linear_dim(base=(-2600, 0), p1=(0, 0), p2=(0, 4200), angle=90, dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_linear_dim(base=(-2600, 0), p1=(0, 4200), p2=(0, od), angle=90, dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + # A boundary fence beside the house: an aligned dimension and an angle against the house's wall. + msp.add_lwpolyline([(ow + 1500, 500), (ow + 4500, 3500)], dxfattribs={"layer": "A-WALL", "linetype": "DASHED"}) + msp.add_aligned_dim(p1=(ow + 1500, 500), p2=(ow + 4500, 3500), distance=-700, dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_line((ow + 1500, 500), (ow + 4500, 500), dxfattribs={"layer": "A-GRID"}) + msp.add_angular_dim_2l(base=(ow + 2300, 700), line1=((ow + 1500, 500), (ow + 4500, 3500)), line2=((ow + 1500, 500), (ow + 4500, 500)), dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_radius_dim(center=(6275, 1700), radius=900, angle=45, dimstyle="PLAN50", dxfattribs={"layer": "A-DIMS"}).render() + + # North arrow, level marks, title text. + msp.add_blockref("NORTH", (ow + 2200, od + 700), dxfattribs={"layer": "A-ANNO", "xscale": 1.0}) + msp.add_blockref("LEVEL", (5000, 3400), dxfattribs={"layer": "A-ANNO"}).add_auto_attribs({"H": "±0.00"}) + t = msp.add_text("GROUND FLOOR PLAN 1:50", height=330, dxfattribs={"layer": "A-ANNO", "style": "ARCH-TITLE"}) + t.set_placement((0, -4700)) + msp.add_line((0, -4900), (4500, -4900), dxfattribs={"layer": "A-ANNO", "lineweight": 70}) + msp.add_text("Drawn for the exav demo. Dimensions in millimetres.", height=130, dxfattribs={"layer": "A-ANNO", "style": "ARCH-NOTE"}).set_placement((0, -5200)) + + # A sheet in paper space: title block, a viewport on the plan. + lay = doc.layouts.new("A3 Plan") + lay.page_setup(size=(420, 297), margins=(10, 10, 10, 10), units="mm") + rect(lay, 10, 10, 410, 287, layer="A-SHEET") + rect(lay, 5, 5, 415, 292, layer="A-SHEET") + rect(lay, 260, 10, 410, 60, layer="A-SHEET") + lay.add_line((260, 35), (410, 35), dxfattribs={"layer": "A-SHEET"}) + lay.add_line((335, 10), (335, 35), dxfattribs={"layer": "A-SHEET"}) + lay.add_text("HOUSE, GROUND FLOOR", height=7, dxfattribs={"layer": "A-SHEET", "style": "ARCH-TITLE"}).set_placement((265, 45)) + lay.add_text("Scale 1:50", height=4, dxfattribs={"layer": "A-SHEET", "style": "ARCH-TEXT"}).set_placement((265, 18)) + lay.add_text("Sheet A-101", height=4, dxfattribs={"layer": "A-SHEET", "style": "ARCH-TEXT"}).set_placement((340, 18)) + vp = lay.add_viewport(center=(135, 160), size=(250, 200), view_center_point=(ow / 2, od / 2 - 600), view_height=200 * 50, status=1) + vp.dxf.layer = "A-VPORT" + vp.dxf.flags |= 0 + save(doc, name) + + +# ------------------------------------------------------------ elevations + + +def window_elev(msp, x, y, w, h, layer="A-WIND"): + rect(msp, x, y, x + w, y + h, layer=layer) + rect(msp, x + 60, y + 60, x + w - 60, y + h - 60, layer=layer) + msp.add_line((x + w / 2, y + 60), (x + w / 2, y + h - 60), dxfattribs={"layer": layer}) + hh = msp.add_hatch(color=5, dxfattribs={"layer": layer, "transparency": colors.float2transparency(0.6)}) + hh.set_solid_fill(color=5) + hh.paths.add_polyline_path([(x + 60, y + 60), (x + w - 60, y + 60), (x + w - 60, y + h - 60), (x + 60, y + h - 60)], is_closed=True) + rect(msp, x - 80, y - 90, x + w + 80, y, layer=layer) + + +def elevation(msp, ox, oy, name, width, windows, door, label): + """A gabled house front: ground, walls, roof, windows, door, levels.""" + wall_h, ridge = 5400, 8300 + layer_ = "E-WALL" + # Ground and its hatch. + msp.add_lwpolyline([(ox - 1500, oy), (ox + width + 1500, oy)], dxfattribs={"layer": "E-GROUND", "lineweight": 70}) + hh = msp.add_hatch(color=3, dxfattribs={"layer": "E-GROUND"}) + hh.set_pattern_fill("EARTH", color=3, scale=60) + hh.paths.add_polyline_path([(ox - 1500, oy - 600), (ox + width + 1500, oy - 600), (ox + width + 1500, oy), (ox - 1500, oy)], is_closed=True) + # Walls with a render hatch, plinth, roof. + rect(msp, ox, oy, ox + width, oy + wall_h, layer=layer_) + hw = msp.add_hatch(color=252, dxfattribs={"layer": "E-RENDER"}) + hw.set_pattern_fill("AR-SAND", color=8, scale=15) + hw.paths.add_polyline_path([(ox, oy), (ox + width, oy), (ox + width, oy + wall_h), (ox, oy + wall_h)], is_closed=True) + rect(msp, ox, oy, ox + width, oy + 450, layer="E-PLINTH") + roof = [(ox - 450, oy + wall_h), (ox + width / 2, oy + ridge), (ox + width + 450, oy + wall_h)] + msp.add_lwpolyline(roof, close=True, dxfattribs={"layer": "E-ROOF", "lineweight": 50}) + hr = msp.add_hatch(color=1, dxfattribs={"layer": "E-ROOF"}) + hr.set_pattern_fill("AR-RROOF", color=1, scale=60) + hr.paths.add_polyline_path(roof, is_closed=True) + # A floor line at 2700. + msp.add_line((ox, oy + 2700), (ox + width, oy + 2700), dxfattribs={"layer": "E-FLOOR", "linetype": "DASHDOT"}) + for wx, wy, w, h in windows: + window_elev(msp, ox + wx, oy + wy, w, h) + if door: + dx, dw, dh = door + rect(msp, ox + dx, oy, ox + dx + dw, oy + dh, layer="E-DOOR") + rect(msp, ox + dx + 100, oy + 100, ox + dx + dw - 100, oy + dh - 100, layer="E-DOOR") + msp.add_circle((ox + dx + dw - 200, oy + 1000), 40, dxfattribs={"layer": "E-DOOR"}) + for i in range(3): + rect(msp, ox + dx - 150 - i * 120, oy + 450 - 150 * i - 150, ox + dx + dw + 150 + i * 120, oy + 450 - 150 * i, layer="E-STEP") + # Levels and heights. + for lv, txt in [(0, "+0.00"), (2700, "+2.70"), (wall_h, "+5.40"), (ridge, "+8.30")]: + b = msp.add_blockref("LEVEL", (ox + width + 1900, oy + lv), dxfattribs={"layer": "A-ANNO"}) + b.add_auto_attribs({"H": txt}) + msp.add_line((ox + width, oy + lv), (ox + width + 1900, oy + lv), dxfattribs={"layer": "A-ANNO", "linetype": "DASHED"}) + msp.add_linear_dim(base=(ox - 1900, oy), p1=(ox, oy), p2=(ox, oy + wall_h), angle=90, dimstyle="ELEV50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_linear_dim(base=(ox - 1900, oy), p1=(ox, oy + wall_h), p2=(ox, oy + ridge), angle=90, dimstyle="ELEV50", dxfattribs={"layer": "A-DIMS"}).render() + msp.add_linear_dim(base=(ox, oy - 1100), p1=(ox, oy), p2=(ox + width, oy), dimstyle="ELEV50", dxfattribs={"layer": "A-DIMS"}).render() + t = msp.add_text(label, height=300, dxfattribs={"layer": "A-ANNO", "style": "ARCH-TITLE"}) + t.set_placement((ox, oy - 2100)) + msp.add_line((ox, oy - 2250), (ox + 3600, oy - 2250), dxfattribs={"layer": "A-ANNO", "lineweight": 70}) + + +def house_elevations(): + doc = new("R2018") + for n, c, lt, w in [("E-GROUND", 3, "Continuous", 50), ("E-WALL", 7, "Continuous", 35), ("E-RENDER", 8, "Continuous", 9), + ("E-PLINTH", 8, "Continuous", 25), ("E-ROOF", 1, "Continuous", 35), ("E-FLOOR", 9, "DASHDOT", 9), + ("E-DOOR", 30, "Continuous", 25), ("E-STEP", 8, "Continuous", 18), ("A-WIND", 5, "Continuous", 25), + ("A-ANNO", 2, "Continuous", 18), ("A-DIMS", 3, "Continuous", 13), ("A-FIXT", 4, "Continuous", 18)]: + layer(doc, n, c, lt, w) + doc.ltscale = 80 + text_style(doc, "ARCH-TEXT", "arial.ttf") + text_style(doc, "ARCH-TITLE", "times.ttf") + dimstyle(doc, "ELEV50", 160, dimtxsty="ARCH-TEXT") + make_blocks(doc) + msp = doc.modelspace() + # South (the front, with the door), east, north, west; widths 10000, 8000, 10000, 8000. + south = ([(3600, 1100, 900, 1300), (6200, 1100, 900, 1300), (8200, 1100, 1200, 1300), (800, 3400, 1000, 1400), (3600, 3400, 900, 1400), (6200, 3400, 900, 1400)], (1400, 1500, 2300)) + east = ([(1500, 1100, 1300, 1300), (4800, 1100, 1300, 1300), (1500, 3400, 1300, 1400), (4800, 3400, 1300, 1400)], None) + north = ([(1200, 1100, 1400, 1300), (3600, 1100, 1600, 1300), (6600, 1100, 1200, 1300), (3600, 3400, 1600, 1400)], None) + west = ([(1600, 1100, 1400, 1300), (5000, 1100, 1400, 1300), (3000, 3400, 1200, 1400)], None) + elevation(msp, 0, 0, "south", 10000, *south, "SOUTH ELEVATION 1:50") + elevation(msp, 16000, 0, "east", 8000, *east, "EAST ELEVATION 1:50") + elevation(msp, 0, -17000, "north", 10000, *north, "NORTH ELEVATION 1:50") + elevation(msp, 16000, -17000, "west", 8000, *west, "WEST ELEVATION 1:50") + # A section: floors, walls, roof, stair opening, hatched cuts. + ox, oy = 32000, 0 + for (x0, x1) in [(ox, ox + 300), (ox + 9700, ox + 10000)]: + rect(msp, x0, oy, x1, oy + 5400, layer="E-WALL") + hh = msp.add_hatch(color=8, dxfattribs={"layer": "E-WALL"}) + hh.set_pattern_fill("ANSI31", color=8, scale=60) + hh.paths.add_polyline_path([(x0, oy), (x1, oy), (x1, oy + 5400), (x0, oy + 5400)], is_closed=True) + for y in (0, 2700, 5100): + rect(msp, ox, oy + y - 100 if y else oy - 300, ox + 10000, oy + y + (200 if y else 0), layer="E-WALL") + sec = [(ox - 450, oy + 5400), (ox + 5000, oy + 8300), (ox + 10450, oy + 5400), (ox + 10450, oy + 5250), (ox + 5000, oy + 8150), (ox - 450, oy + 5250)] + msp.add_lwpolyline(sec, close=True, dxfattribs={"layer": "E-ROOF", "lineweight": 50}) + hr = msp.add_hatch(color=1, dxfattribs={"layer": "E-ROOF"}) + hr.set_solid_fill(color=1) + hr.paths.add_polyline_path(sec, is_closed=True) + msp.add_lwpolyline([(ox - 1500, oy - 300), (ox + 11500, oy - 300)], dxfattribs={"layer": "E-GROUND", "lineweight": 70}) + for i in range(10): + msp.add_line((ox + 6300 + i * 300, oy + i * 270), (ox + 6600 + i * 300, oy + i * 270), dxfattribs={"layer": "E-STEP"}) + msp.add_line((ox + 6600 + i * 300, oy + i * 270), (ox + 6600 + i * 300, oy + (i + 1) * 270), dxfattribs={"layer": "E-STEP"}) + t = msp.add_text("SECTION A-A 1:50", height=300, dxfattribs={"layer": "A-ANNO", "style": "ARCH-TITLE"}) + t.set_placement((ox, oy - 2100)) + save(doc, "house-elevations.dxf") + + +# ---------------------------------------------------------- hatch gallery + + +def hatch_gallery(): + doc = new("R2018") + layer(doc, "PATTERNS", 7) + layer(doc, "GRADIENTS", 5) + layer(doc, "ISLANDS", 1) + layer(doc, "EDGES", 3) + layer(doc, "LABELS", 2) + text_style(doc, "LABEL", "arial.ttf") + msp = doc.modelspace() + names = [n for n in pattern.load() if n.startswith(("ANSI", "AR-", "BRICK", "CLAY", "CORK", "CROSS", "DOTS", "EARTH", "GRASS", "GRAVEL", "HEX", "HONEY", "LINE", "NET", "SQUARE", "STEEL", "STARS", "TRIANG", "ZIGZAG", "ESCHER", "DOLMIT", "SWAMP", "INSUL", "MUDST", "PLAST", "BOX", "BRASS", "CONCRETE"))] + cols = 8 + size, gap = 100, 40 + for i, name in enumerate(names): + cx, cy = (i % cols) * (size + gap), -(i // cols) * (size + gap + 25) + h = msp.add_hatch(color=7, dxfattribs={"layer": "PATTERNS"}) + h.set_pattern_fill(name, color=7, scale=1.0) + h.paths.add_polyline_path([(cx, cy), (cx + size, cy), (cx + size, cy + size), (cx, cy + size)], is_closed=True) + rect(msp, cx, cy, cx + size, cy + size, layer="PATTERNS", color=8) + msp.add_text(name, height=9, dxfattribs={"layer": "LABELS", "style": "LABEL"}).set_placement((cx, cy - 14)) + y0 = -(len(names) // cols + 1) * (size + gap + 25) - 100 + # Gradients. + kinds = ["LINEAR", "CYLINDER", "INVCYLINDER", "SPHERICAL", "INVSPHERICAL", "HEMISPHERICAL", "INVHEMISPHERICAL", "CURVED", "INVCURVED"] + for i, k in enumerate(kinds): + cx = (i % cols) * (size + gap) + cy = y0 - (i // cols) * (size + gap + 25) + h = msp.add_hatch(dxfattribs={"layer": "GRADIENTS"}) + h.set_gradient((255, 220, 80), (20, 60, 140), rotation=30 if i % 2 else 0, name=k) + h.paths.add_polyline_path([(cx, cy), (cx + size, cy), (cx + size, cy + size), (cx, cy + size)], is_closed=True) + msp.add_text(k.lower(), height=9, dxfattribs={"layer": "LABELS", "style": "LABEL"}).set_placement((cx, cy - 14)) + y1 = y0 - 2 * (size + gap + 25) - 100 + # Islands: an outer square with a circle and a triangle inside, the three hatch styles. + for i, (style, label) in enumerate([(0, "normal: nested islands"), (1, "outer only"), (2, "ignore: all filled")]): + cx, cy = i * (size * 2 + gap), y1 + h = msp.add_hatch(color=1, dxfattribs={"layer": "ISLANDS", "hatch_style": style}) + h.set_pattern_fill("ANSI31", color=1, scale=1.4) + h.paths.add_polyline_path([(cx, cy), (cx + 2 * size, cy), (cx + 2 * size, cy + 2 * size), (cx, cy + 2 * size)], is_closed=True, flags=1 | 16) + ep = h.paths.add_edge_path(flags=16) + ep.add_arc((cx + size, cy + size), 70, 0, 360, ccw=True) + h.paths.add_polyline_path([(cx + size - 25, cy + size - 20), (cx + size + 25, cy + size - 20), (cx + size, cy + size + 28)], is_closed=True, flags=16) + msp.add_text(label, height=9, dxfattribs={"layer": "LABELS", "style": "LABEL"}).set_placement((cx, cy - 14)) + # A boundary of line, arc, ellipse and spline edges. + cx, cy = 3 * (size * 2 + gap), y1 + h = msp.add_hatch(color=3, dxfattribs={"layer": "EDGES"}) + h.set_pattern_fill("HONEY", color=3, scale=1.0) + ep = h.paths.add_edge_path(flags=1) + ep.add_line((cx, cy), (cx + 160, cy)) + ep.add_arc((cx + 160, cy + 40), 40, 270, 90, ccw=True) + ep.add_line((cx + 160, cy + 80), (cx + 120, cy + 80)) + ep.add_spline([(cx + 120, cy + 80), (cx + 100, cy + 140), (cx + 60, cy + 40), (cx + 40, cy + 120), (cx, cy + 100)], degree=3) + ep.add_line((cx, cy + 100), (cx, cy)) + msp.add_text("edges: line, arc, spline", height=9, dxfattribs={"layer": "LABELS", "style": "LABEL"}).set_placement((cx, cy - 14)) + cx = 3 * (size * 2 + gap) + 220 + h = msp.add_hatch(color=6, dxfattribs={"layer": "EDGES"}) + h.set_solid_fill(color=6) + ep = h.paths.add_edge_path(flags=1) + ep.add_ellipse((cx + 70, cy + 70), major_axis=(70, 0), ratio=0.55, start_angle=0, end_angle=360, ccw=True) + msp.add_text("ellipse, solid", height=9, dxfattribs={"layer": "LABELS", "style": "LABEL"}).set_placement((cx, cy - 14)) + # A pattern with its own angle and scale, and one with a seed point and a user-defined pattern. + cx = 3 * (size * 2 + gap) + 400 + h = msp.add_hatch(color=2, dxfattribs={"layer": "EDGES"}) + h.set_pattern_fill("ANSI37", color=2, angle=15, scale=2.5) + h.paths.add_polyline_path([(cx, cy), (cx + 140, cy), (cx + 140, cy + 140), (cx, cy + 140)], is_closed=True) + msp.add_text("angle 15, scale 2.5", height=9, dxfattribs={"layer": "LABELS", "style": "LABEL"}).set_placement((cx, cy - 14)) + save(doc, "hatch-gallery.dxf") + + +# -------------------------------------------------------------- dimensions + + +def dimensions(): + doc = new("R2018") + layer(doc, "OBJECT", 7, weight=35) + layer(doc, "DIMS", 3, weight=13) + layer(doc, "NOTE", 2, weight=13) + text_style(doc, "DIM-TEXT", "arial.ttf") + dimstyle(doc, "METRIC", 3.5, dimtxsty="DIM-TEXT", dimdec=1) + dimstyle(doc, "TOL", 3.5, dimtxsty="DIM-TEXT", dimdec=2, dimtol=1, dimtp=0.05, dimtm=0.02, dimtfac=0.7, dimtolj=1) + dimstyle(doc, "ARCH", 3.5, dimtxsty="DIM-TEXT", dimdec=0, dimtad=0, dimtih=1, dimtix=0, dimscale=1.0) + msp = doc.modelspace() + A = {"layer": "DIMS"} + rect(msp, 0, 0, 80, 50, layer="OBJECT") + msp.add_linear_dim(base=(0, -12), p1=(0, 0), p2=(80, 0), dimstyle="METRIC", dxfattribs=A).render() + msp.add_linear_dim(base=(-14, 0), p1=(0, 0), p2=(0, 50), angle=90, dimstyle="METRIC", dxfattribs=A).render() + msp.add_linear_dim(base=(0, 62), p1=(0, 50), p2=(80, 50), dimstyle="TOL", dxfattribs=A, text="<>").render() + msp.add_linear_dim(base=(0, 70), p1=(0, 50), p2=(80, 50), dimstyle="METRIC", dxfattribs=A, text="80 overall (text override)").render() + msp.add_aligned_dim(p1=(80, 0), p2=(110, 50), distance=12, dimstyle="METRIC", dxfattribs=A).render() + # Circles and arcs. + msp.add_circle((150, 25), 25, dxfattribs={"layer": "OBJECT"}) + msp.add_diameter_dim(center=(150, 25), radius=25, angle=40, dimstyle="METRIC", dxfattribs=A).render() + msp.add_circle((220, 25), 18, dxfattribs={"layer": "OBJECT"}) + msp.add_radius_dim(center=(220, 25), radius=18, angle=-60, dimstyle="METRIC", dxfattribs=A).render() + msp.add_arc((290, 0), 40, 20, 140, dxfattribs={"layer": "OBJECT"}) + msp.add_arc_dim_3p(base=(290 + 48 * math.cos(math.radians(80)), 48 * math.sin(math.radians(80))), center=(290, 0), p1=(290 + 40 * math.cos(math.radians(20)), 40 * math.sin(math.radians(20))), p2=(290 + 40 * math.cos(math.radians(140)), 40 * math.sin(math.radians(140))), dimstyle="METRIC", dxfattribs=A).render() + # Angles. + msp.add_line((0, -60), (60, -60), dxfattribs={"layer": "OBJECT"}) + msp.add_line((0, -60), (45, -20), dxfattribs={"layer": "OBJECT"}) + msp.add_angular_dim_2l(base=(30, -56), line1=((0, -60), (60, -60)), line2=((0, -60), (45, -20)), dimstyle="METRIC", dxfattribs=A).render() + msp.add_angular_dim_3p(base=(140, -60), center=(130, -80), p1=(150, -45), p2=(170, -80), dimstyle="METRIC", dxfattribs=A).render() + msp.add_line((130, -80), (170, -80), dxfattribs={"layer": "OBJECT"}) + msp.add_line((130, -80), (150, -45), dxfattribs={"layer": "OBJECT"}) + # Ordinate dimensions from an origin. + msp.add_lwpolyline([(220, -90), (320, -90), (320, -40), (260, -40), (260, -60), (220, -60)], close=True, dxfattribs={"layer": "OBJECT"}) + for x in (220, 260, 320): + msp.add_ordinate_x_dim(feature_location=(x, -90), offset=(0, -12), origin=(220, -90), dimstyle="METRIC", dxfattribs=A).render() + for y in (-90, -60, -40): + msp.add_ordinate_y_dim(feature_location=(320, y), offset=(14, 0), origin=(220, -90), dimstyle="METRIC", dxfattribs=A).render() + # Leaders and multileaders. + ml = msp.add_multileader_mtext("Standard") + ml.set_content("Chamfer 2 x 45°", char_height=3.5) + ml.add_leader_line(ConnectionSide.left, [Vec2(75, 45)]) + ml.build(insert=Vec2(100, 85)) + msp.add_leader([(15, 15), (30, 30), (40, 30)], dimstyle="METRIC", dxfattribs={"layer": "NOTE"}) + msp.add_text("hole 5", height=3.5, dxfattribs={"layer": "NOTE", "style": "DIM-TEXT"}).set_placement((41, 29)) + # Styles shown side by side. + msp.add_text("METRIC style (left), TOL style with tolerances, ARCH (text horizontal)", height=4, dxfattribs={"layer": "NOTE", "style": "DIM-TEXT"}).set_placement((0, 110)) + rect(msp, 0, -160, 60, -130, layer="OBJECT") + msp.add_linear_dim(base=(0, -168), p1=(0, -160), p2=(60, -160), dimstyle="ARCH", dxfattribs=A).render() + msp.add_linear_dim(base=(0, -178), p1=(0, -160), p2=(60, -160), dimstyle="TOL", dxfattribs=A).render() + save(doc, "dimensions.dxf") + + +# --------------------------------------------------------------- linetypes + + +def linetypes(): + doc = new("R2018") + doc.ltscale = 1.0 + text_style(doc, "LT-TEXT", "arial.ttf") + # Custom linetypes: simple, with text and with a shape-like arrow. + doc.linetypes.add("EXAV_DASH3", pattern=[10.0, 6.0, -3.0, 0.0, -3.0, 0.0, -3.0], description="Dash and three dots") + doc.linetypes.add("GAS_LINE", pattern='A,5,-2,["GAS",LT-TEXT,S=1.5,U=0.0,X=-1.0,Y=-0.75],-4', description="Gas line ---- GAS ----") + doc.linetypes.add("HOT_WATER", pattern='A,6,-1.5,["HW",LT-TEXT,S=1.5,R=0.0,X=-1.0,Y=-0.75],-3', description="Hot water ---- HW ----") + doc.linetypes.add("FENCE", pattern='A,4,-2,["x",LT-TEXT,S=1.4,U=0.0,X=-0.5,Y=-0.7],-2', description="Fence ---- x ----") + msp = doc.modelspace() + names = ["Continuous", "DASHED", "DASHED2", "DASHEDX2", "HIDDEN" if "HIDDEN" in doc.linetypes else "DASHED", "CENTER", "CENTER2", "CENTERX2", "PHANTOM", "PHANTOM2", "DASHDOT", "DASHDOT2", "DOT", "DOT2", "DIVIDE", "DIVIDE2", "EXAV_DASH3", "GAS_LINE", "HOT_WATER", "FENCE"] + y = 0 + for i, n in enumerate(names): + layer(doc, f"LT-{i:02d}", (i % 7) + 1, linetype=n, weight=25) + msp.add_line((0, y), (220, y), dxfattribs={"layer": f"LT-{i:02d}", "linetype": "BYLAYER"}) + msp.add_text(n, height=3, dxfattribs={"style": "LT-TEXT", "color": 7}).set_placement((230, y - 1)) + y -= 8 + y -= 6 + # Line weights. + msp.add_text("Line weights (mm)", height=4, dxfattribs={"style": "LT-TEXT", "color": 7}).set_placement((0, y + 6)) + for i, w in enumerate([0, 5, 9, 13, 18, 25, 35, 50, 70, 100, 140, 200]): + msp.add_line((0, y), (220, y), dxfattribs={"lineweight": w, "color": 7}) + msp.add_text(f"{w / 100:.2f}", height=3, dxfattribs={"style": "LT-TEXT", "color": 7}).set_placement((230, y - 1)) + y -= 5 + y -= 10 + # Per-entity linetype scale and BYBLOCK. + for i, s in enumerate([0.25, 0.5, 1, 2, 4]): + msp.add_line((0, y), (220, y), dxfattribs={"linetype": "DASHED", "ltscale": s, "color": 4}) + msp.add_text(f"DASHED, ltscale {s}", height=3, dxfattribs={"style": "LT-TEXT", "color": 7}).set_placement((230, y - 1)) + y -= 6 + y -= 8 + # Polylines: linetype generation on and off, wide segments. + pts = [(0, y), (40, y - 12), (80, y), (120, y - 12), (160, y), (200, y - 12)] + for gen in (True, False): + p = msp.add_lwpolyline(pts, dxfattribs={"linetype": "PHANTOM", "color": 1 if gen else 3}) + p.dxf.flags = 128 if gen else 0 + msp.add_text(f"lwpolyline, linetype generation {'on' if gen else 'off'}", height=3, dxfattribs={"style": "LT-TEXT", "color": 7}).set_placement((230, y - 1)) + pts = [(a, b - 25) for a, b in pts] + y -= 25 + y -= 6 + msp.add_lwpolyline([(0, y, 0, 6, 0), (50, y, 6, 1, 0), (100, y - 15, 1, 6, 0), (150, y, 6, 6, 0), (200, y - 8, 0, 0, 0)], format="xyseb", dxfattribs={"color": 6}) + msp.add_text("lwpolyline with start and end widths per segment", height=3, dxfattribs={"style": "LT-TEXT", "color": 7}).set_placement((230, y - 1)) + y -= 30 + msp.add_circle((30, y), 15, dxfattribs={"linetype": "CENTER", "color": 1, "lineweight": 35}) + msp.add_arc((90, y), 15, 30, 300, dxfattribs={"linetype": "HIDDEN" if "HIDDEN" in doc.linetypes else "DASHED", "color": 3}) + msp.add_ellipse((150, y), major_axis=(25, 0), ratio=0.5, dxfattribs={"linetype": "PHANTOM", "color": 5}) + msp.add_spline([(190, y - 10), (200, y + 12), (215, y - 14), (225, y + 10)], dxfattribs={"linetype": "DASHDOT", "color": 6}) + save(doc, "linetypes.dxf") + + +# ------------------------------------------------------------- text styles + + +def text_styles(): + doc = new("R2018") + layer(doc, "TEXT", 7) + layer(doc, "NOTE", 8) + fonts = [("S-ARIAL", "arial.ttf", {}), ("S-TIMES", "times.ttf", {}), ("S-COURIER", "cour.ttf", {}), ("S-NARROW", "arial.ttf", {"width": 0.6}), + ("S-WIDE", "arial.ttf", {"width": 1.6}), ("S-SLANT", "times.ttf", {"oblique": 20}), ("S-ROMANS", "romans.shx", {}), ("S-SIMPLEX", "simplex.shx", {}), + ("S-BACKWARDS", "arial.ttf", {}), ("S-UPSIDE", "arial.ttf", {})] + for n, f, kw in fonts: + st = text_style(doc, n, f, **kw) + doc.styles.get("S-BACKWARDS").dxf.flags |= 2 + doc.styles.get("S-UPSIDE").dxf.flags |= 4 + msp = doc.modelspace() + y = 0 + sample = "Sphinx of black quartz, judge my vow 0123456789" + for n, f, kw in fonts: + msp.add_text(f"{n} ({f}): {sample}", height=6, dxfattribs={"style": n, "layer": "TEXT"}).set_placement((0, y)) + y -= 12 + y -= 6 + for i, (halign, name) in enumerate([(TextEntityAlignment.TOP_LEFT, "TOP_LEFT"), (TextEntityAlignment.MIDDLE_CENTER, "MIDDLE_CENTER"), (TextEntityAlignment.BOTTOM_RIGHT, "BOTTOM_RIGHT"), (TextEntityAlignment.ALIGNED, "ALIGNED"), (TextEntityAlignment.FIT, "FIT")]): + x = 20 + i * 130 + msp.add_line((x - 5, y), (x + 5, y), dxfattribs={"color": 1}) + msp.add_line((x, y - 5), (x, y + 5), dxfattribs={"color": 1}) + t = msp.add_text(name, height=6, dxfattribs={"style": "S-ARIAL", "layer": "TEXT"}) + if halign in (TextEntityAlignment.ALIGNED, TextEntityAlignment.FIT): + t.set_placement((x, y), (x + 70, y), align=halign) + else: + t.set_placement((x, y), align=halign) + y -= 24 + for angle in (0, 30, 60, 90): + msp.add_text(f"rotated {angle} degrees", height=6, rotation=angle, dxfattribs={"style": "S-TIMES", "layer": "TEXT"}).set_placement((20 + angle * 1.5, y)) + y -= 70 + msp.add_text("Special: %%d degree 45%%d, %%p plus/minus 0.5, %%c diameter 25, %%%% percent, %%u under%%u, %%o over%%o", height=5, dxfattribs={"style": "S-ARIAL", "layer": "TEXT"}).set_placement((0, y)) + y -= 10 + msp.add_text("Unicode: éèê üöä ß ñ ΔΩπ ЖÑ� € ½ ²³", height=5, dxfattribs={"style": "S-ARIAL", "layer": "TEXT"}).set_placement((0, y)) + y -= 20 + # MTEXT formatting. + def mt(text, x, yy, w=110, h=4, **kw): + m = msp.add_mtext(text, dxfattribs={"style": "S-ARIAL", "char_height": h, "layer": "TEXT", **kw}) + m.set_location((x, yy)) + m.dxf.width = w + return m + mt(r"{\C1;Red}, {\C3;green}, {\C5;blue} and {\c16744192;orange by RGB}.\P" + r"{\fTimes New Roman|b1;Bold serif}, {\fArial|i1;italic sans}, {\fCourier New|b1|i1;bold italic mono}.\P" + r"Underline \Lis on\l, overline \Ois on\o, \Kstrike-through\k.\P" + r"Height \H2x;doubles\H1x; and returns; width \W2;wide\W1; and back; slant \Q20;oblique\Q0;.\P" + r"Stacked: \S1/2; \S1#2; \S1^2; \S\U+00B1 0.1^ -0.1; and x\S2^;, H\S2^ ;.\P" + r"Tab\Pstops: A\tB\tC", 0, y, 120) + mt(r"{\L\C2;Paragraph 1}\PA justified-looking paragraph with a long text that wraps inside the box of the entity, " + r"set at width 90, to see how line breaks are made by the viewer.", 130, y, 90, 4.2) + m = mt("Background box, with a border in the text colour: the viewer fills the box behind the text.", 230, y, 70, 4.2) + m.set_bg_color((255, 235, 140), scale=1.5) + y -= 70 + cols = msp.add_mtext_static_columns(["Column one text, a short paragraph that sits at the left.", "Column two: more text, in the middle.", "Column three at the right."], width=40, gutter_width=6, height=40, dxfattribs={"style": "S-ARIAL", "char_height": 3.5, "layer": "TEXT"}) + cols.set_location((0, y)) + msp.add_text("MTEXT in three static columns", height=3, dxfattribs={"layer": "NOTE", "style": "S-ARIAL"}).set_placement((0, y - 44)) + for i, ap in enumerate(range(1, 10)): + m = msp.add_mtext(f"AP {ap}", dxfattribs={"style": "S-ARIAL", "char_height": 4, "layer": "TEXT", "attachment_point": ap}) + m.set_location((150 + (i % 3) * 40, y - (i // 3) * 14)) + m.dxf.width = 30 + msp.add_circle((150 + (i % 3) * 40, y - (i // 3) * 14), 0.8, dxfattribs={"color": 1}) + save(doc, "text-styles.dxf") + + +# ------------------------------------------------------------------ blocks + + +def blocks(): + doc = new("R2018") + layer(doc, "PARTS", 7) + layer(doc, "NOTE", 8) + layer(doc, "RED", 1) + text_style(doc, "BL-TEXT", "arial.ttf") + msp = doc.modelspace() + # A bolt: cap, shank and a thread hatch. + bolt = doc.blocks.new("BOLT") + bolt.add_lwpolyline([(-6, 0), (6, 0), (6, 4), (-6, 4)], close=True) + bolt.add_lwpolyline([(-3, 0), (3, 0), (3, -22), (-3, -22)], close=True) + for i in range(7): + bolt.add_line((-3, -4 - i * 2.5), (3, -6.5 - i * 2.5)) + bolt.add_attdef("SIZE", (8, 1), text="M6", height=2.5) + # A plate with four BOLT inserts in a nested block. + plate = doc.blocks.new("PLATE") + plate.add_lwpolyline([(0, 0), (100, 0), (100, 60), (0, 60)], close=True) + for x, y in [(15, 15), (85, 15), (15, 45), (85, 45)]: + plate.add_circle((x, y), 4) + plate.add_blockref("BOLT", (x, y), dxfattribs={"layer": "RED"}).add_auto_attribs({"SIZE": "M6"}) + plate.add_attdef("MARK", (50, 30), text="P-1", height=6, dxfattribs={"halign": 4, "valign": 0, "align_point": (50, 30)}) + # A frame that nests the plate twice at different scales. + frame = doc.blocks.new("FRAME") + frame.add_lwpolyline([(-10, -10), (330, -10), (330, 90), (-10, 90)], close=True) + frame.add_blockref("PLATE", (0, 0)).add_auto_attribs({"MARK": "A"}) + frame.add_blockref("PLATE", (120, 0), dxfattribs={"xscale": 0.7, "yscale": 0.7, "rotation": 0}).add_auto_attribs({"MARK": "B"}) + frame.add_blockref("PLATE", (230, 70), dxfattribs={"rotation": -90, "xscale": 0.6, "yscale": 0.6}).add_auto_attribs({"MARK": "C"}) + msp.add_blockref("FRAME", (0, 0), dxfattribs={"layer": "PARTS"}) + msp.add_text("FRAME: nested PLATE, BOLT blocks, attributes", height=4, dxfattribs={"layer": "NOTE", "style": "BL-TEXT"}).set_placement((0, -20)) + # Inserts: rotated, scaled, mirrored in x and y. + y = -80 + for i, (sx, sy, rot, label) in enumerate([(1, 1, 0, "scale 1"), (-1, 1, 0, "xscale -1"), (1, -1, 0, "yscale -1"), (1.5, 0.6, 0, "1.5 by 0.6"), (1, 1, 30, "rotated 30")]): + x = i * 130 + msp.add_blockref("PLATE", (x + (100 if sx < 0 else 0), y + (60 if sy < 0 else 0)), dxfattribs={"xscale": sx, "yscale": sy, "rotation": rot}).add_auto_attribs({"MARK": label}) + msp.add_text(label, height=4, dxfattribs={"layer": "NOTE", "style": "BL-TEXT"}).set_placement((x, y - 20)) + # Arrays: a MINSERT of bolts (rows and columns), spaced. + y -= 120 + arr = msp.add_blockref("BOLT", (0, y), dxfattribs={"layer": "PARTS"}) + arr.dxf.row_count = 3 + arr.dxf.column_count = 8 + arr.dxf.row_spacing = 35 + arr.dxf.column_spacing = 20 + arr.add_auto_attribs({"SIZE": "M6"}) + msp.add_text("MINSERT: 3 rows by 8 columns of BOLT", height=4, dxfattribs={"layer": "NOTE", "style": "BL-TEXT"}).set_placement((0, y - 110)) + # A block on a layer that is on, BYBLOCK colours. + sym = doc.blocks.new("SYMBOL") + sym.add_circle((0, 0), 10, dxfattribs={"color": 0}) + sym.add_line((-10, 0), (10, 0), dxfattribs={"color": 0}) + sym.add_line((0, -10), (0, 10), dxfattribs={"color": 256}) + for i, c in enumerate([1, 3, 5, 6]): + msp.add_blockref("SYMBOL", (200 + i * 30, y - 20), dxfattribs={"color": c}) + msp.add_text("BYBLOCK circle and cross, BYLAYER vertical line: insert colour 1, 3, 5, 6", height=3, dxfattribs={"layer": "NOTE", "style": "BL-TEXT"}).set_placement((200, y - 40)) + save(doc, "blocks.dxf") + + +# ----------------------------------------------------------------- bracket + + +def bracket(): + doc = new("R2018") + layer(doc, "OUTLINE", 7, weight=50) + layer(doc, "HIDDEN", 4, linetype="DASHED", weight=25) + layer(doc, "CENTER", 1, linetype="CENTER", weight=18) + layer(doc, "SECTION", 8, weight=9) + layer(doc, "DIMS", 3, weight=18) + layer(doc, "SHEET", 7, weight=35) + layer(doc, "TEXT", 2, weight=18) + doc.ltscale = 1.0 + text_style(doc, "MECH", "arial.ttf") + dimstyle(doc, "MECH", 3.5, dimtxsty="MECH", dimdec=1) + dimstyle(doc, "MECHTOL", 3.5, dimtxsty="MECH", dimdec=2, dimtol=1, dimtp=0.1, dimtm=0.0, dimtfac=0.7, dimtolj=1) + msp = doc.modelspace() + O, Hd, C = {"layer": "OUTLINE"}, {"layer": "HIDDEN"}, {"layer": "CENTER"} + # Front view: an L bracket with two holes in the base and a slot in the upright. + # Base 100 x 60, thickness 12; upright 60 high; fillet radius 8. + pts = [(0, 0), (100, 0), (100, 12), (20, 12), (12, 12), (12, 72), (0, 72)] + msp.add_lwpolyline(pts, close=True, dxfattribs=O) + msp.add_arc((20, 20), 8, 180, 270, dxfattribs=O) + msp.add_line((12, 12), (12, 20), dxfattribs={"layer": "OUTLINE", "color": 8}) + msp.add_line((0, 0), (100, 0), dxfattribs=O) + # The holes seen from the front: hidden. + for x in (35, 80): + msp.add_line((x - 4, 0), (x - 4, 12), dxfattribs=Hd) + msp.add_line((x + 4, 0), (x + 4, 12), dxfattribs=Hd) + msp.add_line((x, -6), (x, 18), dxfattribs=C) + msp.add_line((-6, 52), (18, 52), dxfattribs=C) + # The upright's slot, hidden. + msp.add_line((0, 45), (12, 45), dxfattribs=Hd) + msp.add_line((0, 59), (12, 59), dxfattribs=Hd) + # Top view: below the front view. + oy = -90 + msp.add_lwpolyline([(0, oy), (100, oy), (100, oy - 60), (0, oy - 60)], close=True, dxfattribs=O) + msp.add_line((12, oy), (12, oy - 60), dxfattribs=O) + for x in (35, 80): + msp.add_circle((x, oy - 30), 4, dxfattribs=O) + msp.add_line((x - 8, oy - 30), (x + 8, oy - 30), dxfattribs=C) + msp.add_line((x, oy - 22), (x, oy - 38), dxfattribs=C) + msp.add_lwpolyline([(0, oy - 20), (12, oy - 20)], dxfattribs=Hd) + msp.add_lwpolyline([(0, oy - 40), (12, oy - 40)], dxfattribs=Hd) + msp.add_lwpolyline([(0, oy - 20, 0, 0, 0), (0, oy - 40, 0, 0, 0)], format="xyseb", dxfattribs={"layer": "OUTLINE", "color": 8}) + # Section A-A through a hole, to the right: hatched cut. + sx = 150 + cut = [(sx, 0), (sx + 60, 0), (sx + 60, 12), (sx + 42, 12), (sx + 42, 0 + 12), (sx + 38, 12), (sx + 12, 12), (sx + 12, 72), (sx, 72)] + sec = [(sx, 0), (sx + 31, 0), (sx + 31, 12), (sx + 39, 12), (sx + 39, 0), (sx + 60, 0), (sx + 60, 12), (sx + 12, 12), (sx + 12, 72), (sx, 72)] + msp.add_lwpolyline([(sx, 0), (sx + 31, 0), (sx + 31, 12), (sx + 12, 12), (sx + 12, 72), (sx, 72)], close=True, dxfattribs=O) + msp.add_lwpolyline([(sx + 39, 0), (sx + 60, 0), (sx + 60, 12), (sx + 39, 12)], close=True, dxfattribs=O) + h = msp.add_hatch(color=8, dxfattribs={"layer": "SECTION"}) + h.set_pattern_fill("ANSI31", color=8, scale=2.0) + h.paths.add_polyline_path([(sx, 0), (sx + 31, 0), (sx + 31, 12), (sx + 12, 12), (sx + 12, 72), (sx, 72)], is_closed=True) + h2 = msp.add_hatch(color=8, dxfattribs={"layer": "SECTION"}) + h2.set_pattern_fill("ANSI31", color=8, scale=2.0) + h2.paths.add_polyline_path([(sx + 39, 0), (sx + 60, 0), (sx + 60, 12), (sx + 39, 12)], is_closed=True) + msp.add_line((sx + 35, -6), (sx + 35, 18), dxfattribs=C) + msp.add_text("SECTION A-A", height=5, dxfattribs={"layer": "TEXT", "style": "MECH"}).set_placement((sx, -14)) + # Cutting plane line in the top view. + msp.add_lwpolyline([(-8, oy - 30), (108, oy - 30)], dxfattribs={"layer": "CENTER", "lineweight": 50}) + msp.add_text("A", height=6, dxfattribs={"layer": "TEXT", "style": "MECH"}).set_placement((-14, oy - 33)) + msp.add_text("A", height=6, dxfattribs={"layer": "TEXT", "style": "MECH"}).set_placement((112, oy - 33)) + # Dimensions, two with tolerances. + D = {"layer": "DIMS"} + msp.add_linear_dim(base=(0, -12), p1=(0, 0), p2=(100, 0), dimstyle="MECH", dxfattribs=D).render() + msp.add_linear_dim(base=(-12, 0), p1=(0, 0), p2=(0, 72), angle=90, dimstyle="MECHTOL", dxfattribs=D).render() + msp.add_linear_dim(base=(0, -24), p1=(35, 0), p2=(80, 0), dimstyle="MECHTOL", dxfattribs=D).render() + msp.add_radius_dim(center=(20, 20), radius=8, angle=225, dimstyle="MECH", dxfattribs=D).render() + msp.add_linear_dim(base=(0, oy + 12), p1=(0, oy), p2=(100, oy), dimstyle="MECH", dxfattribs=D).render() + msp.add_diameter_dim(center=(80, oy - 30), radius=4, angle=35, dimstyle="MECH", dxfattribs=D, text="2x ⌀<>").render() + msp.add_linear_dim(base=(sx + 70, 0), p1=(sx + 60, 0), p2=(sx + 60, 12), angle=90, dimstyle="MECH", dxfattribs=D).render() + msp.add_leader([(sx + 5, 60), (sx - 12, 80), (sx - 40, 80)], dxfattribs={"layer": "DIMS"}) + msp.add_text("slot 14 x 12", height=3.5, dxfattribs={"layer": "TEXT", "style": "MECH"}).set_placement((sx - 40, 82)) + # A sheet: layout with a viewport and a title block. + lay = doc.layouts.new("A4 Sheet") + lay.page_setup(size=(297, 210), margins=(5, 5, 5, 5), units="mm") + rect(lay, 5, 5, 292, 205, layer="SHEET") + rect(lay, 137, 5, 292, 45, layer="SHEET") + lay.add_line((137, 25), (292, 25), dxfattribs={"layer": "SHEET"}) + lay.add_line((200, 5), (200, 25), dxfattribs={"layer": "SHEET"}) + lay.add_line((250, 5), (250, 25), dxfattribs={"layer": "SHEET"}) + for txt, x, y, hgt in [("L-BRACKET 100 x 72 x 60", 142, 33, 6), ("Material: steel S235", 142, 15, 3.5), ("Scale 1:1", 205, 15, 3.5), ("Sheet 1/1", 255, 15, 3.5), ("Drawn: exav demo", 142, 8, 3)]: + lay.add_text(txt, height=hgt, dxfattribs={"layer": "SHEET", "style": "MECH"}).set_placement((x, y)) + vp = lay.add_viewport(center=(130, 120), size=(250, 140), view_center_point=(90, -30), view_height=140, status=1) + save(doc, "bracket.dxf") + + +# --------------------------------------------------------------- site plan + + +def site_plan(): + doc = new("R2018", units=6) + layer(doc, "CONTOUR-MINOR", 8, weight=9) + layer(doc, "CONTOUR-MAJOR", 30, weight=25) + layer(doc, "PARCEL", 1, linetype="DASHDOT", weight=35) + layer(doc, "BUILDING", 7, weight=50) + layer(doc, "ROAD", 9, weight=18) + layer(doc, "WATER", 5, weight=18) + layer(doc, "TREES", 3, weight=9) + layer(doc, "SPOT", 2, weight=9) + layer(doc, "TEXT", 2, weight=13) + layer(doc, "LEGEND", 7, weight=13) + text_style(doc, "SITE", "arial.ttf") + doc.ltscale = 6 + make_blocks(doc) + msp = doc.modelspace() + + def height(x, y): + return 100 + 18 * math.sin(x / 38) * math.cos(y / 47) + 8 * math.sin((x + y) / 21) + (x - 160) * 0.05 + + # Contours: marching squares would be long; use closed hill rings as splines at several heights. + for k, hgt in enumerate(range(94, 134, 2)): + r = 12 + k * 7 + cx, cy = 150 + 3 * k, 110 - 2 * k + pts = [] + for i in range(24): + a = i * 2 * math.pi / 24 + rr = r * (1 + 0.12 * math.sin(3 * a + k * 0.3) + 0.06 * math.cos(5 * a)) + pts.append((cx + rr * math.cos(a), cy + rr * 0.75 * math.sin(a))) + major = hgt % 10 == 0 + lyr = "CONTOUR-MAJOR" if major else "CONTOUR-MINOR" + msp.add_spline(pts + pts[:1], degree=3, dxfattribs={"layer": lyr}) + if major: + msp.add_text(f"{hgt}", height=3, dxfattribs={"layer": "TEXT", "style": "SITE"}).set_placement((pts[0][0] + 1, pts[0][1]), align=TextEntityAlignment.MIDDLE_LEFT) + # Roads: two polylines with a width, a roundabout. + msp.add_lwpolyline([(-40, 40), (60, 50), (140, 20), (260, 30), (360, 70)], dxfattribs={"layer": "ROAD", "const_width": 9}) + msp.add_lwpolyline([(140, 20), (150, -40), (130, -90)], dxfattribs={"layer": "ROAD", "const_width": 6}) + msp.add_circle((140, 20), 12, dxfattribs={"layer": "ROAD"}) + # Parcels with their numbers and areas. + parcels = [[(20, 60), (120, 66), (116, 150), (14, 140)], [(120, 66), (230, 62), (240, 160), (116, 150)], [(230, 62), (330, 80), (320, 170), (240, 160)]] + for i, p in enumerate(parcels): + msp.add_lwpolyline(p, close=True, dxfattribs={"layer": "PARCEL"}) + cx = sum(a for a, _ in p) / 4 + cy = sum(b for _, b in p) / 4 + area = 0.5 * abs(sum(p[j][0] * p[(j + 1) % 4][1] - p[(j + 1) % 4][0] * p[j][1] for j in range(4))) + m = msp.add_mtext(rf"Parcel {101 + i}\P{area:.0f} m²", dxfattribs={"layer": "TEXT", "style": "SITE", "char_height": 5, "attachment_point": 5}) + m.set_location((cx, cy - 40)) + # Buildings with a hatch. + for x, y, w, d in [(40, 90, 40, 26), (150, 95, 34, 30), (262, 110, 44, 28)]: + msp.add_lwpolyline([(x, y), (x + w, y), (x + w, y + d), (x, y + d)], close=True, dxfattribs={"layer": "BUILDING"}) + h = msp.add_hatch(color=252, dxfattribs={"layer": "BUILDING"}) + h.set_pattern_fill("ANSI31", color=8, scale=3) + h.paths.add_polyline_path([(x, y), (x + w, y), (x + w, y + d), (x, y + d)], is_closed=True) + # A pond (hatched gradient), trees as circles and points with heights. + pond = [(280, -10), (310, -25), (340, -5), (325, 20), (295, 18)] + msp.add_lwpolyline(pond, close=True, dxfattribs={"layer": "WATER"}) + h = msp.add_hatch(dxfattribs={"layer": "WATER"}) + h.set_gradient((140, 200, 255), (30, 90, 170), rotation=90, name="LINEAR") + h.paths.add_polyline_path(pond, is_closed=True) + import random + rnd = random.Random(5) + for _ in range(60): + x, y = rnd.uniform(-20, 340), rnd.uniform(-110, 40) + if 270 < x < 350 and -30 < y < 25: + continue + msp.add_circle((x, y), rnd.uniform(1.5, 3.2), dxfattribs={"layer": "TREES"}) + for i in range(30): + x, y = rnd.uniform(0, 330), rnd.uniform(-90, 170) + msp.add_point((x, y), dxfattribs={"layer": "SPOT", "color": 2}) + msp.add_text(f"{height(x, y):.2f}", height=1.8, dxfattribs={"layer": "SPOT", "style": "SITE"}).set_placement((x + 1.5, y + 0.5)) + # North arrow, scale bar, legend. + msp.add_blockref("NORTH", (370, 180), dxfattribs={"layer": "LEGEND", "xscale": 0.02, "yscale": 0.02, "zscale": 0.02}) + for i in range(5): + msp.add_lwpolyline([(0 + i * 20, -130), (20 + i * 20, -130), (20 + i * 20, -125), (0 + i * 20, -125)], close=True, dxfattribs={"layer": "LEGEND", "color": 7 if i % 2 else 8}) + h = msp.add_hatch(color=7 if i % 2 else 252, dxfattribs={"layer": "LEGEND"}) + h.set_solid_fill(color=7 if i % 2 else 252) + h.paths.add_polyline_path([(0 + i * 20, -130), (20 + i * 20, -130), (20 + i * 20, -125), (0 + i * 20, -125)], is_closed=True) + for i, v in enumerate((0, 20, 40, 60, 80, 100)): + msp.add_text(f"{v}", height=3, dxfattribs={"layer": "LEGEND", "style": "SITE"}).set_placement((v, -138), align=TextEntityAlignment.TOP_CENTER) + msp.add_text("m", height=3, dxfattribs={"layer": "LEGEND", "style": "SITE"}).set_placement((108, -138)) + ly = 170 + for lyr, label in [("CONTOUR-MINOR", "Contour, 2 m"), ("CONTOUR-MAJOR", "Contour, 10 m"), ("PARCEL", "Parcel boundary"), ("BUILDING", "Building"), ("ROAD", "Road"), ("WATER", "Water"), ("TREES", "Tree")]: + msp.add_line((300 + 70, ly), (320 + 70, ly), dxfattribs={"layer": lyr}) + msp.add_text(label, height=3, dxfattribs={"layer": "LEGEND", "style": "SITE"}).set_placement((324 + 70, ly - 1)) + ly -= 8 + msp.add_text("SITE PLAN 1:1000", height=8, dxfattribs={"layer": "TEXT", "style": "SITE"}).set_placement((0, -160)) + save(doc, "site-plan.dxf") + + +# --------------------------------------------------------------- R12 drawing + + +def legacy_r12(): + doc = ezdxf.new("R12") + doc.layers.add("GEAR", color=7) + doc.layers.add("AXES", color=1, linetype="CENTER") if "CENTER" in doc.linetypes else doc.layers.add("AXES", color=1) + doc.layers.add("TEXT", color=2) + doc.layers.add("DIMS", color=3) + msp = doc.modelspace() + teeth, r_out, r_in = 20, 40.0, 33.0 + pts = [] + for i in range(teeth): + a0 = i * 2 * math.pi / teeth + step = 2 * math.pi / teeth + for da, r in [(0.0, r_in), (step * 0.2, r_out), (step * 0.55, r_out), (step * 0.75, r_in)]: + pts.append((r * math.cos(a0 + da), r * math.sin(a0 + da))) + msp.add_polyline2d(pts, close=True, dxfattribs={"layer": "GEAR"}) + msp.add_circle((0, 0), 10, dxfattribs={"layer": "GEAR"}) + msp.add_circle((0, 0), 4, dxfattribs={"layer": "GEAR"}) + for i in range(6): + a = i * math.pi / 3 + msp.add_circle((20 * math.cos(a), 20 * math.sin(a)), 4, dxfattribs={"layer": "GEAR"}) + msp.add_line((-50, 0), (50, 0), dxfattribs={"layer": "AXES"}) + msp.add_line((0, -50), (0, 50), dxfattribs={"layer": "AXES"}) + msp.add_arc((0, 0), r_in - 3, 30, 150, dxfattribs={"layer": "GEAR", "color": 4}) + msp.add_text("GEAR, 20 TEETH (R12 FILE)", height=4, dxfattribs={"layer": "TEXT"}).set_placement((-45, -58)) + msp.add_text("module 4, pitch diameter 80", height=3, dxfattribs={"layer": "TEXT"}).set_placement((-45, -64)) + msp.add_point((0, 0), dxfattribs={"layer": "AXES"}) + msp.add_solid([(55, 40), (65, 40), (55, 46), (65, 46)], dxfattribs={"layer": "DIMS"}) + msp.add_3dface([(55, -10, 0), (70, -10, 0), (70, 5, 0), (55, 5, 0)], dxfattribs={"layer": "DIMS", "color": 5}) + blk = doc.blocks.new("TAG") + blk.add_circle((0, 0), 5) + blk.add_text("1", dxfattribs={"height": 5}).set_placement((-1.5, -2)) + for i in range(3): + msp.add_blockref("TAG", (60, 20 - i * 15), dxfattribs={"layer": "DIMS", "xscale": 1 + i * 0.3, "yscale": 1 + i * 0.3}) + save(doc, "legacy-r12.dxf") + + +if __name__ == "__main__": + house_plan("R2018", "house-plan.dxf") + house_plan("R2000", "house-plan-r2000.dxf") + house_elevations() + hatch_gallery() + dimensions() + linetypes() + text_styles() + blocks() + bracket() + site_plan() + legacy_r12() diff --git a/crates/exav-viewer/demo/examples/generate/make-ifc.py b/crates/exav-viewer/demo/examples/generate/make-ifc.py new file mode 100644 index 00000000..0e0225f8 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/make-ifc.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Writes the demo's IFC example into ../files/ifc/. + + python3 -I make-ifc.py [OUT_DIR] + +Output: CC0-1.0, a public domain dedication by the exav project. The model is +written here as STEP text, entity by entity, with no IFC library and no +outside model. + +Needs: Python 3. + +house.ifc (IFC4): a two-storey house, 10 by 8 metres. Per storey: outer and +inner walls (extruded rectangles), openings cut by IfcRelVoidsElement and +filled by windows and doors (IfcRelFillsElement) with separate frame and glass +colours, a floor slab; a gable roof (an extruded triangle), a porch with +columns, a straight stair (a step per solid), a table and four chairs that +share one mapped representation, spaces, a material and a property set, and +colours through IfcStyledItem. +""" +import hashlib +import math +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(HERE, "..", "files", "ifc") +os.makedirs(OUT, exist_ok=True) + +ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$" + + +def guid(n): + num = int.from_bytes(hashlib.md5(f"exav-house-{n}".encode()).digest(), "big") + chars = [ALPHABET[num >> 126]] + for i in range(21): + chars.append(ALPHABET[(num >> (120 - 6 * i)) & 63]) + return "".join(chars) + + +def f(x): + s = repr(float(x)) + return s if "e" in s or "." in s else s + "." + + +class Step: + def __init__(self): + self.lines = [] + self.n = 0 + self.g = 0 + + def add(self, text): + self.n += 1 + self.lines.append(f"#{self.n}={text};") + return f"#{self.n}" + + def gid(self): + self.g += 1 + return f"'{guid(self.g)}'" + + +S = Step() +pt_cache = {} + + +def point(x, y, z=None): + key = (round(x, 6), round(y, 6), None if z is None else round(z, 6)) + if key not in pt_cache: + pt_cache[key] = S.add(f"IFCCARTESIANPOINT(({f(x)},{f(y)}" + (f",{f(z)}))" if z is not None else "))")) + return pt_cache[key] + + +def direction(x, y, z=None): + return S.add(f"IFCDIRECTION(({f(x)},{f(y)}" + (f",{f(z)}))" if z is not None else "))")) + + +def axis3(origin=(0, 0, 0), z=None, x=None): + return S.add(f"IFCAXIS2PLACEMENT3D({point(*origin)}," + (direction(*z) if z else "$") + "," + (direction(*x) if x else "$") + ")") + + +def axis2(origin=(0, 0), x=None): + return S.add(f"IFCAXIS2PLACEMENT2D({point(*origin)}," + (direction(*x) if x else "$") + ")") + + +def placement(rel, origin=(0, 0, 0), z=None, x=None): + return S.add(f"IFCLOCALPLACEMENT({rel if rel else '$'},{axis3(origin, z, x)})") + + +def rect_profile(w, d, centre=(0, 0)): + return S.add(f"IFCRECTANGLEPROFILEDEF(.AREA.,$,{axis2(centre)},{f(w)},{f(d)})") + + +def circle_profile(r): + return S.add(f"IFCCIRCLEPROFILEDEF(.AREA.,$,{axis2()},{f(r)})") + + +def poly_profile(points): + pl = S.add("IFCPOLYLINE((" + ",".join(point(*p) for p in points + [points[0]]) + "))") + return S.add(f"IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,{pl})") + + +def extrude(profile, depth, origin=(0, 0, 0), z=None, x=None): + return S.add(f"IFCEXTRUDEDAREASOLID({profile},{axis3(origin, z, x)},{direction(0, 0, 1)},{f(depth)})") + + +# ------------------------------------------------------------------ styles + +style_cache = {} + + +def style(name, rgb, transparency=0.0): + if name not in style_cache: + col = S.add(f"IFCCOLOURRGB($,{f(rgb[0])},{f(rgb[1])},{f(rgb[2])})") + rend = S.add(f"IFCSURFACESTYLERENDERING({col},{f(transparency)},$,$,$,$,$,$,.FLAT.)") + style_cache[name] = S.add(f"IFCSURFACESTYLE('{name}',.BOTH.,({rend}))") + return style_cache[name] + + +def styled(item, name, rgb, transparency=0.0): + S.add(f"IFCSTYLEDITEM({item},({style(name, rgb, transparency)}),$)") + return item + + +# --------------------------------------------------------------- the model + +origin_axis = axis3() +ctx = S.add(f"IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,{origin_axis},$)") +body = S.add(f"IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,{ctx},$,.MODEL_VIEW.,$)") +length = S.add("IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.)") +area = S.add("IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.)") +volume = S.add("IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.)") +angle = S.add("IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.)") +units = S.add(f"IFCUNITASSIGNMENT(({length},{area},{volume},{angle}))") +project = S.add(f"IFCPROJECT({S.gid()},$,'Example house',$,$,$,$,({ctx}),{units})") + +site_pl = placement(None) +site = S.add(f"IFCSITE({S.gid()},$,'Site',$,$,{site_pl},$,$,.ELEMENT.,$,$,$,$,$)") +bld_pl = placement(site_pl) +building = S.add(f"IFCBUILDING({S.gid()},$,'House',$,$,{bld_pl},$,$,.ELEMENT.,$,$,$)") +STOREY_H = 3.0 +storeys = [] +for i, name in enumerate(["Ground floor", "First floor"]): + pl = placement(bld_pl, (0, 0, i * STOREY_H)) + storeys.append((S.add(f"IFCBUILDINGSTOREY({S.gid()},$,'{name}',$,$,{pl},$,$,.ELEMENT.,{f(i * STOREY_H)})"), pl)) +S.add(f"IFCRELAGGREGATES({S.gid()},$,$,$,{project},({site}))") +S.add(f"IFCRELAGGREGATES({S.gid()},$,$,$,{site},({building}))") +S.add(f"IFCRELAGGREGATES({S.gid()},$,$,$,{building},({storeys[0][0]},{storeys[1][0]}))") + +contained = {s[0]: [] for s in storeys} +walls_all = [] +brick = S.add("IFCMATERIAL('Brick, rendered')") +WALL_RGB, FRAME_RGB, GLASS_RGB, DOOR_RGB = (0.93, 0.9, 0.82), (0.95, 0.95, 0.95), (0.55, 0.78, 0.92), (0.45, 0.28, 0.14) + + +def shape(items, kind="SweptSolid"): + rep = S.add(f"IFCSHAPEREPRESENTATION({body},'Body','{kind}',({','.join(items)}))") + return S.add(f"IFCPRODUCTDEFINITIONSHAPE($,$,({rep}))") + + +def add_wall(storey, p1, p2, t, h, openings, name): + """openings: [(kind, offset along the wall, width, sill, height)].""" + sidx = storey + sto, sto_pl = storeys[sidx] + dx, dy = p2[0] - p1[0], p2[1] - p1[1] + L = math.hypot(dx, dy) + ux, uy = dx / L, dy / L + pl = placement(sto_pl, (p1[0], p1[1], 0), (0, 0, 1), (ux, uy, 0)) + solid = styled(extrude(rect_profile(L, t, (L / 2, 0)), h), "Render", WALL_RGB) + wall = S.add(f"IFCWALL({S.gid()},$,'{name}',$,$,{pl},{shape([solid])},$,.STANDARD.)") + contained[sto].append(wall) + walls_all.append(wall) + for k, (kind, off, w, sill, oh) in enumerate(openings): + opl = placement(pl, (off + w / 2, 0, sill)) + hole = extrude(rect_profile(w, t + 0.2), oh) + opening = S.add(f"IFCOPENINGELEMENT({S.gid()},$,'Opening {name} {k + 1}',$,$,{opl},{shape([hole])},$,.OPENING.)") + S.add(f"IFCRELVOIDSELEMENT({S.gid()},$,$,$,{wall},{opening})") + fpl = placement(pl, (off + w / 2, 0, sill)) + if kind == "window": + frame = styled(extrude(rect_profile(w, 0.1), oh), "Frame", FRAME_RGB) + glass = styled(extrude(rect_profile(w - 0.12, 0.04), oh - 0.12, (0, 0, 0.06)), "Glass", GLASS_RGB, 0.55) + bar = styled(extrude(rect_profile(0.05, 0.12), oh), "Frame", FRAME_RGB) + el = S.add(f"IFCWINDOW({S.gid()},$,'Window {name} {k + 1}',$,$,{fpl},{shape([frame, glass, bar])},$,{f(oh)},{f(w)},.WINDOW.,.NOTDEFINED.,$)") + else: + leaf = styled(extrude(rect_profile(w - 0.1, 0.06), oh - 0.05), "Door", DOOR_RGB) + knob = styled(extrude(rect_profile(0.08, 0.14), 0.08, (w / 2 - 0.2, 0, 1.0)), "Frame", FRAME_RGB) + el = S.add(f"IFCDOOR({S.gid()},$,'Door {name} {k + 1}',$,$,{fpl},{shape([leaf, knob])},$,{f(oh)},{f(w)},.DOOR.,.NOTDEFINED.,$)") + S.add(f"IFCRELFILLSELEMENT({S.gid()},$,$,$,{opening},{el})") + contained[sto].append(el) + return wall + + +H = 2.8 +for sidx in (0, 1): + tag = "G" if sidx == 0 else "F" + door_south = [("door", 6.7, 1.1, 0.0, 2.1)] if sidx == 0 else [("window", 6.9, 1.2, 0.9, 1.3)] + add_wall(sidx, (0, 0), (10, 0), 0.3, H, [("window", 1.0, 1.2, 0.9, 1.3), ("window", 3.6, 1.2, 0.9, 1.3)] + door_south, f"{tag}-South") + add_wall(sidx, (10, 0), (10, 8), 0.3, H, [("window", 1.4, 1.3, 0.9, 1.3), ("window", 5.0, 1.3, 0.9, 1.3)], f"{tag}-East") + add_wall(sidx, (10, 8), (0, 8), 0.3, H, [("window", 1.2, 1.4, 0.9, 1.3), ("window", 4.1, 1.6, 0.9, 1.3), ("window", 7.2, 1.2, 0.9, 1.3)], f"{tag}-North") + add_wall(sidx, (0, 8), (0, 0), 0.3, H, [("window", 1.6, 1.4, 0.9, 1.3), ("window", 5.0, 1.4, 0.9, 1.3)], f"{tag}-West") + add_wall(sidx, (6, 0.15), (6, 7.85), 0.15, H, [("door", 1.5, 0.9, 0.0, 2.05), ("door", 5.0, 0.9, 0.0, 2.05)], f"{tag}-Partition A") + add_wall(sidx, (0.15, 4), (6, 4), 0.15, H, [("door", 1.0, 0.9, 0.0, 2.05)], f"{tag}-Partition B") + +# Slabs: ground slab under the house, a floor slab between the storeys, a porch slab. +ground, ground_pl = storeys[0] +first, first_pl = storeys[1] +slab1 = styled(extrude(rect_profile(10.3, 8.3, (5, 4)), 0.2, (0, 0, -0.2)), "Concrete", (0.62, 0.62, 0.6)) +s1 = S.add(f"IFCSLAB({S.gid()},$,'Ground slab',$,$,{placement(ground_pl)},{shape([slab1])},$,.BASESLAB.)") +slab2 = styled(extrude(poly_profile([(-0.15, -0.15), (10.15, -0.15), (10.15, 8.15), (-0.15, 8.15)]), 0.2, (0, 0, H)), "Concrete", (0.7, 0.7, 0.68)) +s2 = S.add(f"IFCSLAB({S.gid()},$,'Floor slab',$,$,{placement(ground_pl)},{shape([slab2])},$,.FLOOR.)") +porch = styled(extrude(rect_profile(4.0, 1.4, (8.2, -0.8)), 0.15, (0, 0, H)), "Concrete", (0.7, 0.7, 0.68)) +s3 = S.add(f"IFCSLAB({S.gid()},$,'Porch roof',$,$,{placement(ground_pl)},{shape([porch])},$,.ROOF.)") +contained[ground] += [s1, s2, s3] +for k, (cx, cy) in enumerate([(6.4, -1.3), (10.0, -1.3)]): + col = styled(extrude(circle_profile(0.15), H, (0, 0, 0)), "Column", (0.9, 0.9, 0.9)) + contained[ground].append(S.add(f"IFCCOLUMN({S.gid()},$,'Porch column {k + 1}',$,$,{placement(ground_pl, (cx, cy, 0))},{shape([col])},$,.COLUMN.)")) + +# Roof: a triangle in the YZ plane, extruded along X. +tri = poly_profile([(-0.6, 0.0), (8.6, 0.0), (4.0, 2.7)]) +roof_solid = styled(extrude(tri, 11.2, (0, 0, 0), (1, 0, 0), (0, 1, 0)), "Tiles", (0.7, 0.28, 0.2)) +roof_pl = placement(first_pl, (-0.6, 0, 2.8 + 0.2)) +roof = S.add(f"IFCROOF({S.gid()},$,'Gable roof',$,$,{roof_pl},{shape([roof_solid])},$,.GABLE_ROOF.)") +contained[first].append(roof) + +# A straight stair in the hall: eleven steps, each a block up to its tread. +steps = [] +for k in range(11): + top = 0.25 * (k + 1) + steps.append(styled(extrude(rect_profile(1.0, 0.28, (0.0, 0.14 + 0.28 * k)), top), "Wood", (0.6, 0.42, 0.25))) +stair = S.add(f"IFCSTAIR({S.gid()},$,'Stair',$,$,{placement(ground_pl, (6.9, 4.3, 0))},{shape(steps)},$,.STRAIGHT_RUN_STAIR.)") +contained[ground].append(stair) + +# A table and four chairs sharing one mapped representation. +def furniture_map(parts, name): + rep = S.add(f"IFCSHAPEREPRESENTATION({body},'Body','SweptSolid',({','.join(parts)}))") + return S.add(f"IFCREPRESENTATIONMAP({axis3()},{rep})") + + +chair_parts = [styled(extrude(rect_profile(0.45, 0.45, (0, 0)), 0.05, (0, 0, 0.45)), "Chair", (0.75, 0.2, 0.2)), + styled(extrude(rect_profile(0.45, 0.05, (0, -0.2)), 0.5, (0, 0, 0.5)), "Chair", (0.75, 0.2, 0.2))] +for dx in (-0.19, 0.19): + for dy in (-0.19, 0.19): + chair_parts.append(styled(extrude(rect_profile(0.05, 0.05, (dx, dy)), 0.45), "Chair legs", (0.3, 0.2, 0.15))) +chair_map = furniture_map(chair_parts, "Chair") +table_parts = [styled(extrude(rect_profile(1.6, 0.9, (0, 0)), 0.06, (0, 0, 0.72)), "Wood", (0.6, 0.42, 0.25))] +for dx in (-0.72, 0.72): + for dy in (-0.36, 0.36): + table_parts.append(styled(extrude(rect_profile(0.07, 0.07, (dx, dy)), 0.72), "Wood", (0.5, 0.35, 0.2))) +table_map = furniture_map(table_parts, "Table") + + +def mapped(rep_map, scale=1.0): + op = S.add(f"IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,{point(0, 0, 0)},{f(scale)},$)") + item = S.add(f"IFCMAPPEDITEM({rep_map},{op})") + return shape([item], "MappedRepresentation") + + +contained[ground].append(S.add(f"IFCFURNISHINGELEMENT({S.gid()},$,'Table',$,$,{placement(ground_pl, (3.0, 6.0, 0))},{mapped(table_map)},$)")) +for k, (cx, cy, rot) in enumerate([(2.4, 6.0, 90), (3.6, 6.0, -90), (3.0, 5.2, 180), (3.0, 6.8, 0)]): + a = math.radians(rot) + contained[ground].append(S.add(f"IFCFURNISHINGELEMENT({S.gid()},$,'Chair {k + 1}',$,$,{placement(ground_pl, (cx, cy, 0), (0, 0, 1), (math.cos(a), math.sin(a), 0))},{mapped(chair_map)},$)")) + +# Spaces (no geometry), a property set and the material on the walls. +spaces = [] +for name, plc in [("Living room", ground_pl), ("Kitchen", ground_pl), ("Hall", ground_pl), ("Bedroom", first_pl), ("Bath", first_pl)]: + spaces.append(S.add(f"IFCSPACE({S.gid()},$,'{name}',$,$,{placement(plc)},$,$,.ELEMENT.,.INTERNAL.,$)")) +S.add(f"IFCRELAGGREGATES({S.gid()},$,$,$,{ground},({spaces[0]},{spaces[1]},{spaces[2]}))") +S.add(f"IFCRELAGGREGATES({S.gid()},$,$,$,{first},({spaces[3]},{spaces[4]}))") +prop_ext = S.add("IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$)") +prop_fire = S.add("IFCPROPERTYSINGLEVALUE('FireRating',$,IFCLABEL('REI 60'),$)") +pset = S.add(f"IFCPROPERTYSET({S.gid()},$,'Pset_WallCommon',$,({prop_ext},{prop_fire}))") +S.add(f"IFCRELDEFINESBYPROPERTIES({S.gid()},$,$,$,({','.join(walls_all[:4])}),{pset})") +S.add(f"IFCRELASSOCIATESMATERIAL({S.gid()},$,$,$,({','.join(walls_all)}),{brick})") +for sto, items in contained.items(): + S.add(f"IFCRELCONTAINEDINSPATIALSTRUCTURE({S.gid()},$,$,$,({','.join(items)}),{sto})") + +text = ( + "ISO-10303-21;\nHEADER;\n" + "FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');\n" + "FILE_NAME('house.ifc','2000-01-01T00:00:00',('exav'),('exav'),'exav make-ifc.py','exav make-ifc.py','');\n" + "FILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n" + "\n".join(S.lines) + "\nENDSEC;\nEND-ISO-10303-21;\n" +) +with open(os.path.join(OUT, "house.ifc"), "w", newline="\n") as fh: + fh.write(text) diff --git a/crates/exav-viewer/demo/examples/generate/make-images.py b/crates/exav-viewer/demo/examples/generate/make-images.py new file mode 100644 index 00000000..90e94cfd --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/make-images.py @@ -0,0 +1,593 @@ +#!/usr/bin/env python3 +"""Writes the demo's raster image examples into ../files/images/. + + python3 -I make-images.py [OUT_DIR] + +Output: CC0-1.0, a public domain dedication by the exav project. Every +picture is drawn here from scratch with numpy and Pillow (text in Pillow's +bundled Aileron font, CC0): no outside image is used. + +Needs: Python 3, Pillow 10 or later (with libtiff, WebP and OpenJPEG), +numpy; the programs opj_compress (libopenjp2-tools), jbig2 (jbig2enc), +cjxl (libjxl-tools) and tiffcp (libtiff-tools) for the JPEG 2000, JBIG2, +JPEG XL and fax files. Without a program, the files it makes are skipped. + +The encoders for QOI, farbfeld, Radiance HDR, DDS (BC1 and BC3), BMP with +RLE8 and the legacy ICO are written here, so that no encoder under another +licence is involved. +""" +import io +import math +import os +import shutil +import struct +import subprocess +import sys +import tempfile + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(HERE, "..", "files", "images") +WORK = tempfile.mkdtemp() +os.makedirs(OUT, exist_ok=True) + + +def font(size): + return ImageFont.load_default(size) + + +def out(name): + return os.path.join(OUT, name) + + +def tool(name): + return shutil.which(name) is not None + + +# ---------------------------------------------------------------- the picture + + +def ridge(w, seed, base, amp): + """A mountain line: heights in 0..1 from the top, one per column.""" + rng = np.random.default_rng(seed) + x = np.linspace(0, 1, w) + y = np.zeros(w) + for k in range(1, 9): + y += (amp / k**1.15) * np.sin(2 * math.pi * (k * 0.9 + rng.random()) * x + rng.random() * 6.28) + return base + y + + +def scene(w=512, h=384): + """A dusk landscape: sky, stars, sun, three ridges, a lake, pines, a caption. + Returns the 8-bit RGB image and the sun's glow (0..1 per pixel).""" + yy = np.linspace(0, 1, h)[:, None] * np.ones((1, w)) + xx = np.linspace(0, 1, w)[None, :] * np.ones((h, 1)) + horizon = 0.62 + t = np.clip(yy / horizon, 0, 1) + top, mid, low = np.array([24, 30, 92.0]), np.array([196, 84, 110.0]), np.array([255, 190, 110.0]) + sky = np.where((t < 0.6)[..., None], top + (mid - top) * (t / 0.6)[..., None], mid + (low - mid) * ((t - 0.6) / 0.4)[..., None]) + sx, sy, sr = 0.68, 0.5, 0.045 + d = np.hypot((xx - sx) * w / h, yy - sy) + glow = np.clip(1 - d / 0.35, 0, 1) ** 2 + sky += glow[..., None] * np.array([120, 70, 20.0]) + sky = np.where((d < sr)[..., None], np.array([255, 250, 225.0]), sky) + rng = np.random.default_rng(3) + for _ in range(90): + px, py = rng.integers(0, w), rng.integers(0, int(h * 0.3)) + sky[py, px] = np.minimum(255, sky[py, px] + rng.integers(90, 200)) + img = sky.copy() + haze = np.array([230, 150, 130.0]) + for i, (base, amp, col) in enumerate([(0.50, 0.05, (120, 80, 120)), (0.56, 0.05, (80, 55, 95)), (0.61, 0.035, (40, 32, 70))]): + line = ridge(w, 10 + i, base, amp) + mask = yy > line[None, :] + shade = np.array(col, float) * (0.9 - 0.15 * i) + haze * (0.3 - 0.1 * i) + img = np.where(mask[..., None] & (yy <= 0.7)[..., None], shade, img) + # The lake: the sky and ridges mirrored about the horizon, with ripples. + lake_top = int(h * 0.66) + for row in range(lake_top, h): + k = row - lake_top + src = max(0, lake_top - 1 - int(k * 0.9)) + shift = int(3 * math.sin(k * 0.8)) + line = np.roll(img[src], shift, axis=0) + img[row] = line * 0.7 + np.array([10, 30, 60.0]) * 0.3 + img = np.clip(img, 0, 255).astype(np.uint8) + im = Image.fromarray(img, "RGB") + dr = ImageDraw.Draw(im) + for cx, base_y, size in [(0.1, 0.75, 70), (0.18, 0.72, 50), (0.3, 0.74, 90), (0.9, 0.73, 80), (0.8, 0.71, 55)]: + bx, by = int(cx * w), int(base_y * h) + for tier in range(5): + half = int(size * (0.18 + 0.1 * (4 - tier) * 0.8)) + top_y = by - size + tier * int(size * 0.18) + dr.polygon([(bx, top_y), (bx - half, top_y + int(size * 0.3)), (bx + half, top_y + int(size * 0.3))], fill=(14, 22, 36)) + dr.rectangle((bx - 3, by - 6, bx + 3, by + 8), fill=(10, 14, 22)) + dr.text((16, h - 34), "exav example picture", font=font(20), fill=(250, 235, 210)) + return im, glow + + +def bounce_frames(n=24, w=192, h=144): + frames = [] + for i in range(n): + im = Image.new("RGB", (w, h)) + px = np.zeros((h, w, 3), np.uint8) + gy = np.linspace(0, 1, h)[:, None] + # Every pixel changes a little in every frame (the tint below), so + # that encoders keep full frames: cjxl reads no cropped ones. + pulse = 14 * math.sin(2 * math.pi * i / n) + px[..., 0] = 20 + 40 * gy + px[..., 1] = 40 + 60 * gy + px[..., 2] = 90 + 120 * gy + im = Image.fromarray(px, "RGB") + d = ImageDraw.Draw(im) + d.rectangle((0, h - 24, w, h), fill=(46, 120, 66)) + phase = i / n + x = 20 + phase * (w - 40) + y = h - 24 - 14 - abs(math.sin(math.pi * phase * 2)) * 80 + rx, ry = 14 * (1 + (0.3 if y > h - 42 else 0)), 14 * (1 - (0.3 if y > h - 42 else 0)) + d.ellipse((x - 12, h - 26, x + 12, h - 20), fill=(30, 80, 45)) + d.ellipse((x - rx, y - ry, x + rx, y + ry), fill=(240, 200, 40), outline=(160, 110, 10)) + d.text((6, 6), f"{i + 1}/{n}", font=font(14), fill=(255, 255, 255)) + tint = np.array(im).astype(int) + tint[..., 0] += int(pulse) + tint[..., 2] -= int(pulse) + frames.append(Image.fromarray(np.clip(tint, 0, 255).astype(np.uint8), "RGB")) + return frames + + +def png_path(im, name): + p = os.path.join(WORK, name) + im.save(p) + return p + + +# --------------------------------------------------------------- fax page + + +def fax_page(w=1728, h=2200, number=1): + """A fax cover sheet and a letter, black on white, at fax size.""" + im = Image.new("L", (w, h), 255) + d = ImageDraw.Draw(im) + d.rectangle((60, 60, w - 60, 200), outline=0, width=6) + d.text((100, 85), "FACSIMILE TRANSMISSION", font=font(72), fill=0) + rows = [("To:", "Records office, harbour authority"), ("From:", "exav example generator"), + ("Date:", "1 January 2000 (a fixed date)"), ("Pages:", "3 including this one"), ("Re:", "Mooring register, east quay")] + y = 260 + for k, v in rows: + d.text((100, y), k, font=font(44), fill=0) + d.text((330, y), v, font=font(44), fill=0) + d.line((330, y + 56, w - 100, y + 56), fill=0, width=2) + y += 96 + d.line((60, y + 20, w - 60, y + 20), fill=0, width=8) + body = [ + "Dear colleagues,", "", + "The register of moorings on the east quay is attached. Each berth has a", + "number, a length in metres and the name of the vessel that holds it. Please", + "check the entries against your own list and send back a corrected copy", + "before the end of the month.", "", + "Berths 1 to 12 are for visiting boats and are not let by the season.", + "Berths 13 to 40 are let by the season; the fee is the same for every length.", "", + "This page and the next are sent in the fine mode (204 by 196 dots to the", + "inch). If anything is unreadable, ask for the page to be sent again.", "", + "With thanks,", + ] + y += 70 + for line in body: + d.text((100, y), line, font=font(40), fill=0) + y += 62 + # A table of berths. + y += 40 + rows = 5 + cols = [100, 330, 700, 1100, w - 100] + for r in range(rows + 1): + top = y + r * 70 + d.line((cols[0], top, cols[-1], top), fill=0, width=3 if r else 6) + if r < rows: + vals = ["Berth", "Length (m)", "Vessel", "Let by"] if r == 0 else [str(12 + number * r), f"{6 + (r * 7 + number) % 9}.{(r * 3) % 10}", ["Aurora", "Brise", "Calypso", "Dorade", "Etoile", "Fauvette", "Goeland"][(r + number) % 7], ["season", "week", "year"][r % 3]] + for c, v in enumerate(vals): + d.text((cols[c] + 12, top + 10), v, font=font(40), fill=0) + for c in cols: + d.line((c, y, c, y + rows * 70), fill=0, width=3) + # A signature scribble, a stamp, and the page number. + base = y + rows * 70 + pts = [(120 + i * 6, base + 110 + int(40 * math.sin(i * 0.5) * math.cos(i * 0.13))) for i in range(70)] + d.line(pts, fill=0, width=5) + d.ellipse((1150, base + 40, 1450, base + 240), outline=0, width=8) + d.text((1190, base + 115), "RECEIVED", font=font(36), fill=0) + d.text((100, h - 100), f"Page {number} of 3", font=font(40), fill=0) + return im + + +# ------------------------------------------------------------------ encoders + + +def qoi_encode(arr): + """QOI (qoiformat.org/qoi-specification.pdf) from an HxWx3 or HxWx4 uint8 array.""" + h, w, ch = arr.shape + out_ = bytearray(b"qoif" + struct.pack(">IIBB", w, h, ch, 0)) + index = [(0, 0, 0, 0)] * 64 + prev = (0, 0, 0, 255) + run = 0 + flat = arr.reshape(-1, ch) + for i, px in enumerate(flat): + px = (int(px[0]), int(px[1]), int(px[2]), int(px[3]) if ch == 4 else 255) + if px == prev: + run += 1 + if run == 62 or i == len(flat) - 1: + out_.append(0xC0 | (run - 1)) + run = 0 + continue + if run: + out_.append(0xC0 | (run - 1)) + run = 0 + h_ = (px[0] * 3 + px[1] * 5 + px[2] * 7 + px[3] * 11) % 64 + if index[h_] == px: + out_.append(h_) + else: + index[h_] = px + if px[3] == prev[3]: + dr, dg, db = (px[0] - prev[0] + 256) % 256, (px[1] - prev[1] + 256) % 256, (px[2] - prev[2] + 256) % 256 + sr, sg, sb = dr - 256 if dr > 127 else dr, dg - 256 if dg > 127 else dg, db - 256 if db > 127 else db + if -2 <= sr <= 1 and -2 <= sg <= 1 and -2 <= sb <= 1: + out_.append(0x40 | (sr + 2) << 4 | (sg + 2) << 2 | (sb + 2)) + else: + drg, dbg = sr - sg, sb - sg + if -32 <= sg <= 31 and -8 <= drg <= 7 and -8 <= dbg <= 7: + out_.append(0x80 | (sg + 32)) + out_.append((drg + 8) << 4 | (dbg + 8)) + else: + out_ += bytes([0xFE, px[0], px[1], px[2]]) + else: + out_ += bytes([0xFF, px[0], px[1], px[2], px[3]]) + prev = px + out_ += b"\x00\x00\x00\x00\x00\x00\x00\x01" + return bytes(out_) + + +def farbfeld(rgba8): + h, w, _ = rgba8.shape + wide = rgba8.astype(np.uint16) * 257 + return b"farbfeld" + struct.pack(">II", w, h) + wide.astype(">u2").tobytes() + + +def rgbe(linear): + """Radiance RGBE bytes from an HxWx3 float array.""" + m = linear.max(axis=2) + mant, ex = np.frexp(m) + scale = np.where(m > 1e-32, mant * 256.0 / np.maximum(m, 1e-32), 0) + px = np.zeros(linear.shape[:2] + (4,), np.uint8) + px[..., :3] = np.clip(linear * scale[..., None], 0, 255).astype(np.uint8) + px[..., 3] = np.where(m > 1e-32, ex + 128, 0).astype(np.uint8) + return px + + +def hdr_file(px, rle): + h, w, _ = px.shape + data = bytearray(b"#?RADIANCE\n# written by exav's make-images.py\nFORMAT=32-bit_rle_rgbe\nEXPOSURE=1.0\n\n") + data += f"-Y {h} +X {w}\n".encode() + for row in px: + if not rle: + data += row.tobytes() + continue + data += bytes([2, 2, w >> 8, w & 255]) + for c in range(4): + ch = row[:, c].tolist() + i = 0 + while i < w: + j = i + while j < w and ch[j] == ch[i] and j - i < 127: + j += 1 + if j - i >= 4: + data += bytes([128 + j - i, ch[i]]) + i = j + else: + k = i + while k < w and k - i < 128 and not (k + 3 < w and ch[k] == ch[k + 1] == ch[k + 2] == ch[k + 3]): + k += 1 + k = max(k, i + 1) + data += bytes([k - i]) + bytes(ch[i:k]) + i = k + return bytes(data) + + +def bc_blocks(arr, fmt): + """Greatly simplified BC1 / BC3 encoder: endpoints are the block's + darkest and brightest colours; indices are the nearest of four.""" + h, w, _ = arr.shape + out_ = bytearray() + + def to565(c): + return ((int(c[0]) >> 3) << 11) | ((int(c[1]) >> 2) << 5) | (int(c[2]) >> 3) + + def from565(v): + return np.array([((v >> 11) & 31) * 255 // 31, ((v >> 5) & 63) * 255 // 63, (v & 31) * 255 // 31], float) + + for by in range(0, h, 4): + for bx in range(0, w, 4): + blk = arr[by:by + 4, bx:bx + 4].reshape(-1, arr.shape[2]) + rgb = blk[:, :3].astype(float) + lum = rgb @ np.array([0.3, 0.59, 0.11]) + c0, c1 = rgb[lum.argmax()], rgb[lum.argmin()] + e0, e1 = to565(c0), to565(c1) + if e0 < e1: + e0, e1 = e1, e0 + if fmt == "bc3": + a = blk[:, 3].astype(int) + a0, a1 = int(a.max()), int(a.min()) + if a0 == a1: + a_idx = [0] * 16 + else: + pal = [a0, a1] + [((6 - i) * a0 + i * a1) // 7 for i in range(1, 7)] + a_idx = [int(np.argmin([abs(v - p) for p in pal])) for v in a] + bits = 0 + for i, v in enumerate(a_idx): + bits |= v << (3 * i) + out_ += bytes([a0, a1]) + bits.to_bytes(6, "little") + if e0 == e1: + idx = [0] * 16 + else: + p0, p1 = from565(e0), from565(e1) + pal = [p0, p1, (2 * p0 + p1) / 3, (p0 + 2 * p1) / 3] + idx = [int(np.argmin([np.sum((px - p) ** 2) for p in pal])) for px in rgb] + bits = 0 + for i, v in enumerate(idx): + bits |= v << (2 * i) + out_ += struct.pack("= 2: + enc += bytes([n, row[i]]) + i = j + else: + k = i + 1 + while k < w and k - i < 255 and (k + 1 >= w or row[k] != row[k + 1]): + k += 1 + n = k - i + if n >= 3: + enc += bytes([0, n]) + bytes(row[i:k]) + (b"\0" if n % 2 else b"") + else: + for v in row[i:k]: + enc += bytes([1, v]) + i = k + enc += b"\x00\x00" + rows.append(bytes(enc)) + data = b"".join(rows)[:-2] + b"\x00\x01" + palette = b"".join(bytes([pal[i * 3 + 2], pal[i * 3 + 1], pal[i * 3], 0]) for i in range(64)) + off = 14 + 40 + len(palette) + info = struct.pack("> (x % 8) + mask_rows += bits_row + if bits == 32: + pix = a[::-1][..., [2, 1, 0, 3]].tobytes() + pal = b"" + else: + p = im.convert("RGB").convert("P", palette=Image.ADAPTIVE, colors=256) + pp = p.getpalette()[:768] + pal = b"".join(bytes([pp[i * 3 + 2], pp[i * 3 + 1], pp[i * 3], 0]) for i in range(256)) + pix = b"".join(bytes(np.array(p)[y].tolist()) for y in range(size - 1, -1, -1)) + head = struct.pack("u2") +with open(out("scene-grey16.pgm"), "wb") as f: + f.write(b"P5\n128 96\n65535\n" + g16.tobytes()) +bw = np.array(small.convert("1")) > 0 +rows = [] +for row in bw: + rows.append(np.packbits(~row).tobytes()) +with open(out("scene.pbm"), "wb") as f: + f.write(b"P4\n256 192\n" + b"".join(rows)) +al = np.array(img.resize((128, 96), Image.LANCZOS).convert("RGBA")) +al[..., 3] = np.clip(255 - np.hypot(np.arange(128)[None, :] - 64, np.arange(96)[:, None] - 48) * 3.6, 0, 255) +with open(out("scene-alpha.pam"), "wb") as f: + f.write(b"P7\nWIDTH 128\nHEIGHT 96\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n" + al.tobytes()) + +# QOI, farbfeld, HDR, DDS +with open(out("scene.qoi"), "wb") as f: + f.write(qoi_encode(np.array(img))) +with open(out("scene-alpha.qoi"), "wb") as f: + f.write(qoi_encode(np.array(Image.fromarray(a, "RGBA").resize((192, 144), Image.LANCZOS)))) +with open(out("scene.ff"), "wb") as f: + f.write(farbfeld(np.array(Image.fromarray(a, "RGBA").resize((96, 72), Image.LANCZOS)))) +srgb = np.array(img, float) / 255 +lin = np.where(srgb <= 0.04045, srgb / 12.92, ((srgb + 0.055) / 1.055) ** 2.4) +lin = lin * (1 + 60 * glow[..., None] ** 2) +with open(out("sunset.hdr"), "wb") as f: + f.write(hdr_file(rgbe(lin[::2, ::2]), True)) +with open(out("sunset-flat.hdr"), "wb") as f: + f.write(hdr_file(rgbe(lin[::4, ::4]), False)) +with open(out("scene-bc1.dds"), "wb") as f: + f.write(dds_file(img.resize((256, 192), Image.LANCZOS), "bc1")) +with open(out("scene-bc3.dds"), "wb") as f: + f.write(dds_file(Image.fromarray(a, "RGBA").resize((256, 192), Image.LANCZOS), "bc3")) + +# JPEG 2000 +if tool("opj_compress"): + src = png_path(img, "scene.png") + srca = png_path(Image.fromarray(a, "RGBA"), "scene-alpha.png") + srcg = png_path(img.convert("L"), "scene-grey.png") + run("opj_compress", "-i", src, "-o", out("scene.jp2"), "-r", "16,8,4", "-n", "5", "-p", "RPCL", "-t", "256,256", "-SOP", "-EPH") + run("opj_compress", "-i", src, "-o", out("scene.j2k"), "-r", "12", "-n", "4") + run("opj_compress", "-i", srcg, "-o", out("scene-grey.jp2"), "-r", "10") + run("opj_compress", "-i", srca, "-o", out("scene-alpha.jp2"), "-r", "10") + +# JBIG2 +page = fax_page(1728, 2200, 1).resize((1240, 1579), Image.LANCZOS).point(lambda v: 255 if v > 140 else 0).convert("1") +if tool("jbig2"): + pb = os.path.join(WORK, "page.png") + page.save(pb) + for name, args in [("page-generic.jb2", []), ("page-symbol.jb2", ["-s"])]: + with open(out(name), "wb") as f: + subprocess.run(["jbig2", *args, pb], stdout=f, check=True, stderr=subprocess.DEVNULL) + +# Fax TIFF (CCITT): Pillow writes the uncompressed bilevel page, tiffcp codes it. +if tool("tiffcp"): + pages = [] + for n in (1, 2, 3): + p = os.path.join(WORK, f"fax{n}.tif") + fax_page(1728, 2200, n).convert("1").save(p, dpi=(204, 196)) + pages.append(p) + # Group 3 is written to show what the viewer says about it: its TIFF decoder reads Group 4 only. + for name, mode in [("fax-g4.tif", "g4"), ("fax-g3-1d.tif", "g3:1d")]: + run("tiffcp", "-c", mode, "-r", "2200", pages[0], out(name)) + run("tiffcp", "-c", "g4", "-r", "2200", *pages, out("fax-g4-3pages.tif")) + +# JPEG XL +if tool("cjxl"): + src = png_path(img, "jxl-scene.png") + srca = png_path(Image.fromarray(a, "RGBA"), "jxl-scene-alpha.png") + gif = os.path.join(WORK, "bounce.gif") + frames[0].save(gif, save_all=True, append_images=frames[1:], duration=60, loop=0) + run("cjxl", src, out("scene-lossy.jxl"), "-d", "1.0", "-e", "7") + run("cjxl", src, out("scene-lossless.jxl"), "-d", "0", "-e", "7") + run("cjxl", srca, out("scene-alpha.jxl"), "-d", "0", "-e", "7") + run("cjxl", gif, out("bounce.jxl"), "-d", "1.0", "-e", "6") + +shutil.rmtree(WORK) diff --git a/crates/exav-viewer/demo/examples/generate/make-manifest.mjs b/crates/exav-viewer/demo/examples/generate/make-manifest.mjs new file mode 100644 index 00000000..c628d128 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/make-manifest.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Writes ../examples.json from catalog.json and the files in ../files/. +// +// node generate/make-manifest.mjs +// +// catalog.json lists what each example is (title, features, author, licence, +// where it came from); this script adds what the bytes say (size, SHA-256) and +// fills the facts that every generated file shares. A file that is fetched and +// not committed keeps the size and hash the catalog gives. +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(here, ".."); +const REPO = "https://github.com/sylvinus/exav/blob/main/crates/exav-viewer/demo/examples"; + +const catalog = JSON.parse(fs.readFileSync(path.join(here, "catalog.json"), "utf8")); +const examples = []; +const seen = new Set(); + +for (const block of catalog.blocks) { + for (const f of block.files) { + const e = { file: f.file, group: f.group ?? block.group, family: f.family ?? block.family, title: f.title }; + if (f.features) e.features = f.features; + const kind = f.source ?? block.source; + if (kind === "generated") { + const script = f.gen ?? block.gen; + const e0 = { script: `generate/${script}`, args: f.args ?? block.args ?? [] }; + const tools = f.tools ?? block.tools; + if (tools) e0.tools = tools; + Object.assign(e, { + path: `${f.dir ?? block.dir}/${f.file}`, + committed: true, + author: f.author ?? "exav project", + source: `${REPO}/generate/${script}`, + evidence: `${REPO}/generate/${script}`, + evidence_quote: "Output: CC0-1.0, a public domain dedication by the exav project.", + licence: "CC0-1.0", + attribution: "", + generated: e0, + }); + } else { + // A `ref` names facts that several files share (the author, the page that states the licence, ...). + const ref = f.ref ?? block.ref; + if (ref && !catalog.refs?.[ref]) throw new Error(`${f.file}: unknown ref ${ref}`); + const g = { ...(ref ? catalog.refs[ref] : {}), ...f }; + for (const k of ["author", "source", "evidence", "evidence_quote", "licence", "attribution"]) { + if (g[k] === undefined) throw new Error(`${f.file}: no ${k}`); + e[k] = g[k]; + } + for (const k of ["fetched", "fixture", "generated"]) if (g[k] !== undefined) e[k] = g[k]; + if (g.derived) e.derived = g.derived; + if (g.evidence_row) e.evidence_row = g.evidence_row; + if (f.path) { + e.path = f.path; + e.committed = true; + } else { + e.committed = false; + } + } + if (seen.has(e.file)) throw new Error(`${e.file}: listed twice`); + seen.add(e.file); + if (e.committed) { + const data = fs.readFileSync(path.join(root, "files", e.path)); + e.bytes = data.length; + e.sha256 = crypto.createHash("sha256").update(data).digest("hex"); + } else { + if (!f.bytes || !f.sha256) throw new Error(`${f.file}: a fetched file needs bytes and sha256 in the catalog`); + e.bytes = f.bytes; + e.sha256 = f.sha256; + } + examples.push(e); + } +} + +const out = { version: 1, examples }; +fs.writeFileSync(path.join(root, "examples.json"), `${JSON.stringify(out, null, 2)}\n`); +const committed = examples.filter((e) => e.committed); +const total = (list) => list.reduce((n, e) => n + e.bytes, 0); +console.log(`${examples.length} examples: ${committed.length} committed (${total(committed)} bytes), ${examples.length - committed.length} fetched (${total(examples.filter((e) => !e.committed))} bytes)`); diff --git a/crates/exav-viewer/demo/examples/generate/make-media.py b/crates/exav-viewer/demo/examples/generate/make-media.py new file mode 100644 index 00000000..9699daf6 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/make-media.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Writes the demo's audio and video examples into ../files/media/. + + python3 -I make-media.py [OUT_DIR] + +Output: CC0-1.0, a public domain dedication by the exav project. The tune is +composed and synthesised here (a four-voice canon on a pentatonic scale, sine +waves with a few harmonics and an envelope); the picture is ffmpeg's own +"testsrc2" pattern with a clock. + +Needs: Python 3, numpy, and ffmpeg (with libx264, libvpx-vp9, libopus, +libvorbis, libmp3lame and the native aac and flac encoders). The files that +use a missing encoder are skipped. +""" +import math +import os +import shutil +import struct +import subprocess +import sys +import tempfile +import wave + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(HERE, "..", "files", "media") +os.makedirs(OUT, exist_ok=True) +RATE = 8000 +SCALE = [0, 2, 4, 7, 9] # a major pentatonic scale +MELODY = [0, 2, 4, 2, 3, 4, 6, 4, 2, 0, 1, 2, 4, 3, 2, 1] # degrees; the scale repeats over octaves + + +def freq(degree, base=261.63): + octave, step = divmod(degree, len(SCALE)) + return base * 2 ** ((SCALE[step] + 12 * octave) / 12) + + +def voice(delay_beats, octave, beat=0.3, seconds=8.4): + t = np.arange(int(RATE * seconds)) / RATE + out = np.zeros_like(t) + for i, degree in enumerate(MELODY): + start = (delay_beats + i) * beat + if start >= seconds: + break + f = freq(degree + octave * len(SCALE)) + n = np.clip(t - start, 0, None) + env = (t >= start) * np.exp(-n * 5.0) * np.minimum(n / 0.01, 1.0) + out += env * (np.sin(2 * math.pi * f * t) + 0.35 * np.sin(4 * math.pi * f * t) + 0.12 * np.sin(6 * math.pi * f * t)) + return out + + +def tune(): + mix = voice(0, 0) + voice(4, 1) * 0.8 + voice(8, 0) * 0.7 + voice(12, -1) * 0.9 + mix /= np.abs(mix).max() * 1.1 + fade = np.minimum(1.0, (len(mix) - np.arange(len(mix))) / (RATE * 0.5)) + return (mix * fade * 32000).astype(" 1 else os.path.join(HERE, "..", "files", "models") +os.makedirs(OUT, exist_ok=True) + + +def out(name): + return os.path.join(OUT, name) + + +def normal(a, b, c): + n = np.cross(b - a, c - a) + length = np.linalg.norm(n) + return n / length if length else n + + +# ---------------------------------------------------------------- the gear + + +def gear_facets(teeth=18, r_root=14.0, r_tip=17.0, r_bore=5.0, thick=8.0, hub_r=8.0): + """Facets (a, b, c, colour) of a spur gear: z from 0 to thick.""" + outline = [] + step = 2 * math.pi / teeth + for i in range(teeth): + a0 = i * step + for da, r in [(0.0, r_root), (0.18 * step, r_tip), (0.5 * step, r_tip), (0.68 * step, r_root)]: + outline.append((a0 + da, r)) + n = len(outline) + bore = [(outline[i][0], r_bore) for i in range(n)] + hub = [(outline[i][0], hub_r) for i in range(n)] + + def p(ang_r, z): + return np.array([ang_r[1] * math.cos(ang_r[0]), ang_r[1] * math.sin(ang_r[0]), z]) + + ORANGE, BLUE, GREY = (255, 140, 30), (40, 100, 220), (170, 170, 175) + facets = [] + + def quad(a, b, c, d, colour): + facets.append((a, b, c, colour)) + facets.append((a, c, d, colour)) + + for i in range(n): + j = (i + 1) % n + # Top and bottom: the ring between the bore and the outline, the hub ring in blue. + for z, flip in ((thick, False), (0.0, True)): + ring = [(bore[i], bore[j], hub[j], hub[i], BLUE), (hub[i], hub[j], outline[j], outline[i], ORANGE if z else GREY)] + for a, b, c, d, col in ring: + pts = [p(a, z), p(b, z), p(c, z), p(d, z)] + if flip: + pts = pts[::-1] + quad(*pts, col) + # The tooth walls and the bore wall. + quad(p(outline[i], 0), p(outline[j], 0), p(outline[j], thick), p(outline[i], thick), ORANGE) + quad(p(bore[j], 0), p(bore[i], 0), p(bore[i], thick), p(bore[j], thick), GREY) + return facets + + +def stl_binary(path, facets, header): + out_ = bytearray(header.ljust(80, b" ")[:80]) + out_ += struct.pack("> 3) << 10) | ((colour[1] >> 3) << 5) | (colour[2] >> 3) if colour else 0 + out_ += struct.pack("<12fH", *nrm, *a, *b, *c, attr) + with open(path, "wb") as f: + f.write(out_) + + +def box_facets(lo, hi): + x0, y0, z0 = lo + x1, y1, z1 = hi + v = [np.array(c, float) for c in [(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0), (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)]] + quads = [(0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)] + tris = [] + for a, b, c, d in quads: + tris.append((v[a], v[b], v[c])) + tris.append((v[a], v[c], v[d])) + return tris + + +def stl_ascii(path, tris, name): + lines = [f"solid {name}"] + for a, b, c in tris: + n = normal(a, b, c) + lines.append(f" facet normal {n[0]:.6e} {n[1]:.6e} {n[2]:.6e}") + lines.append(" outer loop") + for v in (a, b, c): + lines.append(f" vertex {v[0]:.6e} {v[1]:.6e} {v[2]:.6e}") + lines.append(" endloop") + lines.append(" endfacet") + lines.append(f"endsolid {name}") + with open(path, "w") as f: + f.write("\n".join(lines) + "\n") + + +stl_binary(out("gear.stl"), gear_facets(), b"exav example gear, colours as VisCAM attributes") +stl_ascii(out("bracket-ascii.stl"), box_facets((0, 0, 0), (40, 25, 4)) + box_facets((0, 0, 4), (4, 25, 30)) + box_facets((4, 0, 4), (6, 25, 12)), "bracket") + +# ------------------------------------------------------------------- the knot + + +def hsv(h): + h6 = (h % 1.0) * 6 + i = int(h6) + f = h6 - i + q, t = 1 - f, f + r, g, b = [(1, t, 0), (q, 1, 0), (0, 1, t), (0, q, 1), (t, 0, 1), (1, 0, q)][i % 6] + return int(r * 255), int(g * 255), int(b * 255) + + +def knot_obj(path, segs=90, ring=10): + def centre(t): + return np.array([(2 + math.cos(3 * t)) * math.cos(2 * t), (2 + math.cos(3 * t)) * math.sin(2 * t), math.sin(3 * t)]) + + verts, colours, normals, faces = [], [], [], [] + for i in range(segs): + t = 2 * math.pi * i / segs + c = centre(t) + tan = centre(t + 1e-3) - c + tan /= np.linalg.norm(tan) + up = np.array([0.0, 0.0, 1.0]) + nrm = np.cross(tan, up) + nrm /= np.linalg.norm(nrm) + bin_ = np.cross(tan, nrm) + for k in range(ring): + a = 2 * math.pi * k / ring + d = math.cos(a) * nrm + math.sin(a) * bin_ + verts.append(c + 0.32 * d) + normals.append(d) + colours.append(hsv(i / segs)) + for i in range(segs): + for k in range(ring): + a = i * ring + k + b = i * ring + (k + 1) % ring + c = ((i + 1) % segs) * ring + (k + 1) % ring + d = ((i + 1) % segs) * ring + k + faces.append((a, b, c, d)) + lines = ["# A trefoil knot with a colour per vertex, and a base: two objects.", "o knot"] + for v, col in zip(verts, colours): + lines.append(f"v {v[0]:.4f} {v[1]:.4f} {v[2]:.4f} {col[0] / 255:.3f} {col[1] / 255:.3f} {col[2] / 255:.3f}") + for n in normals: + lines.append(f"vn {n[0]:.4f} {n[1]:.4f} {n[2]:.4f}") + for f in faces: + lines.append("f " + " ".join(f"{i + 1}//{i + 1}" for i in f)) + base = len(verts) + lines.append("o base") + ring_n = 48 + for k in range(ring_n): + a = 2 * math.pi * k / ring_n + lines.append(f"v {4.2 * math.cos(a):.4f} {4.2 * math.sin(a):.4f} -1.6 0.35 0.35 0.4") + lines.append(f"v {4.2 * math.cos(a):.4f} {4.2 * math.sin(a):.4f} -1.2 0.6 0.6 0.65") + lines.append(f"v 0 0 -1.2 0.6 0.6 0.65") + centre_i = base + 2 * ring_n + 1 + for k in range(ring_n): + j = (k + 1) % ring_n + lo0, hi0, lo1, hi1 = base + 2 * k + 1, base + 2 * k + 2, base + 2 * j + 1, base + 2 * j + 2 + lines.append(f"f {lo0} {lo1} {hi1} {hi0}") + lines.append(f"f {hi0} {hi1} {centre_i}") + # Faces counted from the end, as OBJ allows: the base's bottom disc. + for k in range(ring_n): + j = (k + 1) % ring_n + lines.append(f"f {base + 2 * j + 1} {base + 2 * k + 1} {base + 2 * ring_n + 1}") + with open(path, "w") as f: + f.write("\n".join(lines) + "\n") + + +knot_obj(out("knot.obj")) + +# -------------------------------------------------------------------- terrain + + +def terrain_ply(path, n=48): + xs = np.linspace(-1, 1, n) + X, Y = np.meshgrid(xs, xs) + Z = 0.35 * np.exp(-((X - 0.3) ** 2 + (Y + 0.2) ** 2) * 6) + 0.25 * np.exp(-((X + 0.5) ** 2 + (Y - 0.4) ** 2) * 9) + 0.04 * np.sin(9 * X) * np.cos(7 * Y) - 0.1 + verts = np.stack([X * 50, Y * 50, Z * 50], axis=-1).reshape(-1, 3).astype(" 0 else [0, 2, 1, 0, 3, 2] + idx += [base + o for o in order] + return np.array(pos), np.array(nrm), np.array(idx) + + +def uv_sphere(r, rings=12, segs=20): + pos, nrm, idx = [], [], [] + for i in range(rings + 1): + phi = math.pi * i / rings + for j in range(segs + 1): + th = 2 * math.pi * j / segs + n = np.array([math.sin(phi) * math.cos(th), math.cos(phi), math.sin(phi) * math.sin(th)]) + pos.append(n * r) + nrm.append(n) + for i in range(rings): + for j in range(segs): + a = i * (segs + 1) + j + idx += [a, a + segs + 1, a + 1, a + 1, a + segs + 1, a + segs + 2] + return np.array(pos), np.array(nrm), np.array(idx) + + +def cylinder(r, h, segs=20): + pos, nrm, idx = [], [], [] + for j in range(segs + 1): + th = 2 * math.pi * j / segs + n = np.array([math.cos(th), 0, math.sin(th)]) + pos += [n * r + [0, 0, 0], n * r + [0, h, 0]] + nrm += [n, n] + for j in range(segs): + a = 2 * j + idx += [a, a + 2, a + 1, a + 1, a + 2, a + 3] + c0 = len(pos) + pos.append(np.array([0, h, 0.0])) + nrm.append(np.array([0, 1.0, 0])) + for j in range(segs + 1): + th = 2 * math.pi * j / segs + pos.append(np.array([math.cos(th) * r, h, math.sin(th) * r])) + nrm.append(np.array([0, 1.0, 0])) + for j in range(segs): + idx += [c0, c0 + 2 + j, c0 + 1 + j] + return np.array(pos), np.array(nrm), np.array(idx) + + +def quat(axis, deg): + a = np.array(axis, float) + a /= np.linalg.norm(a) + s = math.sin(math.radians(deg) / 2) + return [float(a[0] * s), float(a[1] * s), float(a[2] * s), math.cos(math.radians(deg) / 2)] + + +def arm_glb(path): + g = Gltf() + steel = g.material((0.7, 0.72, 0.78, 1), 0.9, 0.35, name="steel") + orange = g.material((0.95, 0.45, 0.1, 1), 0.2, 0.5, name="paint") + blue = g.material((0.1, 0.3, 0.8, 1), 0.1, 0.6, name="blue") + glass = g.material((0.4, 0.8, 1.0, 0.45), 0.0, 0.1, name="glass") + glow = g.material((0.1, 0.1, 0.1, 1), 0.0, 0.5, emissive=(1.0, 0.3, 0.1), name="lamp") + base_m = g.mesh(*cylinder(0.6, 0.25), steel, name="base") + link_m = g.mesh(*box_mesh(0.22, 1.0, 0.22), orange, name="link") + joint_m = g.mesh(*uv_sphere(0.17), blue, name="joint") + dome_m = g.mesh(*uv_sphere(0.9, 10, 16), glass, name="dome") + lamp_m = g.mesh(*uv_sphere(0.1, 8, 12), glow, name="lamp") + pos, nrm, idx = box_mesh(2.4, 0.05, 2.4) + colors = np.array([[0.9, 0.9, 0.9] if (int(p[0] * 2.5 + 100) + int(p[2] * 2.5 + 100)) % 2 else [0.3, 0.3, 0.35] for p in pos]) + floor_m = g.mesh(pos, nrm, idx, g.material((1, 1, 1, 1), 0.0, 0.9, name="floor"), colors=colors, name="floor") + # A tree: base > joint1 > link1 > joint2 > link2 > joint3 > link3 > lamp. + lamp = g.node(mesh=lamp_m, translation=[0, 0.55, 0], name="lamp") + link3 = g.node(mesh=link_m, scale=[0.7, 0.6, 0.7], translation=[0, 0.3, 0], name="link3", children=[lamp]) + joint3 = g.node(mesh=joint_m, translation=[0, 1.0, 0], rotation=quat([0, 0, 1], -50), name="joint3", children=[link3]) + link2 = g.node(mesh=link_m, translation=[0, 0.5, 0], name="link2", children=[joint3]) + joint2 = g.node(mesh=joint_m, translation=[0, 1.0, 0], rotation=quat([0, 0, 1], 65), name="joint2", children=[link2]) + link1 = g.node(mesh=link_m, translation=[0, 0.5, 0], name="link1", children=[joint2]) + joint1 = g.node(mesh=joint_m, translation=[0, 0.25, 0], rotation=quat([0, 1, 0], 40), name="joint1", children=[link1]) + base = g.node(mesh=base_m, name="base", children=[joint1]) + dome = g.node(mesh=dome_m, translation=[0, 0.0, 0], name="dome") + floor = g.node(mesh=floor_m, translation=[0, -0.03, 0], name="floor") + root = g.node(name="arm cell", children=[floor, base, dome], rotation=quat([0, 1, 0], -20)) + doc = g.document([root]) + js = json.dumps(doc, separators=(",", ":")).encode() + js += b" " * (-len(js) % 4) + binary = bytes(g.buf) + b"\0" * (-len(g.buf) % 4) + blob = struct.pack("<4sII", b"glTF", 2, 12 + 8 + len(js) + 8 + len(binary)) + struct.pack(" 1 else os.path.join(HERE, "..", "files") +for d in ("office", "converted", "pdf"): + os.makedirs(os.path.join(OUT, d), exist_ok=True) + +NS = ( + 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" ' + 'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" ' + 'xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" ' + 'xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" ' + 'xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" ' + 'xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" ' + 'xmlns:smil="urn:oasis:names:tc:opendocument:xmlns:smil-compatible:1.0" xmlns:anim="urn:oasis:names:tc:opendocument:xmlns:animation:1.0" ' + 'xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0" xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2" ' + 'xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" ' + 'xmlns:ooo="http://openoffice.org/2004/office"' +) + + +def aileron(size): + return ImageFont.load_default(size) + + +def png_b64(im): + buf = io.BytesIO() + im.save(buf, "PNG", optimize=True) + return base64.b64encode(buf.getvalue()).decode() + + +def picture(w=640, h=360, seed=1, label="exav example picture"): + """A made-up picture: a lake at dusk, drawn with numpy and Pillow.""" + y = np.linspace(0, 1, h)[:, None] + x = np.linspace(0, 1, w)[None, :] + px = np.zeros((h, w, 3), float) + px[..., 0] = 30 + 200 * y ** 1.5 + px[..., 1] = 40 + 90 * y + px[..., 2] = 110 + 40 * y - 60 * y ** 2 + sun = np.hypot((x - 0.7) * w / h, y - 0.55) < 0.07 + px[sun] = (255, 245, 215) + im = Image.fromarray(np.clip(px, 0, 255).astype(np.uint8), "RGB") + d = ImageDraw.Draw(im) + rng = np.random.default_rng(seed) + pts = [(0, int(h * 0.64))] + [(i * w // 16, int(h * (0.5 + 0.1 * rng.random()))) for i in range(1, 16)] + [(w, int(h * 0.64)), (w, h), (0, h)] + d.polygon(pts, fill=(34, 40, 66)) + d.rectangle((0, int(h * 0.64), w, h), fill=(28, 56, 92)) + d.text((12, h - 30), label, font=aileron(18), fill=(245, 235, 215)) + return im + + +# ====================================================================== +# The handbook (text document) +# ====================================================================== + +LOREM = [ + "The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell.", + "Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master.", + "Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and its draught, and to say how many nights they plan to stay.", +] + + +def t(text): + return escape(text) + + +def span(style, text): + return f'{t(text)}' + + +def para(style, *content): + return f'{"".join(c if c.startswith("<") else t(c) for c in content)}' + + +def heading(level, text, bookmark=None): + b = f'' if bookmark else "" + return f'{b}{t(text)}' + + +def list_(style, items, level=1): + out = [f''] + for it in items: + if isinstance(it, list): + out.append("" + list_(style, it, level + 1) + "") + else: + out.append(f'{it if it.startswith("<") else t(it)}') + out.append("") + return "".join(out) + + +def table(name, style, widths, rows, header_rows=1): + cols = "".join(f'' for w in widths) + head, body = rows[:header_rows], rows[header_rows:] + out = [f'', cols] + if header_rows: + out.append("" + "".join(row for row in head) + "") + out.append("".join(body)) + out.append("") + return "".join(out) + + +def cell(text, style="Tc", pstyle="Table_20_Contents", span_=1, rowspan=1, value=None): + attrs = f' table:style-name="{style}" office:value-type="string"' + if span_ > 1: + attrs += f' table:number-columns-spanned="{span_}"' + if rowspan > 1: + attrs += f' table:number-rows-spanned="{rowspan}"' + if value is not None: + attrs = attrs.replace('office:value-type="string"', f'office:value-type="float" office:value="{value}"') + return f'{text if text.startswith("<") else t(text)}' + + +def covered(n=1): + return "" * n + + +def row(*cells): + return "" + "".join(cells) + "" + + +def handbook_fodt(): + pic = picture(640, 360) + pic2 = picture(480, 270, 4, "Second picture") + cites = " ".join([f"[{i}]" for i in range(1, 3)]) + headings = [("Introduction", 1, "intro"), ("Text and styles", 1, "text"), ("Lists", 1, "lists"), ("Tables", 1, "tables"), ("Pictures and shapes", 1, "pictures"), ("Links, fields and notes", 1, "links"), ("Wide table (landscape)", 1, "wide")] + toc_entries = "".join( + f'{i}. {t(h)}' + for i, (h, _, b) in enumerate(headings, 1) + ) + body = [] + # Title page. + body.append(para("Title", "Harbour handbook")) + body.append(para("Subtitle", "The east quay: rules, figures and notes")) + body.append(para("Author", "exav project, written for the demo (CC0-1.0)")) + body.append(para("Author", "1 January 2000")) + body.append( + '' + 'Contents' + f'Contents{toc_entries}' + ) + # 1 Introduction. + body.append(heading(1, "Introduction", "intro")) + body.append(para("Text_20_body", LOREM[0])) + body.append(para("Text_20_body", LOREM[1] + " ", f'1The register of 2000 lists sixty berths.')) + body.append(para("Quotations", "“A harbour is a promise that the sea will wait.â€� ")) + body.append(para("Text_20_body", LOREM[2])) + # 2 Text and styles. + body.append(heading(1, "Text and styles", "text")) + body.append(heading(2, "Character formatting")) + body.append( + para( + "Text_20_body", + span("Strong", "Bold"), ", ", span("Emphasis", "italic"), ", ", span("Underline", "underlined"), ", ", span("Strike", "struck through"), ", ", span("Red", "red"), ", ", span("Highlight", "highlighted"), + ", ", span("SmallCaps", "small capitals"), ", ", span("Big", "large"), " and ", span("Mono", "monospaced"), " text; H", span("Sub", "2"), "O and 10", span("Super", "3"), "; a ", span("Emphasis", "mixed"), " style.", + ) + ) + body.append(heading(2, "Paragraph formatting")) + body.append(para("Justified", LOREM[0] + " " + LOREM[1])) + body.append(para("Centered", "A centred line")) + body.append(para("Right", "A line aligned to the right")) + body.append(para("Indented", "An indented paragraph with a left border: " + LOREM[2])) + body.append(para("Code", "moor --boat Aurora --length 6.5")) + body.append(para("Code", "berth 14 free until 06:00")) + body.append(heading(2, "A section in two columns")) + body.append( + '' + + para("Text_20_body", LOREM[0] + " " + LOREM[1] + " " + LOREM[2] + " " + LOREM[0]) + + "" + ) + # 3 Lists. + body.append(heading(1, "Lists", "lists")) + body.append(list_("L1", ["Water at every berth", ["cold, from the quay", "hot, in the shower block"], "Electricity", ["16 A at berths 1 to 40", ["with a meter", "without a meter"], "32 A at the east pontoon"], "Wi-Fi on the quay"])) + body.append(para("Text_20_body", "Numbered, with other numbering styles at each level:")) + body.append(list_("L2", ["Call the office on channel 9", "Give the length and the draught", ["length over all, in metres", "draught, in metres"], "Take the berth you are given", ["tie up bow first", "tie up stern first"], "Pay at the office"])) + # 4 Tables. + body.append(heading(1, "Tables", "tables")) + body.append(para("Caption", "Table ", '1', ": berths by length, with merged cells and a repeated header row")) + rows = [row(cell("Length", "Th", "Table_20_Heading"), cell("Berths", "Th", "Table_20_Heading"), cell("Let by", "Th", "Table_20_Heading"), cell("Fee (EUR)", "Th", "Table_20_Heading"))] + for a, b, c, d in [("up to 6 m", 12, "week", 18.0), ("6 to 8 m", 18, "season", 24.5), ("8 to 10 m", 14, "season", 31.0), ("10 to 12 m", 9, "year", 42.0), ("over 12 m", 7, "year", 58.5)]: + rows.append(row(cell(a), cell(str(b), "TcR", "Table_20_Right", value=b), cell(c), cell(f"{d:.2f}", "TcR", "Table_20_Right", value=d))) + rows.append(row(cell("Total", "TcTotal", span_=1), cell("60", "TcTotalR", "Table_20_Right", value=60), cell("", "TcTotal", span_=2), covered())) + body.append(table("Table1", "Tbl1", ["TcolA", "TcolB", "TcolC", "TcolD"], rows)) + body.append(para("Text_20_body", "")) + rows = [ + row(cell("Merged across two columns", "Th", "Table_20_Heading", span_=2), covered(), cell("Merged down two rows", "Tc", rowspan=2), ), + row(cell("a"), cell("b"), covered()), + row(cell("c"), cell("d"), cell("e")), + ] + body.append(table("Table2", "Tbl1", ["TcolA", "TcolB", "TcolC"], rows, header_rows=0)) + # 5 Pictures and shapes. + body.append(heading(1, "Pictures and shapes", "pictures")) + body.append( + '' + f'{png_b64(pic)}A lake at duskA made-up picture of a lake and a hill at dusk.' + + t(LOREM[0] + " " + LOREM[1] + " " + LOREM[2] + " ") + + t(LOREM[0] + " ") + + "" + ) + body.append(para("Caption", "Figure ", '1', ": a picture that text flows around")) + body.append( + '' + f'{png_b64(pic2)}The second picture' + ) + body.append( + '' + 'A shape with text in it' + "A line of text beside a shape." + ) + # 6 Links, fields and notes. + body.append(heading(1, "Links, fields and notes", "links")) + body.append( + para( + "Text_20_body", + "An external link: ", 'exav.org', + ". An internal link: ", 'back to the introduction', + ". A reference to a bookmark: ", 'Tables', ".", + ) + ) + body.append( + para( + "Text_20_body", "Fields: this document is titled “", 'Harbour handbook', "â€�, and this line is on page ", + '1', ". A fixed date: ", '01/01/2000', ".", + ) + ) + body.append( + para( + "Text_20_body", "A comment is attached to this word", 'exav2000-01-01T00:00:00A comment: the viewer shows or ignores it.', + ". A change that was tracked: ", 'this text was inserted', ", ", '', "and some text was deleted before this one.", + ) + ) + body.append(para("Text_20_body", f"Citations {cites}. ", '2A second footnote, with emphasis.')) + # 7 A wide table in landscape. + body.append('Wide table (landscape)') + heads = ["Month"] + [f"Berth {i}" for i in range(1, 11)] + ["Total"] + rows = [row(*[cell(h, "Th", "Table_20_Heading") for h in heads])] + for m, name in enumerate(["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]): + vals = [(m * 7 + i * 5) % 9 + 1 for i in range(10)] + rows.append(row(cell(name), *[cell(str(v), "TcR", "Table_20_Right", value=v) for v in vals], cell(str(sum(vals)), "TcTotalR", "Table_20_Right", value=sum(vals)))) + body.append(table("Table3", "TblWide", ["TcolW"] * 12, rows)) + body.append(para("Text_20_body", "Nights let at each berth, by month.")) + + return f""" + +exav projectHarbour handbookA document made for the exav demo (CC0-1.0)demoen-US2000-01-01T00:00:00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +// + + + + + + + + + + + + + + + + + + +Harbour handbookexav demoPage 1 of 1 +Harbour handbook (landscape section)Page 1 + + + +exav2000-01-01T00:00:00 +exav2000-01-01T00:00:00some deleted words + +{"".join(body)} + + +""" + + +# ====================================================================== +# The workbook (spreadsheet) +# ====================================================================== + + +def chart(kind, title, ranges, cats, series, width="15cm", height="8.5cm", legend=True): + """An embedded chart object. series: [(name cell, values range)]; ranges: the whole data range.""" + ser = "".join( + f'' + for i, (n, v) in enumerate(series) + ) + styles = "".join( + f'' + for i, c in enumerate(["#146c94", "#f6a01a", "#2c7a4b", "#c0392b"]) + ) + pie_styles = "" + if kind == "circle": + pie_styles = "".join(f'' for i, c in enumerate(["#146c94", "#f6a01a", "#2c7a4b", "#c0392b", "#8e44ad"])) + ser = ser.replace('', "".join(f'' for i in range(5))) + axes = ( + f'' + if kind != "circle" + else "" + ) + return f"""{styles}{pie_styles} +{t(title)}{'' if legend else ""} +{axes}{ser}""" + + +def workbook_fods(): + months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] + cats = ["Berths let", "Visitors", "Shop", "Fuel", "Repairs"] + values = [[11200, 11800, 12100, 15400, 21800, 24900], [3100, 3300, 4800, 8200, 15600, 19200], [1200, 1100, 1500, 2600, 4100, 5200], [900, 800, 1100, 1900, 3300, 4400], [-2100, -900, -3300, -1500, -800, -1200]] + + def fcell(v, style=None): + return f'{v}' + + def scell(s, style=None, extra=""): + return f'{t(s)}' + + def fm(formula, style=None, value=None, vtype="float"): + key = "office:string-value" if vtype == "string" else "office:value" + v = f' office:value-type="{vtype}"' + (f' {key}="{value}"' if value is not None else "") + return f'{value if value is not None else ""}' + + # Sheet 1: Budget (rows 1 title, 2 note, 3 header, 4-8 data, 9 total, then a lookup block). + rows = [] + rows.append('' + scell("Harbour budget, first half", "title", ' table:number-columns-spanned="9" table:number-rows-spanned="1"') + "" * 8 + "") + rows.append("" + scell("Amounts in euros; the totals are formulas.", "note") + "" * 8 + "") + rows.append("" + scell("Category", "head") + "".join(scell(m, "head") for m in months) + scell("Total", "head") + scell("Share", "head") + "") + # Every formula carries the value it gives, as a spreadsheet saves it. + grand = sum(sum(v) for v in values) + nets = [sum(v[i] for v in values) for i in range(6)] + for i, (name, vals) in enumerate(zip(cats, values)): + r = 4 + i + total = sum(vals) + rows.append( + "" + scell(name, "label") + "".join(fcell(v, "eur") for v in vals) + + fm(f"of:=SUM([.B{r}:.G{r}])", "eurb", total) + fm(f"of:=[.H{r}]/[.H$9]", "pct", total / grand) + "" + ) + rows.append( + "" + scell("Net", "labelb") + "".join(fm(f"of:=SUM([.{c}4:.{c}8])", "eurtot", nets[i]) for i, c in enumerate("BCDEFG")) + fm("of:=SUM([.H4:.H8])", "eurtot", grand) + fm("of:=SUM([.I4:.I8])", "pcttot", 1) + "" + ) + rows.append("" + "" * 9 + "") + rows.append("" + scell("Averages and checks", "labelb") + "" * 8 + "") + rows.append("" + scell("Average a month", "label") + fm("of:=AVERAGE([.B9:.G9])", "eur", grand / 6) + scell("Best month", "label") + fm("of:=INDEX([.B3:.G3];MATCH(MAX([.B9:.G9]);[.B9:.G9];0))", None, months[nets.index(max(nets))], "string") + scell("Months in credit", "label") + fm("of:=COUNTIF([.B9:.G9];\">0\")", None, sum(1 for n in nets if n > 0)) + "" * 3 + "") + rows.append("" + scell("Largest item", "label") + fm("of:=MAX([.B4:.G8])", "eur", max(max(v) for v in values)) + scell("Rounded share of fuel", "label") + fm("of:=ROUND([.I7];3)", "pct", round(sum(values[3]) / grand, 3)) + scell("Status", "label") + fm('of:=IF([.H9]>0;"surplus";"deficit")', None, "surplus" if grand > 0 else "deficit", "string") + "" * 3 + "") + rows.append("" + scell("Lookup: category", "label") + scell("Shop", "input") + scell("its total", "label") + fm("of:=VLOOKUP([.B14];[.A4:.H8];8;0)", "eur", sum(values[2])) + "" * 5 + "") + rows.append("" + scell("Link", "label") + 'exav.org/viewer' + scell("Note on a cell", "label") + 'exavThis cell has a comment.hover me' + "" * 5 + "") + budget_cols = '' + budget = '' + budget_cols + "".join(rows) + "" + + # Sheet 2: Data, 120 rows, with an autofilter. + rng = np.random.default_rng(3) + regions, products = ["West", "East", "North", "South"], ["Mooring", "Fuel", "Water", "Power", "Shop"] + drows = ["" + "".join(scell(h, "head") for h in ["Date", "Region", "Product", "Units", "Unit price", "Amount"]) + ""] + units_sum, amount_sum = 0, 0.0 + for i in range(120): + day = 1 + i * 150 // 120 + month, dd = 1 + (day - 1) // 28, 1 + (day - 1) % 28 + units = int(rng.integers(1, 40)) + price = round(float(rng.uniform(2, 60)), 2) + units_sum += units + amount_sum += round(units * price, 2) + r = i + 2 + drows.append( + "" + f'{dd:02d}/{month:02d}/2000' + + scell(regions[i % 4]) + scell(products[(i * 3) % 5]) + fcell(units) + fcell(price, "eur2") + fm(f"of:=[.D{r}]*[.E{r}]", "eur2", round(units * price, 2)) + "" + ) + drows.append("" + scell("Total", "labelb") + "" * 2 + fm("of:=SUM([.D2:.D121])", "eurtot", units_sum) + "" + fm("of:=SUBTOTAL(9;[.F2:.F121])", "eurtot", round(amount_sum, 2)) + "") + data = ( + '' + + "".join(drows) + "" + ) + + # Sheet 3: Charts. + c1 = chart("bar", "Spending by category", "Budget.$A$3:.$G$7", "Budget.$A$4:.$A$7", [("Budget.$B$3", "Budget.$B$4:.$B$7"), ("Budget.$G$3", "Budget.$G$4:.$G$7")]) + c2 = chart("line", "Net income by month", "Budget.$A$3:.$G$3", "Budget.$B$3:.$G$3", [("Budget.$A$9", "Budget.$B$9:.$G$9")]) + c3 = chart("circle", "Share of the total", "Budget.$A$4:.$A$8", "Budget.$A$4:.$A$8", [("Budget.$I$3", "Budget.$I$4:.$I$8")]) + charts = ( + '' + f'{c1}' + f'{c2}' + f'{c3}' + '' + ) + + settings = """ +view1 + +02 +33 + +02 +11 +""" + + return f""" + +exav projectHarbour workbookA workbook made for the exav demo (CC0-1.0)2000-01-01T00:00:00 +{settings} + + + +€ +€ +% +// + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Choose one of the five categories. +{budget.replace('Shop', 'Shop')} +{data} +{charts} + + + + +""" + + +# ====================================================================== +# The deck (presentation) +# ====================================================================== + + +def deck_fodp(): + pic = picture(800, 450, 2, "Slide picture") + c = chart("bar", "Berths let by month", "local-table.$A$1:.$C$7", "local-table.$A$2:.$A$7", [("local-table.$B$1", "local-table.$B$2:.$B$7"), ("local-table.$C$1", "local-table.$C$2:.$C$7")]) + months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] + local = 'This yearLast year' + for i, m in enumerate(months): + local += f'{m}{20 + i * 6}{18 + i * 5}' + local += "" + c = c.replace("", local + "") + + def title(text, y="0.8cm"): + return f'{t(text)}' + + def bullets(items, y="3.8cm", w="24cm", h="10cm", x="2cm"): + def li(it, lv=1): + if isinstance(it, list): + return "" + "".join(li(i, lv + 1) for i in it) + "" + return f'{t(it)}' + + return f'' + "".join(li(i) for i in items) + "" + + def notes(text): + return f'{t(text)}' + + slides = [] + slides.append( + f'' + f'Harbour review' + f'The east quay, first half of the year' + f'exav' + f'{notes("Welcome. These notes are speaker notes: the viewer does not show them.")}' + ) + slides.append( + f'{title("Agenda")}' + + bullets(["Where we are", ["berths let", ["by length", "by season"], "visitors"], "What the figures say", "What we do next", ["order the new chain", "paint the numbers"]]) + + notes("Four items.") + "" + ) + slides.append( + f'{title("Berths let, month by month")}' + f'{c}' + 'Up 40% on last year' + '' + + notes("The chart is a native chart, with its data inside the slide.") + "" + ) + rows = "" + for r in [("Length", "Berths", "Fee"), ("up to 6 m", "12", "18.00"), ("6 to 8 m", "18", "24.50"), ("8 to 10 m", "14", "31.00"), ("over 10 m", "16", "48.00")]: + head = r[0] == "Length" + rows += "" + "".join(f'{t(v)}' for v in r) + "" + slides.append( + f'{title("Fees")}' + '' + f"{rows}" + + bullets(["Season fee includes water", "Power is metered", "Visitors pay by the night"], y="4cm", w="8cm", h="8cm", x="19cm") + "" + ) + slides.append( + f'{title("A picture and shapes")}' + f'{png_b64(pic)}A lake at dusk' + 'Start' + '' + 'Moor' + '' + 'Pay?' + 'Star' + + notes("Shapes with gradients, arrows and text.") + "" + ) + return f""" + +exav projectHarbour reviewA deck made for the exav demo (CC0-1.0)2000-01-01T00:00:00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 + + + +{"".join(slides)} + + + +""" + + +# ====================================================================== +# The diagram (drawing) +# ====================================================================== + + +def diagram_fodg(): + def node(i, x, y, w, h, text, style="st-box", shape="rect"): + tag = "draw:rect" if shape == "rect" else "draw:ellipse" + return f'<{tag} draw:id="n{i}" draw:style-name="{style}" draw:layer="Boxes" svg:width="{w}cm" svg:height="{h}cm" svg:x="{x}cm" svg:y="{y}cm" draw:corner-radius="0.2cm">{t(text)}' if shape == "rect" else f'<{tag} draw:id="n{i}" draw:style-name="{style}" draw:layer="Boxes" svg:width="{w}cm" svg:height="{h}cm" svg:x="{x}cm" svg:y="{y}cm">{t(text)}' + + def conn(a, b, sa=2, sb=0, kind="standard", style="st-line", label=None): + return f'' + + p1 = [ + node(1, 9, 1, 6, 1.6, "Boat calls the office", "st-start", "ell"), + node(2, 9, 4, 6, 1.6, "Is a berth free?", "st-decision"), + node(3, 2, 7.5, 6, 1.6, "Give berth number"), + node(4, 16, 7.5, 6, 1.6, "Offer the waiting list"), + node(5, 2, 11, 6, 1.6, "Boat moors"), + node(6, 9, 14.5, 6, 1.6, "Pay at the office", "st-end", "ell"), + node(7, 16, 11, 6, 1.6, "Boat waits at anchor"), + ] + links = [conn(1, 2), conn(2, 3, 3, 0, "standard"), conn(2, 4, 3, 0), conn(3, 5), conn(4, 7), conn(5, 6, 2, 1), conn(7, 2, 1, 3, "curve")] + labels = 'yes' + 'no' + legend = ( + '' + 'start or end' + 'step' + 'decision' + ) + curve = '' + page1 = f'{"".join(p1)}{"".join(links)}{labels}{legend}{curve}Flowchart for the exav demo (CC0-1.0). Connectors follow their shapes.' + # Page 2: a block diagram with groups and a hidden layer. + blocks = "" + for i, (x, label) in enumerate([(1.5, "Harbour office"), (9.5, "Berth register"), (17.5, "Billing")]): + blocks += f'{label}{i + 1}' + blocks += '' + stack = "".join(f'Layer {i + 1}' for i in range(4)) + hidden = 'On a hidden layer' + page2 = f'{blocks}{stack}{hidden}' + return f""" + +exav projectFlowchartA drawing made for the exav demo (CC0-1.0)2000-01-01T00:00:00 + + + + + + + + + + + + + + + + + + + + + + + +{page1}{page2} + +""" + + +# ====================================================================== +# Conversion +# ====================================================================== + +TARGETS = { + "handbook": [("fodt", "converted", None), ("odt", "converted", "odt:writer8"), ("docx", "office", 'docx:MS Word 2007 XML'), ("doc", "converted", "doc:MS Word 97"), ("rtf", "converted", "rtf:Rich Text Format")], + "workbook": [("fods", "converted", None), ("ods", "converted", "ods:calc8"), ("xlsx", "office", "xlsx:Calc MS Excel 2007 XML"), ("xls", "converted", "xls:MS Excel 97")], + "deck": [("fodp", "converted", None), ("odp", "converted", "odp:impress8"), ("pptx", "office", "pptx:Impress MS PowerPoint 2007 XML"), ("ppt", "converted", "ppt:MS PowerPoint 97")], + "diagram": [("fodg", "converted", None), ("odg", "converted", "odg:draw8")], +} + + +def convert(src, filt, outdir, profile): + ext, name = filt.split(":", 1) + subprocess.run( + ["soffice", f"-env:UserInstallation=file://{profile}", "--headless", "--norestore", "--convert-to", f"{ext}:{name}", "--outdir", outdir, src], + check=True, capture_output=True, timeout=300, + ) + + +def main(): + docs = {"handbook": ("fodt", handbook_fodt()), "workbook": ("fods", workbook_fods()), "deck": ("fodp", deck_fodp()), "diagram": ("fodg", diagram_fodg())} + if not shutil.which("soffice"): + print("soffice is missing: only the flat files are written", file=sys.stderr) + with tempfile.TemporaryDirectory() as tmp: + profile = os.path.join(tmp, "profile") + for base, (ext, xml) in docs.items(): + src_dir = os.path.join(tmp, base) + os.makedirs(src_dir) + src = os.path.join(src_dir, f"{base}.{ext}") + with open(src, "w", encoding="utf-8") as f: + f.write(xml) + for target, folder, filt in TARGETS[base]: + dest = os.path.join(OUT, folder, f"{base}.{target}") + if filt is None: + shutil.copy(src, dest) + elif shutil.which("soffice"): + out_dir = os.path.join(tmp, f"out-{base}-{target}") + os.makedirs(out_dir) + convert(src, filt, out_dir, profile) + shutil.copy(os.path.join(out_dir, f"{base}.{target}"), dest) + if base == "handbook" and shutil.which("soffice"): + out_dir = os.path.join(tmp, "out-pdf") + os.makedirs(out_dir) + opts = '{"UseTaggedPDF":{"type":"boolean","value":"true"},"ExportBookmarks":{"type":"boolean","value":"true"},"ExportNotes":{"type":"boolean","value":"false"},"SelectPdfVersion":{"type":"long","value":"2"}}' + subprocess.run(["soffice", f"-env:UserInstallation=file://{profile}", "--headless", "--norestore", "--convert-to", f"pdf:writer_pdf_Export:{opts}", "--outdir", out_dir, src], check=True, capture_output=True, timeout=300) + shutil.copy(os.path.join(out_dir, "handbook.pdf"), os.path.join(OUT, "pdf", "handbook-tagged.pdf")) + + +main() diff --git a/crates/exav-viewer/demo/examples/generate/make-pdf.py b/crates/exav-viewer/demo/examples/generate/make-pdf.py new file mode 100644 index 00000000..7cf90498 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/make-pdf.py @@ -0,0 +1,643 @@ +#!/usr/bin/env python3 +"""Writes the demo's PDF examples into ../files/pdf/. + + python3 -I make-pdf.py [OUT_DIR] + +Output: CC0-1.0, a public domain dedication by the exav project. Every file +is made here from scratch. + +Needs: Python 3, reportlab 4 (BSD licence), Pillow, numpy; the programs +opj_compress (libopenjp2-tools) and jbig2 (jbig2enc) and tiffcp (libtiff-tools) +for the scan; the Liberation fonts (SIL OFL 1.1, which lets documents embed +them) for the embedded-font page. The pictures' text is set in Pillow's +bundled Aileron font (CC0). A part whose program is missing is skipped. + +- field-report.pdf: six pages of a report written for the demo: a table of + contents whose entries are links, an outline of two levels, roman and + arabic page labels, internal and external links, tables, bar, line and pie + charts drawn as vectors, a raster picture, text in the 14 standard fonts + and in embedded Liberation fonts (Latin, Greek, Cyrillic), footnote + references, running heads, document information. +- application-form.pdf: an AcroForm with text fields (one multiline, one + with a maximum length), check boxes, radio buttons, a drop-down list, a + list box and a push button, some filled in. +- annotations.pdf: link annotations (URI, page, named destination) and + markup annotations (note, highlight, underline, strike-out, squiggly, free + text, square, circle, line, polygon, ink, stamp) without appearance + streams, an outline and a named destination: written by the small PDF + writer below. +- scan-mixed.pdf: a four-page scanned letter, one page for each image filter + a scanner's PDF uses: DCTDecode (JPEG), CCITTFaxDecode (Group 4), + JBIG2Decode and JPXDecode (JPEG 2000), each with an invisible text layer + (an OCR result). +- vector-art.pdf: gradients (axial and radial shadings), transparency, + clipping, dashes, caps and joins, Bezier curves, rotated and scaled text. +""" +import io +import math +import os +import shutil +import struct +import subprocess +import sys +import tempfile +import zlib + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont +from reportlab import rl_config + +rl_config.invariant = 1 + +from reportlab.graphics.charts.barcharts import VerticalBarChart +from reportlab.graphics.charts.legends import Legend +from reportlab.graphics.charts.lineplots import LinePlot +from reportlab.graphics.charts.piecharts import Pie +from reportlab.graphics.shapes import Drawing, String +from reportlab.lib import colors +from reportlab.lib.enums import TA_JUSTIFY +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import cm, mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas +from reportlab.platypus import BaseDocTemplate, Frame, Image as RLImage, KeepTogether, PageBreak, PageTemplate, Paragraph, Spacer, Table, TableStyle +from reportlab.platypus.tableofcontents import TableOfContents + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(HERE, "..", "files", "pdf") +os.makedirs(OUT, exist_ok=True) +FONTS = "/usr/share/fonts/truetype/liberation" +EMBED = os.path.exists(os.path.join(FONTS, "LiberationSans-Regular.ttf")) +if EMBED: + for name, f in [("LibSans", "LiberationSans-Regular"), ("LibSerif", "LiberationSerif-Regular"), ("LibSerif-Italic", "LiberationSerif-Italic"), ("LibMono", "LiberationMono-Regular")]: + pdfmetrics.registerFont(TTFont(name, os.path.join(FONTS, f + ".ttf"))) + # Names the font by itself so that and stay in the family. + pdfmetrics.registerFontFamily("LibSerif", normal="LibSerif", italic="LibSerif-Italic", bold="LibSerif", boldItalic="LibSerif-Italic") + + +def aileron(size): + return ImageFont.load_default(size) + + +# ------------------------------------------------------------ field report + + +def diagram_image(): + """A small raster picture: a gradient with circles, for the report.""" + w, h = 480, 260 + y = np.linspace(0, 1, h)[:, None] + x = np.linspace(0, 1, w)[None, :] + px = np.zeros((h, w, 3), np.uint8) + px[..., 0] = 40 + 160 * x + px[..., 1] = 80 + 100 * y + px[..., 2] = 200 - 120 * x + im = Image.fromarray(px, "RGB") + d = ImageDraw.Draw(im) + for i in range(7): + r = 14 + i * 7 + d.ellipse((60 + i * 60 - r, 130 - r, 60 + i * 60 + r, 130 + r), outline=(255, 255, 255), width=3) + d.text((16, 14), "A raster picture in the report", font=aileron(22), fill=(255, 255, 255)) + buf = io.BytesIO() + im.save(buf, "JPEG", quality=88) + buf.seek(0) + return buf + + +class Report(BaseDocTemplate): + def __init__(self, path): + super().__init__(path, pagesize=A4, leftMargin=2.2 * cm, rightMargin=2.2 * cm, topMargin=2.5 * cm, bottomMargin=2.2 * cm, + title="Field report: the east quay", author="exav project", subject="A report written for the exav demo (CC0-1.0)", + keywords="demo, report, links, outline, charts", creator="exav make-pdf.py", pageCompression=1) + frame = Frame(self.leftMargin, self.bottomMargin, self.width, self.height, id="body") + self.addPageTemplates([PageTemplate(id="all", frames=[frame], onPage=self.decorate)]) + + def decorate(self, c, doc): + c.saveState() + if doc.page == 1: + # The cover and the contents are i and ii; the body starts again at 1. + c.addPageLabel(0, "ROMAN_LOWER", 1) + c.addPageLabel(2, "ARABIC", 1) + c.setFont("Helvetica", 8) + c.setFillColor(colors.HexColor("#555555")) + if doc.page > 1: + c.drawString(self.leftMargin, A4[1] - 1.5 * cm, "Field report: the east quay") + c.drawRightString(A4[0] - self.rightMargin, A4[1] - 1.5 * cm, "exav demo, CC0-1.0") + c.setStrokeColor(colors.HexColor("#999999")) + c.line(self.leftMargin, A4[1] - 1.7 * cm, A4[0] - self.rightMargin, A4[1] - 1.7 * cm) + c.drawCentredString(A4[0] / 2, 1.2 * cm, f"page {doc.page}") + c.linkURL("https://exav.org/viewer/", (A4[0] - self.rightMargin - 3 * cm, A4[1] - 1.6 * cm, A4[0] - self.rightMargin, A4[1] - 1.3 * cm), relative=0) + c.restoreState() + + def afterFlowable(self, fl): + if isinstance(fl, Paragraph) and fl.style.name in ("H1", "H2"): + level = 0 if fl.style.name == "H1" else 1 + text = fl.getPlainText() + key = "h" + "".join(c if c.isalnum() else "-" for c in text) + self.canv.bookmarkPage(key) + self.canv.addOutlineEntry(text, key, level, closed=False) + self.notify("TOCEntry", (level, text, self.page, key)) + + +def field_report(path): + ss = getSampleStyleSheet() + body_font, serif_font = ("LibSans", "LibSerif") if EMBED else ("Helvetica", "Times-Roman") + body = ParagraphStyle("Body", parent=ss["BodyText"], fontName=serif_font, fontSize=10.5, leading=14.5, alignment=TA_JUSTIFY, spaceAfter=6) + h1 = ParagraphStyle("H1", parent=ss["Heading1"], fontName="Helvetica-Bold", fontSize=18, spaceBefore=14, spaceAfter=8, textColor=colors.HexColor("#0b4f6c")) + h2 = ParagraphStyle("H2", parent=ss["Heading2"], fontName="Helvetica-Bold", fontSize=13, spaceBefore=10, spaceAfter=5, textColor=colors.HexColor("#146c94")) + small = ParagraphStyle("Small", parent=body, fontSize=8.5, leading=11, alignment=0) + mono = ParagraphStyle("Mono", parent=body, fontName="Courier", fontSize=9, leading=11.5, alignment=0, backColor=colors.HexColor("#f2f2f2"), borderPadding=4, spaceBefore=4, spaceAfter=8) + + lorem = [ + "The east quay was built in three stages. The first stage, a timber jetty of twenty berths, dates from the start of the register; the second doubled it in stone, and the third, the long breakwater, shelters both from the south-west swell.", + "Every berth has a number, a length in metres and a holder. The register is kept on paper and in a table; the two are compared each spring, and the differences are explained in the notes of the harbour master.", + "Visitors are asked to call the harbour office on channel 9 before they come in, to give the length of their boat and the draught, and to say how many nights they plan to stay. The office will give a berth number and, when the tide is low, the best line of approach.", + "A note on the figures. Lengths are over all, in metres, to one decimal. Fees are in euros and include the tax. Where a table gives a total, it is the sum of the rows above it, rounded once, at the end.", + ] + + def paras(n, start=0): + return [Paragraph(lorem[(start + i) % len(lorem)], body) for i in range(n)] + + story = [] + # The cover page. + story += [Spacer(1, 5 * cm), Paragraph("Field report", ParagraphStyle("Title", parent=h1, fontSize=34, leading=40, textColor=colors.HexColor("#0b4f6c"))), + Paragraph("The east quay: berths, tides and visitors", ParagraphStyle("Sub", parent=body, fontName="Helvetica", fontSize=16, leading=20, alignment=0)), Spacer(1, 1.2 * cm), + Paragraph("A document made for the exav demo. It has a table of contents whose lines are links, an outline in the side panel, page labels, charts drawn as vectors, tables, a picture, and text in embedded fonts. It is in the public domain (CC0-1.0).", body), + Spacer(1, 1 * cm), Paragraph('Project page: https://exav.org/viewer/', body), PageBreak()] + toc = TableOfContents() + toc.levelStyles = [ParagraphStyle("t0", fontName="Helvetica-Bold", fontSize=11, leading=16, leftIndent=0, firstLineIndent=0), ParagraphStyle("t1", fontName="Helvetica", fontSize=10, leading=14, leftIndent=18, firstLineIndent=0)] + story += [Paragraph("Contents", ParagraphStyle("TocTitle", parent=h1)), toc, PageBreak()] + + story += [Paragraph("1. The quay", h1), Paragraph("1.1 History", h2)] + paras(3) + story += [Paragraph("1.2 Today", h2)] + paras(2, 1) + story += [Paragraph("Notes: see the picture and the first table in section 2.1", small)] + story += [Paragraph("2. Figures", h1), Paragraph("2.1 Berths by length", h2)] + data = [["Length (m)", "Berths", "Let by", "Fee (EUR)", "Income (EUR)"]] + rows = [("up to 6", 12, "week", 18.0), ("6 to 8", 18, "season", 24.5), ("8 to 10", 14, "season", 31.0), ("10 to 12", 9, "year", 42.0), ("over 12", 7, "year", 58.5)] + for a, b, c, d in rows: + data.append([a, b, c, f"{d:.2f}", f"{b * d:.2f}"]) + data.append(["Total", sum(r[1] for r in rows), "", "", f"{sum(r[1] * r[3] for r in rows):.2f}"]) + t = Table(data, colWidths=[3.2 * cm, 2.4 * cm, 2.6 * cm, 3 * cm, 3.4 * cm], repeatRows=1) + t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0b4f6c")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"), ("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, colors.HexColor("#e8f1f5")]), + ("ALIGN", (1, 0), (-1, -1), "RIGHT"), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#8aa6b4")), ("LINEABOVE", (0, -1), (-1, -1), 1.2, colors.black)])) + story += [Paragraph('Table 1. Berths by length, with the season\'s fee.', small), t, Spacer(1, 0.4 * cm)] + story += [Paragraph("2.2 Charts", h2)] + bar = Drawing(420, 200) + chart = VerticalBarChart() + chart.x, chart.y, chart.width, chart.height = 40, 30, 360, 150 + chart.data = [[12, 18, 14, 9, 7], [10, 17, 15, 8, 5]] + chart.categoryAxis.categoryNames = [r[0] for r in rows] + chart.valueAxis.valueMin, chart.valueAxis.valueMax, chart.valueAxis.valueStep = 0, 20, 5 + chart.bars[0].fillColor = colors.HexColor("#146c94") + chart.bars[1].fillColor = colors.HexColor("#f6a01a") + chart.barLabelFormat = "%d" + chart.barLabels.nudge = 7 + bar.add(chart) + bar.add(String(40, 188, "Berths let, this year and last", fontName="Helvetica-Bold", fontSize=10)) + story += [bar] + line = Drawing(420, 190) + lp = LinePlot() + lp.x, lp.y, lp.width, lp.height = 40, 25, 360, 140 + months = list(range(1, 13)) + lp.data = [[(m, 40 + 35 * math.sin((m - 3) / 12 * 2 * math.pi) + 5 * m / 12) for m in months], [(m, 28 + 22 * math.sin((m - 3) / 12 * 2 * math.pi)) for m in months]] + lp.lines[0].strokeColor, lp.lines[1].strokeColor = colors.HexColor("#c0392b"), colors.HexColor("#2c7a4b") + lp.lines[0].strokeWidth = lp.lines[1].strokeWidth = 2 + lp.lines[0].symbol = lp.lines[1].symbol = None + lp.xValueAxis.valueMin, lp.xValueAxis.valueMax, lp.xValueAxis.valueStep = 1, 12, 1 + line.add(lp) + line.add(String(40, 175, "Boats a day, by month (visitors and holders)", fontName="Helvetica-Bold", fontSize=10)) + story += [line] + pie = Drawing(420, 180) + p = Pie() + p.x, p.y, p.width, p.height = 40, 15, 140, 140 + p.data = [41, 27, 18, 9, 5] + p.labels = ["sail", "motor", "fishing", "rowing", "other"] + for i, c in enumerate(["#146c94", "#f6a01a", "#2c7a4b", "#c0392b", "#8e44ad"]): + p.slices[i].fillColor = colors.HexColor(c) + p.slices[0].popout = 8 + pie.add(p) + leg = Legend() + leg.x, leg.y, leg.alignment = 230, 120, "right" + leg.colorNamePairs = [(colors.HexColor(c), f"{n} ({v}%)") for c, n, v in zip(["#146c94", "#f6a01a", "#2c7a4b", "#c0392b", "#8e44ad"], p.labels, p.data)] + pie.add(leg) + story += [pie] + story += [Paragraph("2.3 A picture", h2), Paragraph('Figure 1. A raster picture, embedded as a JPEG.', small), RLImage(diagram_image(), width=12 * cm, height=6.5 * cm)] + story += [Paragraph("3. Fonts and text", h1), Paragraph("3.1 The standard fonts", h2)] + for fam, label in [("Helvetica", "Helvetica"), ("Times-Roman", "Times Roman"), ("Courier", "Courier"), ("Helvetica-Oblique", "Helvetica Oblique"), ("Times-BoldItalic", "Times Bold Italic"), ("Symbol", "Symbol: abgd"), ("ZapfDingbats", "abcdef")]: + story.append(Paragraph(f"{label}: The quick brown fox jumps over the lazy dog 0123456789", ParagraphStyle("f", parent=body, fontName=fam, fontSize=10.5, alignment=0, spaceAfter=2))) + if EMBED: + story += [Paragraph("3.2 Embedded fonts (Liberation, SIL OFL 1.1)", h2), + Paragraph("Latin: café, naïve, Zürich, Å�ódź, Ã¥ngström, €12,50. Greek: Αθήνα, Ï€ ≈ 3,14159. Cyrillic: МоÑ�ква, живой Ñ�зык.", ParagraphStyle("u", parent=body, fontName="LibSerif", alignment=0)), + Paragraph("Sans: Portez ce vieux whisky au juge blond qui fume. 0123456789", ParagraphStyle("u2", parent=body, fontName="LibSans", alignment=0)), + Paragraph("def moor(boat): return berth_for(boat.length)", ParagraphStyle("u3", parent=body, fontName="LibMono", fontSize=9.5, alignment=0))] + story += [Paragraph("3.3 Preformatted text and a list", h2), Paragraph("moor --boat Aurora --length 6.5\nberth 14 free until 06:00\n (tide table on page 6)", mono)] + bullets = [Paragraph(f"• {t}", ParagraphStyle("b", parent=body, leftIndent=14, firstLineIndent=-10, spaceAfter=2, alignment=0)) for t in ["Call the office on channel 9.", "Give the length, the draught and the number of nights.", "Take the berth you are given."]] + story += bullets + story += [Paragraph("4. Links", h1), Paragraph("4.1 Where they go", h2), + Paragraph('An external link: exav.org. A link to another page of this file: back to section 1. A link on a picture, and one around a long phrase that wraps at the end of a line to the next, so that its box is two rectangles: the source of the demo is on GitHub, and this phrase is long enough to wrap.', body)] + paras(2, 2) + story += [Paragraph("Footnote 1: the table's totals are exact; the charts are rounded.", small)] + doc = Report(path) + doc.multiBuild(story) + + +# ------------------------------------------------------------------- forms + + +def application_form(path): + c = canvas.Canvas(path, pagesize=A4, pageCompression=1) + c.setTitle("Berth application form") + c.setAuthor("exav project") + form = c.acroForm + w, h = A4 + c.setFont("Helvetica-Bold", 20) + c.drawString(2 * cm, h - 2.5 * cm, "Berth application") + c.setFont("Helvetica", 9) + c.drawString(2 * cm, h - 3.1 * cm, "A form for the exav demo (CC0-1.0). Some fields are filled in.") + y = h - 4.5 * cm + + def label(text, yy): + c.setFont("Helvetica", 10) + c.setFillColor(colors.black) + c.drawString(2 * cm, yy + 3, text) + + label("Full name", y) + form.textfield(name="name", tooltip="Full name", value="Anne Le Gall", x=6 * cm, y=y - 3, width=9 * cm, height=0.7 * cm, borderStyle="inset", fillColor=colors.HexColor("#fffbe6"), fontSize=11) + y -= 1.2 * cm + label("Boat's name", y) + form.textfield(name="boat", tooltip="Boat's name", value="Hirondelle", x=6 * cm, y=y - 3, width=9 * cm, height=0.7 * cm, borderStyle="inset", fontSize=11) + y -= 1.2 * cm + label("Length (m), 5 characters", y) + form.textfield(name="length", tooltip="Length", value="9.4", maxlen=5, x=6.5 * cm, y=y - 3, width=2.2 * cm, height=0.7 * cm, borderStyle="inset", fontSize=11) + y -= 1.2 * cm + label("Type of boat", y) + form.choice(name="kind", tooltip="Type of boat", value="sloop", options=["sloop", "ketch", "trawler", "motor yacht", "catamaran"], x=6 * cm, y=y - 3, width=5 * cm, height=0.7 * cm, fieldFlags="combo", borderStyle="inset", fontSize=11) + y -= 1.2 * cm + label("Services", y) + for i, (n, t) in enumerate([("water", "Water"), ("power", "Shore power"), ("wifi", "Wi-Fi")]): + form.checkbox(name=n, tooltip=t, checked=(i != 2), x=6 * cm + i * 4 * cm, y=y - 2, size=14, buttonStyle="check", borderColor=colors.black, fillColor=colors.white, textColor=colors.black) + c.drawString(6 * cm + i * 4 * cm + 20, y + 1, t) + y -= 1.2 * cm + label("Stay", y) + for i, (v, t) in enumerate([("night", "One night"), ("week", "A week"), ("season", "The season")]): + form.radio(name="stay", tooltip=t, value=v, selected=(v == "week"), x=6 * cm + i * 4 * cm, y=y - 2, size=14, buttonStyle="circle", borderColor=colors.black, fillColor=colors.white, textColor=colors.black) + c.drawString(6 * cm + i * 4 * cm + 20, y + 1, t) + y -= 1.2 * cm + label("Arrival month", y) + form.listbox(name="month", tooltip="Arrival month", value="June", options=["April", "May", "June", "July", "August", "September"], x=6 * cm, y=y - 2.4 * cm, width=4 * cm, height=2.4 * cm + 0.5 * cm, borderStyle="inset", fontSize=10) + y -= 3.6 * cm + label("Remarks", y) + form.textfield(name="remarks", tooltip="Remarks", value="Arriving after sunset.\nDraught 1.4 m.", x=6 * cm, y=y - 2 * cm, width=10 * cm, height=2.4 * cm, borderStyle="inset", fieldFlags="multiline", fontSize=10) + y -= 3.4 * cm + label("Password (hidden)", y) + form.textfield(name="secret", tooltip="A password field shows dots", value="berth", x=6 * cm, y=y - 3, width=5 * cm, height=0.7 * cm, borderStyle="inset", fieldFlags="password", fontSize=11) + y -= 1.6 * cm + form.textfield(name="readonly", tooltip="Read only", value="Application no. 2000-0001 (read only)", x=2 * cm, y=y - 3, width=9 * cm, height=0.7 * cm, borderStyle="solid", fieldFlags="readOnly", fontSize=10, fillColor=colors.HexColor("#eeeeee")) + c.setFont("Helvetica-Oblique", 8) + c.drawString(2 * cm, 2 * cm, "Fill the fields in the viewer; nothing is sent anywhere.") + c.showPage() + c.save() + + +# --------------------------------------------------------- a tiny PDF writer + + +class Pdf: + def __init__(self): + self.objs = [] + + def reserve(self): + self.objs.append(None) + return len(self.objs) + + def set(self, n, body): + self.objs[n - 1] = body if isinstance(body, bytes) else body.encode("latin-1") + + def add(self, body): + n = self.reserve() + self.set(n, body) + return n + + def stream(self, entries, data, compress=False): + if compress: + data = zlib.compress(data, 9) + entries += " /Filter /FlateDecode" + return self.add(f"<< {entries} /Length {len(data)} >>\nstream\n".encode("latin-1") + data + b"\nendstream") + + def write(self, path, root, info=None): + out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") + offsets = [] + for i, body in enumerate(self.objs, 1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n" + xref = len(out) + out += f"xref\n0 {len(self.objs) + 1}\n0000000000 65535 f \n".encode() + for o in offsets: + out += f"{o:010d} 00000 n \n".encode() + out += f"trailer\n<< /Size {len(self.objs) + 1} /Root {root} 0 R {f'/Info {info} 0 R' if info else ''} /ID [<6578617620657861> <6578617620657861>] >>\nstartxref\n{xref}\n%%EOF\n".encode() + with open(path, "wb") as f: + f.write(out) + + +def pdf_str(s): + return "(" + s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + ")" + + +def annotations(path): + p = Pdf() + catalog, pages = p.reserve(), p.reserve() + page1, page2 = p.reserve(), p.reserve() + font = p.add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + bold = p.add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + attach = p.stream("/Type /EmbeddedFile /Subtype /text#2Fplain /Params << /Size 38 >>", b"An attached file, inside the annotation.\n", False) + spec = p.add(f"<< /Type /Filespec /F (note.txt) /UF (note.txt) /EF << /F {attach} 0 R >> /Desc (A file attached to the page) >>") + + lines = [] + annots = [] + + def text(x, y, s, f="F1", size=11): + lines.append(f"BT /{f} {size} Tf {x} {y} Td {pdf_str(s)} Tj ET") + + def annot(body): + annots.append(p.add(body)) + + text(50, 790, "Annotations", "F2", 22) + text(50, 770, "Every mark below is an annotation of the page, drawn from its own entry.", "F1", 10) + rows = [ + ("Link to a web address", "<< /Type /Annot /Subtype /Link /Rect [200 722 330 740] /Border [0 0 1] /C [0 0 1] /A << /S /URI /URI (https://exav.org/viewer/) >> >>"), + ("Link to page 2", f"<< /Type /Annot /Subtype /Link /Rect [200 692 330 710] /Border [0 0 1] /C [0 0.5 0] /Dest [{page2} 0 R /XYZ 50 760 0] >>"), + ("Link to a named place", "<< /Type /Annot /Subtype /Link /Rect [200 662 330 680] /Border [0 0 1] /C [0.6 0 0.6] /Dest (target) >>"), + ] + y = 730 + for label, body in rows: + text(50, y, label) + text(205, y, "click here") + annot(body) + y -= 30 + y = 630 + marks = [ + ("Highlight", "Highlight", "/C [1 1 0]"), + ("Underline", "Underline", "/C [0 0.6 0]"), + ("Strike-out", "StrikeOut", "/C [1 0 0]"), + ("Squiggly", "Squiggly", "/C [0.6 0 0.8]"), + ] + for label, sub, extra in marks: + text(50, y, f"{label}: marked text in a line") + x0, x1 = 50, 50 + 7.0 * len(f"{label}: marked text in a line") * 0.82 + annot(f"<< /Type /Annot /Subtype /{sub} /Rect [{x0} {y - 3} {x1} {y + 11}] /QuadPoints [{x0} {y + 11} {x1} {y + 11} {x0} {y - 3} {x1} {y - 3}] {extra} /T (exav) /Contents ({label} annotation) /F 4 >>") + y -= 24 + text(50, 520, "Text note (sticky note), free text, shapes:") + annot("<< /Type /Annot /Subtype /Text /Rect [60 470 80 490] /Name /Comment /Contents (A note. Its text is in the annotation.) /T (exav) /C [1 0.8 0] /Open false /F 28 >>") + annot("<< /Type /Annot /Subtype /FreeText /Rect [100 450 300 495] /Contents (Free text in a box) /DA (/Helv 12 Tf 0 0 1 rg) /DS (font: Helvetica 12pt) /C [1 1 0.8] /IC [1 1 0.8] /BS << /W 1 >> /F 4 >>") + annot("<< /Type /Annot /Subtype /Square /Rect [320 450 400 495] /C [1 0 0] /IC [1 0.9 0.9] /BS << /W 2 >> /F 4 >>") + annot("<< /Type /Annot /Subtype /Circle /Rect [420 450 500 495] /C [0 0 1] /IC [0.9 0.9 1] /BS << /W 2 /S /D /D [4 2] >> /F 4 >>") + annot("<< /Type /Annot /Subtype /Line /Rect [50 390 250 430] /L [60 400 240 420] /LE [/OpenArrow /ClosedArrow] /C [0 0.5 0] /BS << /W 2 >> /F 4 >>") + annot("<< /Type /Annot /Subtype /Polygon /Rect [280 380 380 430] /Vertices [290 390 370 390 380 420 300 425] /C [0.5 0 0.5] /IC [0.95 0.85 0.95] /BS << /W 1.5 >> /F 4 >>") + annot("<< /Type /Annot /Subtype /PolyLine /Rect [400 380 500 430] /Vertices [410 390 440 425 470 392 495 420] /C [0 0.4 0.4] /BS << /W 2 >> /F 4 >>") + annot("<< /Type /Annot /Subtype /Ink /Rect [50 310 250 370] /InkList [[60 320 90 360 120 320 150 360 180 320 210 360 240 330]] /C [0.8 0.3 0] /BS << /W 2.5 >> /F 4 >>") + annot("<< /Type /Annot /Subtype /Stamp /Rect [280 310 400 370] /Name /Approved /Contents (A stamp) /C [0 0.6 0] /F 4 >>") + annot(f"<< /Type /Annot /Subtype /FileAttachment /Rect [430 330 450 350] /FS {spec} 0 R /Name /Paperclip /Contents (An attached file) /C [0.3 0.3 0.3] /F 28 >>") + text(50, 280, "The page also holds a link under a rotated line of text, below.", "F1", 10) + lines.append("q 0.866 0.5 -0.5 0.866 80 150 cm BT /F1 12 Tf 0 0 Td (A rotated line with a link over it) Tj ET Q") + annot("<< /Type /Annot /Subtype /Link /Rect [80 150 330 230] /Border [0 0 0] /QuadPoints [80 150 80 150 80 150 80 150] /A << /S /URI /URI (https://exav.org/) >> >>") + content1 = p.stream("", "\n".join(lines).encode("latin-1"), True) + + lines2 = [] + lines2.append("BT /F2 22 Tf 50 790 Td (Page 2: the target) Tj ET") + lines2.append("BT /F1 11 Tf 50 760 Td (The named destination \"target\" points here, and so does the second line of the outline.) Tj ET") + lines2.append("0.9 0.95 1 rg 50 600 300 120 re f 0 0 0.6 RG 50 600 300 120 re S") + lines2.append("BT /F1 11 Tf 60 700 Td (A framed box.) Tj ET") + content2 = p.stream("", "\n".join(lines2).encode("latin-1"), True) + + res = f"<< /Font << /F1 {font} 0 R /F2 {bold} 0 R /Helv {font} 0 R >> >>" + p.set(page1, f"<< /Type /Page /Parent {pages} 0 R /MediaBox [0 0 595 842] /Resources {res} /Contents {content1} 0 R /Annots [{' '.join(f'{a} 0 R' for a in annots)}] >>") + p.set(page2, f"<< /Type /Page /Parent {pages} 0 R /MediaBox [0 0 595 842] /Resources {res} /Contents {content2} 0 R >>") + p.set(pages, f"<< /Type /Pages /Kids [{page1} 0 R {page2} 0 R] /Count 2 >>") + outlines, o1, o2, o3 = p.reserve(), p.reserve(), p.reserve(), p.reserve() + p.set(outlines, f"<< /Type /Outlines /First {o1} 0 R /Last {o2} 0 R /Count 3 >>") + p.set(o1, f"<< /Title (Annotations) /Parent {outlines} 0 R /Next {o2} 0 R /Dest [{page1} 0 R /Fit] >>") + p.set(o2, f"<< /Title (The target) /Parent {outlines} 0 R /Prev {o1} 0 R /First {o3} 0 R /Last {o3} 0 R /Count 1 /Dest [{page2} 0 R /XYZ 50 760 0] >>") + p.set(o3, f"<< /Title (A box on that page) /Parent {o2} 0 R /A << /S /GoTo /D [{page2} 0 R /FitR 50 600 350 720] >> >>") + info = p.add("<< /Title (Annotations) /Author (exav project) /Subject (CC0-1.0) /Creator (exav make-pdf.py) >>") + p.set(catalog, f"<< /Type /Catalog /Pages {pages} 0 R /Outlines {outlines} 0 R /Names << /Dests << /Names [(target) [{page2} 0 R /XYZ 50 760 0]] >> >> /PageMode /UseOutlines /ViewerPreferences << /DisplayDocTitle true >> /Lang (en) >>") + p.write(path, catalog, info) + + +# ------------------------------------------------------------------- scan + +LETTER = [ + ["Harbour office, East quay", "", "Report of the harbour master", "for the month of March", "", "Berths let this month: 41 of 60.", "Visitors: 63 boats, of which 18 stayed more than two nights.", "", + "The breakwater light was out for three nights; it is repaired.", "The pontoon at berth 24 needs a new chain; quote attached.", "", "Weather: four days of gale from the south-west, no damage."], + ["Tide notes", "", "High water at the quay was at 06:12 and 18:34 on the first.", "The spring tides of the 14th and 15th reached 7.4 metres.", "Boats drawing more than 1.8 metres should wait for the", "top of the tide before they come in.", "", "The fairway buoy has been moved 50 metres to the east."], + ["Visitors' book (extract)", "", "Aurora, sloop 9.4 m, two nights.", "Brise, ketch 12.1 m, one night.", "Calypso, trawler 14.0 m, a week.", "Dorade, sloop 8.2 m, one night.", "", "Thanks to the office for the loan of the bicycle."], + ["Notes for the next month", "", "Order a new chain for berth 24.", "Paint the numbers on the east pontoon.", "Check the shore power at berths 30 to 40.", "", "Signed, the harbour master"], +] + + +def letter_page(n, w=1240, h=1754): + """A scanned-looking letter: paper tone, text a little off the straight, noise, a shadow.""" + rng = np.random.default_rng(n) + page = Image.new("L", (w, h), 255) + d = ImageDraw.Draw(page) + words = [] + y = 160 + for i, line in enumerate(LETTER[n]): + size = 54 if i == 0 else 38 + d.text((130, y), line, font=aileron(size), fill=25) + if line: + words.append((130, y, line, size)) + y += 88 if i == 0 else 62 + d.line((130, 140, w - 130, 140), fill=40, width=3) + d.rectangle((w - 420, h - 420, w - 140, h - 170), outline=60, width=4) + d.text((w - 400, h - 330), "STAMP", font=aileron(48), fill=60) + page = page.rotate(0.5 + 0.25 * n, resample=Image.BICUBIC, fillcolor=255, expand=False) + return page, words + + +def scan_variants(n): + page, words = letter_page(n) + arr = np.array(page).astype(float) + yy, xx = np.mgrid[0 : arr.shape[0], 0 : arr.shape[1]] + paper = 232 - 18 * (xx / arr.shape[1]) ** 3 - 14 * (yy / arr.shape[0]) ** 2 + grey = arr / 255 * paper + np.random.default_rng(100 + n).normal(0, 3.0, arr.shape) + scan_grey = Image.fromarray(np.clip(grey, 0, 255).astype(np.uint8), "L").filter(ImageFilter.GaussianBlur(0.6)) + bilevel = page.point(lambda v: 255 if v > 150 else 0).convert("1") + return scan_grey, bilevel, words + + +def scan_pdf(path): + if not (shutil.which("opj_compress") and shutil.which("jbig2") and shutil.which("tiffcp")): + print("scan-mixed.pdf skipped: opj_compress, jbig2 or tiffcp is missing", file=sys.stderr) + return + p = Pdf() + catalog, pages = p.reserve(), p.reserve() + font = p.add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + kids = [] + W, H = 1240, 1754 + with tempfile.TemporaryDirectory() as tmp: + for n in range(4): + grey, bilevel, words = scan_variants(n) + if n == 0: + buf = io.BytesIO() + grey.save(buf, "JPEG", quality=70, dpi=(150, 150)) + img = p.stream(f"/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /DCTDecode", buf.getvalue()) + elif n == 1: + src = os.path.join(tmp, "p.tif") + inv = bilevel.convert("L").point(lambda v: 255 - v).convert("1") + inv.save(src) + dst = os.path.join(tmp, "g4.tif") + subprocess.run(["tiffcp", "-c", "g4", "-r", str(H), src, dst], check=True, capture_output=True) + raw = open(dst, "rb").read() + e = "<" if raw[:2] == b"II" else ">" + ifd = struct.unpack(e + "I", raw[4:8])[0] + tags = {} + for i in range(struct.unpack(e + "H", raw[ifd : ifd + 2])[0]): + tag, kind, count, value = struct.unpack(e + "HHII", raw[ifd + 2 + 12 * i : ifd + 14 + 12 * i]) + if kind == 3 and count == 1: + value = value & 0xFFFF if e == "<" else value >> 16 + tags[tag] = value + strip = raw[tags[273] : tags[273] + tags[279]] + img = p.stream(f"/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns {W} /Rows {H} >>", strip) + elif n == 2: + src = os.path.join(tmp, "p.png") + bilevel.save(src) + data = subprocess.run(["jbig2", "-p", src], check=True, capture_output=True).stdout + img = p.stream(f"/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode", data) + else: + src = os.path.join(tmp, "p.png") + grey.save(src) + dst = os.path.join(tmp, "p.jp2") + subprocess.run(["opj_compress", "-i", src, "-o", dst, "-r", "30", "-n", "5"], check=True, capture_output=True) + img = p.stream(f"/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode", open(dst, "rb").read()) + # The page is A4 at 150 dpi: 595 by 842 points. The invisible text sits over each line. + sx = 595.0 / W + ops = [f"q {595} 0 0 {842} 0 0 cm /Im0 Do Q", "BT 3 Tr"] + for x, y, line, size in words: + ops.append(f"/F1 {size * sx * 0.95:.1f} Tf 1 0 0 1 {x * sx:.1f} {842 - (y + size) * sx:.1f} Tm {pdf_str(line)} Tj") + ops.append("ET") + content = p.stream("", "\n".join(ops).encode("latin-1"), True) + kids.append(p.add(f"<< /Type /Page /Parent {pages} 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 {font} 0 R >> /XObject << /Im0 {img} 0 R >> >> /Contents {content} 0 R >>")) + p.set(pages, f"<< /Type /Pages /Kids [{' '.join(f'{k} 0 R' for k in kids)}] /Count {len(kids)} >>") + p.set(catalog, f"<< /Type /Catalog /Pages {pages} 0 R /Lang (en) >>") + info = p.add("<< /Title (Scanned letters) /Author (exav project) /Subject (CC0-1.0) /Creator (exav make-pdf.py) >>") + p.write(path, catalog, info) + + +# ------------------------------------------------------------ vector art + + +def vector_art(path): + c = canvas.Canvas(path, pagesize=A4, pageCompression=1) + c.setTitle("Vector art") + c.setAuthor("exav project") + w, h = A4 + c.setFont("Helvetica-Bold", 18) + c.drawString(40, h - 50, "Vector art") + c.setFont("Helvetica", 9) + c.drawString(40, h - 64, "Gradients, transparency, clipping, dashes, joins, curves and transformed text (CC0-1.0).") + # Axial and radial shadings. + # A shading paints everywhere inside the clip, so each is clipped to its box. + c.saveState() + box = c.beginPath() + box.roundRect(40, h - 200, 200, 110, 14) + c.clipPath(box, stroke=0, fill=0) + c.linearGradient(40, h - 200, 240, h - 90, (colors.HexColor("#0b4f6c"), colors.HexColor("#f6a01a"), colors.HexColor("#c0392b")), (0, 0.5, 1)) + c.restoreState() + c.saveState() + disc = c.beginPath() + disc.circle(400, h - 145, 70) + c.clipPath(disc, stroke=0, fill=0) + c.radialGradient(400, h - 145, 70, (colors.white, colors.HexColor("#146c94"), colors.black), (0, 0.6, 1)) + c.restoreState() + # Transparency: three overlapping discs. + for i, (cx, col) in enumerate([(80, "#c0392b"), (130, "#2c7a4b"), (105, "#146c94")]): + c.saveState() + c.setFillAlpha(0.55) + c.setFillColor(colors.HexColor(col)) + c.circle(cx, h - 290 + (i == 2) * 40, 45, stroke=0, fill=1) + c.restoreState() + # Clipping: a star clips a striped rectangle. + c.saveState() + p = c.beginPath() + cx, cy, r1, r2 = 330, h - 280, 60, 25 + for i in range(10): + a = math.pi / 5 * i - math.pi / 2 + r = r1 if i % 2 == 0 else r2 + (p.moveTo if i == 0 else p.lineTo)(cx + r * math.cos(a), cy + r * math.sin(a)) + p.close() + c.clipPath(p, stroke=0, fill=0) + for i in range(14): + c.setFillColor(colors.HexColor("#f6a01a") if i % 2 else colors.HexColor("#0b4f6c")) + c.rect(cx - 70 + i * 10, cy - 70, 10, 140, stroke=0, fill=1) + c.restoreState() + # Dashes, caps and joins. + y = h - 380 + for i, (dash, cap, join) in enumerate([((), 0, 0), ((6, 3), 1, 1), ((12, 4, 2, 4), 2, 2), ((1, 5), 1, 0)]): + c.saveState() + c.setLineWidth(10 if i != 3 else 3) + c.setDash(list(dash), 0) + c.setLineCap(cap) + c.setLineJoin(join) + c.setStrokeColor(colors.HexColor("#146c94")) + pth = c.beginPath() + pth.moveTo(50 + i * 130, y) + pth.lineTo(90 + i * 130, y - 50) + pth.lineTo(130 + i * 130, y) + c.drawPath(pth, stroke=1, fill=0) + c.restoreState() + # Bezier curves. + c.saveState() + c.setStrokeColor(colors.HexColor("#c0392b")) + c.setLineWidth(2) + pth = c.beginPath() + pth.moveTo(40, h - 500) + pth.curveTo(120, h - 400, 200, h - 600, 280, h - 500) + pth.curveTo(340, h - 440, 400, h - 560, 480, h - 480) + c.drawPath(pth, stroke=1, fill=0) + c.setFillColor(colors.HexColor("#8e44ad")) + c.setFillAlpha(0.4) + pth = c.beginPath() + pth.moveTo(60, h - 600) + pth.curveTo(60, h - 540, 160, h - 540, 160, h - 600) + pth.curveTo(160, h - 660, 60, h - 660, 60, h - 600) + c.drawPath(pth, stroke=1, fill=1) + c.restoreState() + # Even-odd fill: a ring. + c.saveState() + c.setFillColor(colors.HexColor("#2c7a4b")) + pth = c.beginPath() + pth.circle(260, h - 600, 50) + pth.circle(260, h - 600, 28) + c.drawPath(pth, stroke=0, fill=1, fillMode=0) + c.restoreState() + # Text: rotated, scaled, with a stroke and a fill (render modes). + c.saveState() + c.translate(420, h - 640) + c.rotate(25) + c.setFont("Times-BoldItalic", 34) + t = c.beginText(0, 0) + t.setTextRenderMode(2) + t.setFillColor(colors.HexColor("#f6a01a")) + t.setStrokeColor(colors.HexColor("#0b4f6c")) + t.textOut("exav") + c.drawText(t) + c.restoreState() + c.saveState() + c.setFont("Helvetica", 14) + for i, s in enumerate([1.0, 0.8, 0.6]): + c.saveState() + c.translate(40, h - 720 - i * 22) + c.scale(s * 1.5, s) + c.drawString(0, 0, "Scaled text: the horizontal scale and the size change.") + c.restoreState() + c.restoreState() + c.showPage() + c.save() + + +field_report(os.path.join(OUT, "field-report.pdf")) +application_form(os.path.join(OUT, "application-form.pdf")) +annotations(os.path.join(OUT, "annotations.pdf")) +scan_pdf(os.path.join(OUT, "scan-mixed.pdf")) +vector_art(os.path.join(OUT, "vector-art.pdf")) diff --git a/crates/exav-viewer/demo/examples/generate/make-text.py b/crates/exav-viewer/demo/examples/generate/make-text.py new file mode 100644 index 00000000..05be95d1 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/make-text.py @@ -0,0 +1,800 @@ +#!/usr/bin/env python3 +"""Writes the demo's text examples into ../files/text/. + + python3 -I make-text.py [OUT_DIR] + +Output: CC0-1.0, a public domain dedication by the exav project. Everything +here is written for the demo: the prose, the data (made up, from a seeded +random generator) and the programs. + +Needs: Python 3. +""" +import json +import math +import os +import random +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(HERE, "..", "files", "text") +os.makedirs(OUT, exist_ok=True) + + +def write(name, text): + with open(os.path.join(OUT, name), "w", encoding="utf-8", newline="\n") as f: + f.write(text) + + +FILES = {} + +FILES["field-guide.md"] = r"""# Field guide to Markdown + +A short document that uses most of what Markdown can say. It is written for the +exav demo and is in the public domain (CC0-1.0). + +## Emphasis and spans + +Text can be **bold**, *italic*, ***both***, ~~struck through~~ or `code`. A line +that ends with two spaces +breaks without starting a paragraph. A [link with a title](https://exav.org/ "exav.org"), +an autolink , and a reference link: [the demo][demo]. + +[demo]: https://exav.org/viewer/demo/ + +Escapes: \*not italic\*, 2 \< 3, a literal backtick \`, and entities: © & é. +Unicode goes through unchanged: café, naïve, Ωmega, Жук, 日本語, مرحبا, ☃. + +## Headings + +### Third level +#### Fourth level +##### Fifth level +###### Sixth level + +Setext heading +============== + +Another one +----------- + +## Lists + +- A bullet +- Another, with a nested list: + - second level + - third level + - back to the second +- And a last one + +1. Numbered +2. Items + 1. Nested numbers + 2. Continue +3. Done + +7. A list that starts at seven +8. and goes on + +- [x] A finished task +- [ ] An open task +- [ ] Another open task, with `code` in it + +Loose list: + +- First paragraph. + + A second paragraph inside the same item. + +- Next item. + +## Quotes + +> A quotation, which can wrap over several lines and holds +> *other* **formatting**. +> +> > A quotation inside it. +> +> - and a list + +## Tables + +| Left | Centre | Right | +| :--- | :----: | ----: | +| a | b | c | +| `code` | **bold** | 1,234.50 | +| A longer cell that holds more words | x | 7 | + +## Code + +Inline `let x = 1;` and a fenced block with a language: + +```rust +fn fib(n: u32) -> u64 { + let (mut a, mut b) = (0u64, 1u64); + for _ in 0..n { + (a, b) = (b, a + b); + } + a +} +``` + +```python +def fib(n): + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a +``` + +```diff +- old line ++ new line +``` + +An indented block: + + four spaces make this code + and it keeps its spacing + +## Rules, HTML and footnotes + +--- + +Inline HTML such as Ctrl + C and sub and sup is +shown as text or ignored, never run: does nothing. + +A sentence with a footnote.[^1] And another one.[^note] + +[^1]: The first footnote. +[^note]: A second, with *formatting*. + +![An image reference](https://example.invalid/not-fetched.png) + +The end. +""" + +FILES["field-notes.txt"] = """Field notes +=========== + +Plain text is shown as it is written: line breaks, indentation, tabs and +long lines are kept. + + A line that starts with a tab. + A line that starts with eight spaces. + +A very long line, to see how the viewer wraps or scrolls it: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +Pangrams, one for each script: + + English The quick brown fox jumps over the lazy dog. + Français Portez ce vieux whisky au juge blond qui fume. + Deutsch Zwölf Boxkämpfer jagen Viktor quer über den großen Sylter Deich. + Español El pingüino Wenceslao hizo kilómetros bajo exhaustiva lluvia y frío. + Ελληνικά Ξεσκεπάζω την ψυχοφθόÏ�α βδελυγμία. + РуÑ�Ñ�кий Съешь же ещё Ñ�тих мÑ�гких французÑ�ких булок да выпей чаю. + 日本語 ã�„ã‚�ã�¯ã�«ã�»ã�¸ã�¨ ã�¡ã‚Šã�¬ã‚‹ã‚’ ã‚�ã�‹ã‚ˆã�Ÿã‚Œã�� ã�¤ã�­ã�ªã‚‰ã‚€ + العربية نص حكيم له سر قاطع وذو شأن عظيم مكتوب على ثوب أخضر ومزخرÙ� + עברית דג סקרן שט בי×� מ×�וכזב ולפתע מצ×� לו חברה ×�יך הקליטה + +Symbols and box drawing: + + ┌──────┬──────â”� â†� ↑ → ↓ ✓ ✗ ½ â…“ ¾ ± × ÷ ≠ ≤ ≥ ∞ + │ a │ b │ € £ Â¥ â‚¿ © ® â„¢ ☀ â˜� ☂ ☃ + └──────┴──────┘ + +The last line has no newline at the end.""" + +FILES["recipes.json"] = json.dumps( + { + "title": "A small recipe book", + "version": 3, + "published": True, + "updated": None, + "tags": ["cooking", "demo", "café", "日本語"], + "numbers": {"integer": 42, "negative": -7, "float": 3.14159, "exponent": 6.02e23, "tiny": 1e-9, "zero": 0}, + "recipes": [ + { + "name": "Crepes", + "serves": 4, + "time": {"prep_min": 10, "rest_min": 30, "cook_min": 20}, + "ingredients": [{"item": "flour", "amount": 250, "unit": "g"}, {"item": "milk", "amount": 0.5, "unit": "l"}, {"item": "eggs", "amount": 3, "unit": None}, {"item": "salt", "amount": "a pinch"}], + "steps": ["Whisk the flour and eggs.", "Add the milk slowly.", "Rest for 30 minutes.", "Cook in a hot pan, a minute a side."], + "nutrition": {"kcal": 210, "protein_g": 8.5, "vegetarian": True}, + }, + { + "name": "Ratatouille", + "serves": 6, + "time": {"prep_min": 25, "cook_min": 60}, + "ingredients": [{"item": "aubergine", "amount": 2}, {"item": "courgette", "amount": 3}, {"item": "tomatoes", "amount": 500, "unit": "g"}, {"item": "olive oil", "amount": 4, "unit": "tbsp"}], + "steps": ["Dice the vegetables.", "Fry each in turn.", "Stew together for an hour."], + "nutrition": {"kcal": 160, "protein_g": 3.1, "vegetarian": True, "vegan": True}, + }, + ], + "nested": {"a": {"b": {"c": {"d": {"e": [1, [2, [3, [4, [5]]]]]}}}}}, + "escapes": "quote \" backslash \\ slash / newline \n tab \t unicode é 中 😀", + "empty": {"object": {}, "array": [], "string": ""}, + }, + indent=2, + ensure_ascii=False, +) + "\n" + +FILES["config.yaml"] = """# A configuration file, to show YAML as text. +service: + name: harbour-register + port: 8080 + debug: false + tags: [boats, berths, "quay east"] + limits: + requests_per_minute: 600 + body_bytes: 1048576 +database: + url: postgres://register@localhost:5432/harbour + pool: {min: 2, max: 10} +berths: + - number: 1 + length_m: 6.5 + vessel: Aurora + - number: 2 + length_m: 8.2 + vessel: Brise +notes: | + A block of text that keeps + its line breaks. +folded: > + A block of text that is + folded into one line. +anchors: + base: &base {retries: 3, timeout_s: 30} + copy: *base +""" + +FILES["pyproject.toml"] = """# A project file, to show TOML as text. +[project] +name = "harbour-register" +version = "1.4.0" +description = "The mooring register of the east quay" +requires-python = ">=3.11" +dependencies = ["httpx>=0.27", "pydantic>=2"] + +[project.optional-dependencies] +test = ["pytest", "pytest-cov"] + +[tool.ruff] +line-length = 100 +select = ["E", "F", "I"] + +[[tool.harbour.berths]] +number = 1 +length = 6.5 + +[[tool.harbour.berths]] +number = 2 +length = 8.2 +""" + +FILES["feed.xml"] = """ + + + Notes from the east quay + Moorings, tides & weather + + + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + 2000-01-03T12:00:00Z + The harbour officeoffice@example.invalid + + New berths on the east quay + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2000-01-03T12:00:00Z + Twelve berths for visiting boats open on 1 March. +

    The east quay is longer by 40 metres.

    + +
    + + Tide tables for the year + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b + 2000-01-01T08:00:00Z + + +
    +""" + + +def gpx(): + pts = [] + for i in range(60): + t = i / 59 + lat = 48.3794 + 0.03 * math.sin(t * 5) + 0.01 * t + lon = -4.4900 + 0.06 * t + 0.01 * math.cos(t * 7) + ele = 20 + 15 * math.sin(t * 9) + 10 * t + pts.append(f' {ele:.1f}') + return ( + '\n' + '\n' + " Coastal walk (made up)A made-up track, 60 points.\n" + ' StartFlag\n' + ' ViewpointSeals on the rocks, in winter.\n' + " \n Morning walk\n \n" + "\n".join(pts) + "\n \n \n\n" + ) + + +FILES["route.gpx"] = gpx() + +FILES["places.kml"] = """ + + + Places (made up) + + + East quay + Moorings
    for 60 boats.]]> + #harbour + -4.4860,48.3830,0 + + + Breakwater + 1-4.4900,48.3790,0 -4.4850,48.3820,0 -4.4800,48.3835,0 + + + Anchorage + #harbour + -4.4950,48.3850,0 -4.4900,48.3850,0 -4.4900,48.3900,0 -4.4950,48.3900,0 -4.4950,48.3850,0 + + + +""" + + +def harbour_csv(): + rng = random.Random(11) + boats = ["Aurora", "Brise", "Calypso", "Dorade", "Etoile", "Fauvette", "Goeland", "Hirondelle", "Iroise", "Jolie Brise", "Kerguelen", "La Mouette"] + kinds = ["sloop", "ketch", "trawler", "motor yacht", "catamaran", "dinghy"] + notes = ["", "", "", "Arrived late, \"quiet\" night", "Bill sent; paid by cheque", "Owner: M. Le Gall, tel. 02 98 00 00 00", "Two nights,\nleft at dawn", "Café voucher given", ""] + rows = ["date,vessel,type,length_m,beam_m,berth,nights,fee_eur,notes"] + + def q(s): + s = str(s) + return '"' + s.replace('"', '""') + '"' if any(c in s for c in ',"\n') else s + + for i in range(300): + d = 1 + i // 10 + month = 1 + (d - 1) // 28 + day = 1 + (d - 1) % 28 + length = round(rng.uniform(4.5, 16.0), 1) + nights = rng.randint(1, 5) + rows.append(",".join(q(v) for v in [f"2000-{month:02d}-{day:02d}", rng.choice(boats) + (f" {i // 40 + 2}" if i > 40 else ""), rng.choice(kinds), length, round(length * 0.33, 2), rng.randint(1, 60), nights, f"{nights * (8 + length * 1.6):.2f}", rng.choice(notes)])) + return "\n".join(rows) + "\n" + + +FILES["harbour-log.csv"] = harbour_csv() + +FILES["tides.tsv"] = "day\thigh_1\theight_1_m\tlow_1\theight_2_m\tcoefficient\n" + "\n".join( + f"{d}\t{(5 + d * 0.8) % 24:05.2f}\t{4.5 + 2.4 * math.sin(d / 4.7):.2f}\t{(11 + d * 0.8) % 24:05.2f}\t{1.2 + 0.8 * math.cos(d / 4.7):.2f}\t{int(70 + 40 * math.sin(d / 4.7))}" for d in range(1, 29) +) + "\n" + +FILES["prix.csv"] = "Région;Produit;Quantité;Prix unitaire (€);Total (€)\n" + "\n".join( + f"{r};{p};{q};{u:.2f}".replace(".", ",") + ";" + f"{q * u:.2f}".replace(".", ",") + for r, p, q, u in [("Bretagne", "Cidre", 120, 3.5), ("Bretagne", "Galettes", 300, 1.2), ("Normandie", "Cidre", 90, 3.8), ("Normandie", "Camembert", 200, 4.1), ("Alsace", "Riesling", 150, 7.9), ("Alsace", "Bretzels", 500, 0.6)] +) + "\n" + +FILES["life.py"] = '''"""Conway's Game of Life on a torus, printed as text. CC0-1.0, exav project.""" +import random +import sys + +WIDTH, HEIGHT = 40, 16 + + +def step(cells): + """The next generation of a set of (x, y) cells on a WIDTH by HEIGHT torus.""" + counts = {} + for (x, y) in cells: + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + if dx or dy: + key = ((x + dx) % WIDTH, (y + dy) % HEIGHT) + counts[key] = counts.get(key, 0) + 1 + return {cell for cell, n in counts.items() if n == 3 or (n == 2 and cell in cells)} + + +def show(cells): + return "\\n".join("".join("#" if (x, y) in cells else "." for x in range(WIDTH)) for y in range(HEIGHT)) + + +def main(generations=20, seed=7): + rng = random.Random(seed) + cells = {(x, y) for x in range(WIDTH) for y in range(HEIGHT) if rng.random() < 0.25} + for g in range(generations): + print(f"generation {g}: {len(cells)} cells") + print(show(cells)) + cells = step(cells) + + +if __name__ == "__main__": + main(*(int(a) for a in sys.argv[1:3])) +''' + +FILES["lru.rs"] = '''//! A small least-recently-used cache. CC0-1.0, exav project. +use std::collections::HashMap; +use std::hash::Hash; + +/// A cache that keeps at most `capacity` entries and drops the one used longest ago. +pub struct Lru { + capacity: usize, + tick: u64, + entries: HashMap, +} + +impl Lru { + pub fn new(capacity: usize) -> Self { + assert!(capacity > 0, "a cache holds at least one entry"); + Lru { capacity, tick: 0, entries: HashMap::with_capacity(capacity) } + } + + pub fn get(&mut self, key: &K) -> Option<&V> { + self.tick += 1; + let tick = self.tick; + self.entries.get_mut(key).map(|(v, used)| { + *used = tick; + &*v + }) + } + + pub fn put(&mut self, key: K, value: V) -> Option { + self.tick += 1; + if !self.entries.contains_key(&key) && self.entries.len() == self.capacity { + let oldest = self.entries.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()); + if let Some(k) = oldest { + self.entries.remove(&k); + } + } + self.entries.insert(key, (value, self.tick)).map(|(v, _)| v) + } + + pub fn len(&self) -> usize { + self.entries.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drops_the_least_recently_used() { + let mut c = Lru::new(2); + c.put("a", 1); + c.put("b", 2); + assert_eq!(c.get(&"a"), Some(&1)); + c.put("c", 3); + assert_eq!(c.get(&"b"), None); + assert_eq!(c.len(), 2); + } +} +''' + +FILES["tokens.ts"] = '''// An arithmetic tokenizer and evaluator. CC0-1.0, exav project. +type Token = { kind: "num"; value: number } | { kind: "op"; value: "+" | "-" | "*" | "/" | "^" } | { kind: "paren"; value: "(" | ")" }; + +export function tokenize(source: string): Token[] { + const tokens: Token[] = []; + const re = /\\s*(?:(\\d+(?:\\.\\d+)?)|([-+*/^])|([()]))/y; + let match: RegExpExecArray | null; + while (re.lastIndex < source.length && (match = re.exec(source))) { + if (match[1] !== undefined) tokens.push({ kind: "num", value: Number(match[1]) }); + else if (match[2]) tokens.push({ kind: "op", value: match[2] as "+" }); + else tokens.push({ kind: "paren", value: match[3] as "(" }); + } + if (re.lastIndex < source.trimEnd().length) throw new SyntaxError(`unexpected character at ${re.lastIndex}`); + return tokens; +} + +const PRECEDENCE: Record = { "+": [1, "left"], "-": [1, "left"], "*": [2, "left"], "/": [2, "left"], "^": [3, "right"] }; + +/** Evaluates with the shunting-yard algorithm. */ +export function evaluate(source: string): number { + const out: number[] = []; + const ops: string[] = []; + const apply = (op: string) => { + const b = out.pop(); + const a = out.pop(); + if (a === undefined || b === undefined) throw new SyntaxError("missing operand"); + out.push(op === "+" ? a + b : op === "-" ? a - b : op === "*" ? a * b : op === "/" ? a / b : a ** b); + }; + for (const t of tokenize(source)) { + if (t.kind === "num") out.push(t.value); + else if (t.kind === "op") { + const [p, assoc] = PRECEDENCE[t.value]!; + while (ops.length) { + const top = ops[ops.length - 1]!; + const [tp] = PRECEDENCE[top] ?? [-1]; + if (top !== "(" && (tp > p || (tp === p && assoc === "left"))) apply(ops.pop()!); + else break; + } + ops.push(t.value); + } else if (t.value === "(") ops.push("("); + else { + while (ops.length && ops[ops.length - 1] !== "(") apply(ops.pop()!); + if (ops.pop() !== "(") throw new SyntaxError("unbalanced parenthesis"); + } + } + while (ops.length) { + const op = ops.pop()!; + if (op === "(") throw new SyntaxError("unbalanced parenthesis"); + apply(op); + } + return out[0] ?? NaN; +} + +console.log(evaluate("2 + 3 * (4 - 1) ^ 2")); +''' + +FILES["bits.c"] = '''/* Bit tricks on 32-bit words. CC0-1.0, exav project. */ +#include +#include + +/* The number of set bits, by the parallel sum. */ +static unsigned popcount32(uint32_t x) { + x = x - ((x >> 1) & 0x55555555u); + x = (x & 0x33333333u) + ((x >> 2) & 0x33333333u); + x = (x + (x >> 4)) & 0x0f0f0f0fu; + return (x * 0x01010101u) >> 24; +} + +/* The index of the highest set bit, or -1 for zero. */ +static int high_bit(uint32_t x) { + int n = -1; + while (x) { + n++; + x >>= 1; + } + return n; +} + +/* The next power of two that is not below x. */ +static uint32_t next_pow2(uint32_t x) { + if (x <= 1) return 1; + x--; + x |= x >> 1; + x |= x >> 2; + x |= x >> 4; + x |= x >> 8; + x |= x >> 16; + return x + 1; +} + +/* The bits of x in reverse order. */ +static uint32_t reverse32(uint32_t x) { + x = ((x >> 1) & 0x55555555u) | ((x & 0x55555555u) << 1); + x = ((x >> 2) & 0x33333333u) | ((x & 0x33333333u) << 2); + x = ((x >> 4) & 0x0f0f0f0fu) | ((x & 0x0f0f0f0fu) << 4); + x = ((x >> 8) & 0x00ff00ffu) | ((x & 0x00ff00ffu) << 8); + return (x >> 16) | (x << 16); +} + +int main(void) { + const uint32_t samples[] = {0u, 1u, 255u, 1000u, 0xdeadbeefu}; + for (size_t i = 0; i < sizeof samples / sizeof samples[0]; i++) { + uint32_t v = samples[i]; + printf("%10u: bits %2u, high %2d, pow2 %10u, reversed %08x\\n", v, popcount32(v), high_bit(v), next_pow2(v), reverse32(v)); + } + return 0; +} +''' + +FILES["report.sql"] = """-- Moorings by month, with window functions. CC0-1.0, exav project. +CREATE TABLE vessel ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + length_m REAL NOT NULL CHECK (length_m > 0), + kind TEXT NOT NULL DEFAULT 'sloop' +); + +CREATE TABLE stay ( + id INTEGER PRIMARY KEY, + vessel_id INTEGER NOT NULL REFERENCES vessel (id), + berth INTEGER NOT NULL, + arrived DATE NOT NULL, + nights INTEGER NOT NULL, + fee_eur NUMERIC(8, 2) +); + +CREATE INDEX stay_by_arrival ON stay (arrived); + +WITH monthly AS ( + SELECT strftime('%Y-%m', arrived) AS month, + count(*) AS stays, + sum(nights) AS nights, + sum(fee_eur) AS income + FROM stay + GROUP BY 1 +) +SELECT month, + stays, + income, + income - lag(income) OVER (ORDER BY month) AS change, + round(100.0 * income / sum(income) OVER (), 1) AS share_pct, + rank() OVER (ORDER BY income DESC) AS income_rank +FROM monthly +ORDER BY month; + +-- The ten longest boats that stayed more than three nights. +SELECT v.name, v.length_m, s.nights +FROM vessel AS v +JOIN stay AS s ON s.vessel_id = v.id +WHERE s.nights > 3 AND v.kind IN ('ketch', 'trawler') +ORDER BY v.length_m DESC +LIMIT 10; +""" + +FILES["deploy.sh"] = """#!/usr/bin/env bash +# Build, check and copy a static site. CC0-1.0, exav project. +set -euo pipefail + +SITE_DIR="${SITE_DIR:-./public}" +REMOTE="${REMOTE:-deploy@example.invalid:/var/www/site}" +LOG="$(mktemp)" + +cleanup() { + local status=$? + rm -f "$LOG" + if [ "$status" -ne 0 ]; then + echo "deploy failed with status $status" >&2 + fi +} +trap cleanup EXIT + +log() { printf '[%s] %s\\n' "$(date +%H:%M:%S)" "$*"; } + +build() { + log "building" + npm ci --silent >"$LOG" 2>&1 || { cat "$LOG" >&2; return 1; } + npm run build --silent +} + +check() { + local broken=0 + while IFS= read -r -d '' file; do + if ! grep -q '' "$file"; then + log "incomplete page: $file" + broken=$((broken + 1)) + fi + done < <(find "$SITE_DIR" -name '*.html' -print0) + return "$broken" +} + +case "${1:-all}" in + build) build ;; + check) check ;; + all) build && check && rsync -az --delete "$SITE_DIR/" "$REMOTE" && log "done" ;; + *) echo "usage: $0 [build|check|all]" >&2; exit 2 ;; +esac +""" + +FILES["style.css"] = """/* A small stylesheet with custom properties, grid and a media query. CC0-1.0, exav project. */ +:root { + --ink: #1b2430; + --paper: #fbfaf7; + --accent: #0b7285; + --radius: 0.5rem; + --gap: clamp(0.75rem, 2vw, 1.5rem); + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { --ink: #e7edf3; --paper: #12171d; --accent: #66d9e8; } +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: 1rem/1.55 system-ui, sans-serif; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: var(--gap); + padding: var(--gap); +} + +.card { + padding: 1rem; + border: 1px solid color-mix(in srgb, var(--ink) 20%, transparent); + border-radius: var(--radius); + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.card:hover, +.card:focus-within { + transform: translateY(-2px); + box-shadow: 0 6px 18px rgb(0 0 0 / 0.12); +} + +.card > h2::after { content: " \\2192"; color: var(--accent); } + +a:not([class]) { color: var(--accent); text-underline-offset: 0.2em; } + +@media (max-width: 40rem) { + .cards { grid-template-columns: 1fr; } +} +""" + +FILES["page.html"] = """ + + + + A page, shown as its source + + + + + + +

    Harbour office

    +
    +
    +

    Berths

    + + + +
    BerthLength
    16.5 m
    28.2 m
    +

    Lengths are over all, in metres.

    +
    +
    +
    + + + +""" + +FILES["changes.diff"] = """diff --git a/src/berths.py b/src/berths.py +index 3f2a1c4..9b7d0e8 100644 +--- a/src/berths.py ++++ b/src/berths.py +@@ -10,12 +10,16 @@ class Berth: + number: int + length_m: float ++ max_draught_m: float = 1.5 + + def fits(self, boat): +- return boat.length_m <= self.length_m ++ return boat.length_m <= self.length_m and boat.draught_m <= self.max_draught_m + + def label(self): +- return "Berth %d" % self.number ++ return f"Berth {self.number} ({self.length_m:.1f} m)" +@@ -40,7 +44,7 @@ def free_berths(register, boat): + for berth in register: + if berth.fits(boat): + yield berth +- return ++ return None +diff --git a/README.md b/README.md +index 1e2d3f4..5a6b7c8 100644 +--- a/README.md ++++ b/README.md +@@ -1,4 +1,5 @@ + # Harbour register + + The mooring register of the east quay. ++Berths now have a maximum draught. +""" + +FILES["subtitles.srt"] = "\n".join( + f"{i}\n00:00:{2 * i:02d},000 --> 00:00:{2 * i + 1:02d},800\n{line}\n" + for i, line in enumerate(["The tide is coming in.", "Berth fourteen is free.", "A gull laughs.", "Take the line, please.", "Engine off, thank you.", "Welcome to the east quay."], 1) +) + +FILES["server.log"] = "".join( + f'2000-01-01T{8 + i // 60:02d}:{i % 60:02d}:{(i * 7) % 60:02d}Z {["INFO", "INFO", "INFO", "WARN", "ERROR"][i % 5]} request id={1000 + i} path=/berths/{i % 60 + 1} status={[200, 200, 304, 404, 500][i % 5]} ms={12 + (i * 37) % 250}\n' for i in range(120) +) + +for name, text in FILES.items(): + write(name, text) diff --git a/crates/exav-viewer/demo/examples/generate/verify-evidence.mjs b/crates/exav-viewer/demo/examples/generate/verify-evidence.mjs new file mode 100644 index 00000000..76081104 --- /dev/null +++ b/crates/exav-viewer/demo/examples/generate/verify-evidence.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// Fetches the page that states each third-party example's licence and checks +// that the words recorded in examples.json (`evidence_quote`) are on it. +// +// node generate/verify-evidence.mjs [name-fragment ...] +// +// A name fragment also matches the evidence address (`commons.wikimedia.org`). +// It needs the network and is slow on purpose (one request at a time, with a +// pause), so it is not part of the demo build. A page that is a PDF is read with +// pdftotext, and a web page is read without its tags. An entry with `evidence_row` names a field (the file's SHA-256) that +// must sit in the same row of a JSON table as the quote. Exit 1 when a quote is +// missing or a page cannot be read. +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const { examples } = JSON.parse(fs.readFileSync(path.join(here, "..", "examples.json"), "utf8")); +const only = process.argv.slice(2); +const USER_AGENT = "exav-examples/1.0 (https://github.com/sylvinus/exav)"; + +const norm = (s) => + s + .replace(/\\\//g, "/") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/�?39;|'/g, "'") + .replace(/ /g, " ") + .replace(/\\"/g, '"') + .replace(/\s+/g, " ") + .replace(/ ([.,;:!?)])/g, "$1"); + +const cache = new Map(); +async function page(url) { + if (cache.has(url)) return cache.get(url); + let last; + for (let attempt = 1; attempt <= 4; attempt++) { + try { + const res = await fetch(url, { headers: { "User-Agent": USER_AGENT, Accept: "*/*" }, redirect: "follow" }); + if (res.ok) { + const buf = Buffer.from(await res.arrayBuffer()); + let raw; + const texts = []; + if (buf.subarray(0, 5).toString() === "%PDF-") { + const tmp = path.join(os.tmpdir(), `exav-evidence-${process.pid}.pdf`); + fs.writeFileSync(tmp, buf); + raw = execFileSync("pdftotext", [tmp, "-"], { maxBuffer: 1 << 28 }).toString(); + fs.rmSync(tmp); + } else { + raw = buf.toString("utf8"); + // A web page is also read as a person sees it, tags apart. + if (/]/i.test(raw.slice(0, 2000)) || /^\s*/gi, " ").replace(/<[^>]*>/g, " ")); + } + } + texts.push(raw); + cache.set(url, texts); + return texts; + } + last = new Error(`HTTP ${res.status}`); + if (res.status !== 429 && res.status < 500) break; + } catch (e) { + last = e; + } + await new Promise((r) => setTimeout(r, 2000 * attempt)); + } + throw new Error(`${url}: ${last?.message ?? last}`); +} + +let failed = 0; +let checked = 0; +for (const e of examples) { + if (e.generated || (only.length && !only.some((o) => e.file.includes(o) || e.evidence.includes(o)))) continue; + checked++; + try { + const texts = await page(e.evidence); + const quote = norm(e.evidence_quote); + let ok; + if (e.evidence_row) { + const row = norm(texts.at(-1)).split(/\],\s?\[/).find((r) => r.includes(e[e.evidence_row])); + ok = Boolean(row) && row.includes(quote); + } else { + ok = texts.some((t) => norm(t).includes(quote)); + } + console.log(`${ok ? "ok " : "FAIL"} ${e.file}: ${e.evidence}`); + if (!ok) failed++; + } catch (err) { + console.log(`FAIL ${e.file}: ${err.message}`); + failed++; + } + await new Promise((r) => setTimeout(r, 400)); +} +console.log(`${checked} pages checked, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/crates/exav-viewer/demo/examples/verify-open.mjs b/crates/exav-viewer/demo/examples/verify-open.mjs new file mode 100644 index 00000000..dbe2549f --- /dev/null +++ b/crates/exav-viewer/demo/examples/verify-open.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node +// Checks that the viewer's own engines read every example, in Node, without a browser: +// +// node demo/examples/verify-open.mjs [name-fragment ...] +// +// For each example (from demo/public/examples/ after `npm run demo:examples`, else +// the committed files/), it asks the viewer's detection (dist/core, from `npm run +// build`) which format claims it, and then reads it with the engine for that +// format: the wasm decoders and converters of wasm/ (`npm run build:wasm`), +// @exav/unpack-wasm for archives and pdf.js for PDF. A file of the `unsupported` +// group must fail; any other must open. Exit 1 on a surprise. +// +// What it does not check: that the picture drawn is right (it counts what the +// engine returns), and the browser-only formats (the browser draws PNG, JPEG, +// GIF, WebP and SVG; its media players play audio and video): those are checked +// by their signature. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pkg = path.join(here, "..", ".."); +const only = process.argv.slice(2); +const { examples } = JSON.parse(fs.readFileSync(path.join(here, "examples.json"), "utf8")); + +const wasm = (name) => path.join(pkg, "wasm", name); +const need = (p) => { + if (!fs.existsSync(p)) throw new Error(`${path.relative(pkg, p)} is missing: run npm run build:wasm and npm run build first`); + return p; +}; +const load = async (name) => { + const mod = await import(pathToFileURL(need(wasm(`exav_viewer_${name}.js`))).href); + mod.initSync({ module: fs.readFileSync(wasm(`exav_viewer_${name}_bg.wasm`)) }); + return mod; +}; +const cache = {}; +const engine = async (name) => (cache[name] ??= await load(name)); + +const formats = await import(pathToFileURL(need(path.join(pkg, "dist", "core", "formats.js"))).href); +const { createDetector } = await import(pathToFileURL(path.join(pkg, "dist", "core", "detect.js")).href); +const merge = (a, b) => ({ ...a, types: [...(a.types ?? []), ...(b.types ?? [])], extensions: [...(a.extensions ?? []), ...(b.extensions ?? [])], extensionOverrides: [...(a.extensionOverrides ?? []), ...(b.extensionOverrides ?? [])] }); +const detector = createDetector(formats.BUILTIN_ORDER.map((id) => ({ id, match: id === "image" ? merge(formats.MATCHERS.image, formats.WASM_IMAGES) : formats.MATCHERS[id] }))); + +let unpack; +const archive = async () => { + if (!unpack) { + const dir = path.join(pkg, "..", "exav-unpack-wasm", "pkg"); + unpack = await import(pathToFileURL(need(path.join(dir, "exav_unpack_wasm.js"))).href); + unpack.initSync({ module: fs.readFileSync(path.join(dir, "exav_unpack_wasm_bg.wasm")) }); + } + return unpack; +}; + +let pdfjs; +const pdf = async () => (pdfjs ??= await import(pathToFileURL(path.join(pkg, "node_modules", "pdfjs-dist", "legacy", "build", "pdf.mjs")).href)); + +const ext = (name) => path.extname(name).slice(1).toLowerCase(); +const GB = 1 << 30; + +/** One check per detected format: returns a short description, or throws. */ +const READ = { + async image(name, bytes) { + const e = ext(name); + if (["png", "jpg", "jpeg", "gif", "webp"].includes(e)) { + const type = detector.sniff(bytes.subarray(0, 512)); + if (!type.startsWith("image/")) throw new Error(`sniffed as "${type}"`); + return `browser draws it (${type})`; + } + if (e === "svg") { + if (!/]/.test(new TextDecoder().decode(bytes.subarray(0, 4096)))) throw new Error("no element at the start"); + return "browser draws it (SVG)"; + } + const m = await engine("image"); + const d = m.decodeImage(bytes, 1 << 29); + const px = d.takeRgba(); + let sum = 0; + for (let i = 0; i < px.length; i += 4) sum += px[i] + px[i + 1] + px[i + 2]; + if (px.length === 0 || sum === 0) throw new Error("decoded to nothing, or all black"); + return `${m.imageFormat(bytes.subarray(0, 512)) ?? "?"} ${d.width}x${d.height}`; + }, + async dwg(name, bytes) { + const m = await engine("dwg"); + const doc = m.parseDrawing(bytes, GB / 2); + const layouts = JSON.parse(doc.layoutsJson()); + let best = 0; + for (const l of layouts) { + const d = doc.tessellate(l.name, new Uint8Array([255, 255, 255]), 50_000_000); + best = Math.max(best, d.opaqueStrokes() + d.opaqueFills()); + } + if (!best) throw new Error("nothing drawn"); + return `${layouts.length} layout(s), ${best} strokes and fills`; + }, + async model(name, bytes, id) { + const m = await engine("model"); + const model = { stl: m.parseStl, obj: m.parseObj, ply: m.parsePly, gltf: m.parseGltf, ifc: m.parseIfc }[id](bytes, 20_000_000); + const meta = JSON.parse(model.metaJson()); + const tris = meta.batches.reduce((n, b) => n + b.indices / 3, 0); + if (!tris) throw new Error("no triangles"); + return `${tris} triangles, ${meta.elements.length} element(s)`; + }, + async ooxml(name, bytes, id) { + const a = (await archive()).Archive.open(bytes); + const names = a.list().map((e) => e.name); + const part = { docx: "word/document.xml", xlsx: "xl/workbook.xml", pptx: "ppt/presentation.xml" }[id]; + if (!names.includes(part)) throw new Error(`no ${part}`); + return `${names.length} parts`; + }, + async docs(name, bytes) { + const c = (await engine("docs")).convertDocs(bytes, ext(name), 1e6, GB); + return `${c.paragraphs} paragraphs, ${c.takeDocx().length} bytes of DOCX`; + }, + async sheets(name, bytes) { + const c = (await engine("sheets")).convertSheets(bytes, ext(name), 1e7, GB); + return `${c.takeXlsx().length} bytes of XLSX`; + }, + async slides(name, bytes) { + const c = (await engine("slides")).convertSlides(bytes, ext(name), 1e6, GB, Infinity); + return `${c.slides} slide(s)`; + }, + async pdf(name, bytes) { + const lib = await pdf(); + const task = lib.getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, isEvalSupported: false, verbosity: 0 }); + try { + const doc = await task.promise; + const outline = await doc.getOutline(); + const page = await doc.getPage(1); + const annots = (await page.getAnnotations()).length; + return `${doc.numPages} page(s), ${outline?.length ?? 0} outline entries, ${annots} annotations on page 1`; + } finally { + await task.destroy(); + } + }, + async text(name, bytes) { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + if (["json", "geojson"].includes(ext(name))) JSON.parse(text); + if (["md", "markdown"].includes(ext(name))) { + const doc = JSON.parse((await engine("text")).parseMarkdown(text)); + return `markdown, ${JSON.stringify(doc).length} bytes of tree`; + } + return `${text.split("\n").length} lines`; + }, + async csv(name, bytes) { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const lines = text.split(/\r?\n/).filter(Boolean); + if (lines.length < 2) throw new Error("fewer than two rows"); + return `${lines.length} lines`; + }, + async archive(name, bytes) { + const a = (await archive()).Archive.open(bytes); + const list = a.list(); + if (!list.length) throw new Error("no members"); + return `${a.format()}, ${list.length} member(s)${list.some((m) => m.encrypted) ? ", encrypted" : ""}`; + }, + async media(name, bytes) { + // The browser's players decode them; here only that the head is a media container's or an MP3 frame's. + const type = detector.sniff(bytes.subarray(0, 512)); + const mp3Frame = bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0; + if (!/^(audio|video|application\/ogg)/.test(type) && !mp3Frame) throw new Error(`sniffed as "${type}"`); + return `plays in the browser (${type || "MP3 frames"})`; + }, +}; + +const ROUTE = { + image: READ.image, dwg: READ.dwg, dxf: READ.dwg, ifc: (n, b) => READ.model(n, b, "ifc"), stl: (n, b) => READ.model(n, b, "stl"), obj: (n, b) => READ.model(n, b, "obj"), + ply: (n, b) => READ.model(n, b, "ply"), gltf: (n, b) => READ.model(n, b, "gltf"), docx: (n, b) => READ.ooxml(n, b, "docx"), xlsx: (n, b) => READ.ooxml(n, b, "xlsx"), + pptx: (n, b) => READ.ooxml(n, b, "pptx"), rtf: READ.docs, doc: READ.docs, odt: READ.docs, xls: READ.sheets, ods: READ.sheets, numbers: READ.sheets, + ppt: READ.slides, odp: READ.slides, keynote: READ.slides, odg: READ.slides, visio: READ.slides, pdf: READ.pdf, text: READ.text, csv: READ.csv, + archive: READ.archive, video: READ.media, audio: READ.media, +}; + +let surprises = 0; +let count = 0; +for (const e of examples) { + if (only.length && !only.some((o) => e.file.includes(o))) continue; + const served = path.join(pkg, "demo", "public", "examples", e.file); + const file = fs.existsSync(served) ? served : e.path ? path.join(here, "files", e.path) : null; + if (!file || !fs.existsSync(file)) { + console.log(`skip ${e.file}: not downloaded`); + continue; + } + count++; + const bytes = new Uint8Array(fs.readFileSync(file)); + // As the demo does it: a file given by its address has a name and no type. + const id = detector.detect({ type: "", name: e.file, path: e.file }); + const expectFail = e.group === "unsupported"; + let result; + try { + if (!id) throw new Error("no plugin claims it"); + if (["pages", "coreldraw", "publisher", "pagemaker", "freehand", "quarkxpress", "zonerdraw", "wpg"].includes(id)) throw new Error(`${id} needs an external converter`); + if (!ROUTE[id]) throw new Error(`no check for ${id}`); + result = { ok: true, text: `${id}: ${await ROUTE[id](e.file, bytes)}` }; + } catch (err) { + result = { ok: false, text: `${id ?? "none"}: ${String(err.message ?? err).slice(0, 120)}` }; + } + // The ODG converter is newer than some builds of wasm/: say so rather than fail. + if (!result.ok && id === "odg" && /unsupported/.test(result.text) && !expectFail) { + console.log(`warn ${e.file.padEnd(44)} ${result.text} (wasm/ may predate ODG: run npm run build:wasm)`); + continue; + } + const fine = result.ok !== expectFail; + if (!fine) surprises++; + console.log(`${fine ? "ok " : "FAIL"} ${e.file.padEnd(44)} ${expectFail ? "(unsupported on purpose) " : ""}${result.text}`); +} +console.log(`${count} examples read, ${surprises} surprise${surprises === 1 ? "" : "s"}`); +process.exit(surprises ? 1 : 0); diff --git a/crates/exav-viewer/demo/headless.html b/crates/exav-viewer/demo/headless.html new file mode 100644 index 00000000..59d0e579 --- /dev/null +++ b/crates/exav-viewer/demo/headless.html @@ -0,0 +1,32 @@ + + + + + + + + exav viewer, the host's own controls + + +
    +

    The host's own controls

    +

    Each file in the sandboxed frame; every button on this page is the page's, driven by the session's controllers. No React.

    + + + +
    +
    + +
    +
    +
    +
    +
    +
    +
      + + + diff --git a/crates/exav-viewer/demo/src/App.tsx b/crates/exav-viewer/demo/src/App.tsx index e58f2d1b..6bfa8e56 100644 --- a/crates/exav-viewer/demo/src/App.tsx +++ b/crates/exav-viewer/demo/src/App.tsx @@ -1,37 +1,118 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from "react"; -import type { ViewerFile } from "@exav/viewer"; +import { BUILTIN_ORDER, type FileTree, type Session, type ViewerFile } from "@exav/viewer"; import { allPlugins } from "@exav/viewer/all"; +import { + coreldraw, + doc, + freehand, + keynote, + numbers, + odg, + odp, + ods, + odt, + pagemaker, + ppt, + publisher, + quarkxpress, + rtf, + visio, + wpg, + xls, + zonerdraw, +} from "@exav/viewer/office"; import { ViewerBody, ViewerProvider } from "@exav/viewer/react"; import { SandboxedViewerProvider } from "@exav/viewer/react/sandbox"; -import { GROUPS, type Sample } from "./showcase.js"; +import { ExportImage } from "./ExportImage.js"; +import { credit, GROUPS, type Example, type Sample } from "./showcase.js"; type Locale = "en" | "fr"; /** A file in the list, shown by its name. */ type Item = ViewerFile; +/** A file chosen or dropped, and where it is in the folder it came from. */ +interface Picked { + file: File; + path: string; +} + +const picked = (files: FileList | File[]): Picked[] => Array.from(files).map((file) => ({ file, path: file.webkitRelativePath || file.name })); + +/** + * What a drop holds, folders walked: the files by their path from the + * dropped folder's parent. `entries` are read before the first await, which + * is when the browser lets them be. + */ +async function dropped(data: DataTransfer): Promise { + const entries = Array.from(data.items ?? []) + .map((i) => i.webkitGetAsEntry?.()) + .filter((e): e is FileSystemEntry => !!e); + if (!entries.length) return picked(data.files); + const out: Picked[] = []; + const walk = async (entry: FileSystemEntry): Promise => { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => (entry as FileSystemFileEntry).file(resolve, reject)); + out.push({ file, path: entry.fullPath.replace(/^\//, "") }); + } else if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntry).createReader(); + // A directory is read in batches until one is empty. + for (;;) { + const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + if (!batch.length) break; + for (const e of batch) await walk(e); + } + } + }; + for (const e of entries) await walk(e); + return out; +} + +/** The files of a batch, each seen as one of them: a drawing finds the files it refers to among the others. */ +function around(batch: Picked[], at: Picked): FileTree | undefined { + if (batch.length < 2) return undefined; + return { + path: at.path, + paths: batch.map((b) => b.path), + open: async (path) => { + const hit = batch.find((b) => b.path === path); + if (!hit) throw new Error(`no ${path} in the folder`); + return { blob: hit.file }; + }, + }; +} + +/** A sample or an example in the sidebar: its section, and who made it, under what licence. */ +interface Listed { + group: string; + note: string; + item: Item; +} + const TEXT = { en: { tagline: "Files viewed in the browser. Nothing you open here leaves your device.", yours: "Your files", open: "Open one of your files", - drop: "Drop files to view them", + folder: "Open a folder", + drop: "Drop files or a folder to view them", docs: "Documentation", licences: "Licences", upload: "Drop files here, or click to choose", - formats: "PDF, images, DWG and DXF drawings, Word, Excel, PowerPoint, CSV, IFC and STL models, video, audio and archives. Or pick a sample on the left.", + formats: "PDF, images, DWG and DXF drawings, Word, Excel, PowerPoint, CSV, text, Markdown, JSON and XML, IFC, STL, OBJ, PLY and glTF models, video, audio and archives. Or pick a sample on the left.", }, fr: { tagline: "Des fichiers affichés dans le navigateur. Rien de ce que vous ouvrez ici ne quitte votre appareil.", yours: "Vos fichiers", open: "Ouvrir un de vos fichiers", - drop: "Déposez des fichiers pour les afficher", + folder: "Ouvrir un dossier", + drop: "Déposez des fichiers ou un dossier pour les afficher", docs: "Documentation", licences: "Licences", upload: "Déposez des fichiers ici, ou cliquez pour les choisir", - formats: "PDF, images, plans DWG et DXF, Word, Excel, PowerPoint, CSV, maquettes IFC et STL, vidéo, audio et archives. Ou choisissez un exemple à gauche.", + formats: "PDF, images, plans DWG et DXF, Word, Excel, PowerPoint, CSV, texte, Markdown, JSON et XML, maquettes IFC, STL, OBJ, PLY et glTF, vidéo, audio et archives. Ou choisissez un exemple à gauche.", }, } satisfies Record>; @@ -47,21 +128,54 @@ const IN_PAGE = QUERY.get("mode") === "page"; /** Where the demo is deployed (vite.config.ts): the frame may show media and fetch from it. */ declare const __DEMO_ORIGIN__: string; +/** The external converter the demo was built with (`EXAV_EXTERNAL_CONVERTER`), or null. */ +declare const __EXTERNAL_CONVERTER__: string | null; /** * The frame's delivery per kind of file, defaults unless `?delivery=pdf:url,media:blob` * says otherwise (the browser tests). */ const DELIVERY = Object.fromEntries((QUERY.get("delivery") ?? "").split(",").filter(Boolean).map((d) => d.split(":"))); + +// Every format, the converted Office ones included: where no external +// converter was copied in, a format only it reads ends in "needs_converter". +/** + * `?converter=exav` or `?converter=external` (the browser tests): without + * it, text documents (DOC, ODT, RTF) go to the external converter when the + * demo has one, else to exav-render's `docs`. + */ +const CONVERTER = { + ...(QUERY.get("converter") === "exav" || QUERY.get("converter") === "external" ? { converter: QUERY.get("converter") as "exav" | "external" } : {}), + // `?timeoutMs=500&maxMemory=5000000` (the browser tests). + ...(QUERY.get("timeoutMs") ? { timeoutMs: Number(QUERY.get("timeoutMs")) } : {}), + ...(QUERY.get("maxMemory") ? { maxMemory: Number(QUERY.get("maxMemory")) } : {}), + // `?externalConverter=/e2e/fake-converter.js`: a converter by its URL (the browser tests). + ...(QUERY.get("externalConverter") || __EXTERNAL_CONVERTER__ ? { externalConverter: QUERY.get("externalConverter") ?? __EXTERNAL_CONVERTER__! } : {}), +}; + const SANDBOX = { url: `${BASE}frame/index.html`, + options: { conversion: CONVERTER }, origins: { media: [__DEMO_ORIGIN__], connect: [__DEMO_ORIGIN__] }, delivery: DELIVERY, + formats: BUILTIN_ORDER, }; /** `?url=
      `: a file by its address, opened first. */ const LINKED = QUERY.get("url"); +/** `?cad=monochrome`: drawings in ink, as plotted; `?rasterTile=64`: pictures drawn in small tiles (the browser tests). */ +const CAD = { + ...(QUERY.get("cad") === "monochrome" ? { monochrome: true } : {}), + ...(QUERY.get("rasterTile") ? { rasterTile: Number(QUERY.get("rasterTile")) } : {}), +}; + +/** `?rasterTile=64` and `?rasterSupersample=2`: the models' pictures drawn in small tiles, or supersampled (the browser tests). */ +const MODEL = { + ...(QUERY.get("rasterTile") ? { rasterTile: Number(QUERY.get("rasterTile")) } : {}), + ...(QUERY.get("rasterSupersample") ? { rasterSupersample: Number(QUERY.get("rasterSupersample")) } : {}), +}; + /** The engines in the page (`?mode=page`), or each file in a sandboxed frame, the default. */ function Provider({ plugins, locale, children }: { plugins: ReturnType; locale: Locale; children: ReactNode }) { return IN_PAGE ? ( @@ -81,57 +195,90 @@ function initialLocale(): Locale { return navigator.language.toLowerCase().startsWith("fr") ? "fr" : "en"; } +/** Hands the viewer's session to the page around it, and takes it back when the file goes. */ +function SessionOut({ session, onSession }: { session: Session | null; onSession: (s: Session | null) => void }) { + useEffect(() => { + onSession(session); + return () => onSession(null); + }, [session, onSession]); + return null; +} + export function App() { const [locale, setLocale] = useState(initialLocale); const [own, setOwn] = useState([]); // Nothing open until a file is chosen, unless the address names one. const [selected, setSelected] = useState(() => (LINKED ? "linked" : QUERY.get("file"))); const [dragging, setDragging] = useState(false); + // The session of the file on screen, for the host's controls beside its name. + const [session, setSession] = useState(null); const input = useRef(null); + const folder = useRef(null); const counter = useRef(0); const t = TEXT[locale]; // One plugin list for the page: `ViewerProvider` starts a viewer per list. - const plugins = useMemo(() => allPlugins(), []); - // The sidebar's files, from showcase/samples.json (`npm run demo:showcase`); - // none when it was not copied in. + const plugins = useMemo( + () => [ + ...allPlugins({ cad: CAD, ifc: MODEL, model: MODEL }), + ...[rtf(CONVERTER), doc(CONVERTER), odt(CONVERTER), ppt(CONVERTER), odp(CONVERTER), keynote(CONVERTER), xls(CONVERTER), ods(CONVERTER), numbers(CONVERTER)], + ...[odg(CONVERTER), visio(CONVERTER), coreldraw(CONVERTER), publisher(CONVERTER), pagemaker(CONVERTER), freehand(CONVERTER), quarkxpress(CONVERTER), zonerdraw(CONVERTER), wpg(CONVERTER)], + ], + [], + ); + // The sidebar's files, from showcase/samples.json (`npm run demo:showcase`) + // and examples/examples.json (`npm run demo:examples`); none when they were + // not copied in. A file is fetched only when it is selected. const [samples, setSamples] = useState([]); + const [examples, setExamples] = useState([]); useEffect(() => { fetch(`${BASE}showcase/samples.json`) .then((r) => (r.ok ? r.json() : { samples: [] })) .then((j: { samples: Sample[] }) => setSamples(j.samples)) .catch(() => setSamples([])); + fetch(`${BASE}examples/examples.json`) + .then((r) => (r.ok ? r.json() : { examples: [] })) + .then((j: { examples: Example[] }) => setExamples(j.examples)) + .catch(() => setExamples([])); }, []); - const showcase = useMemo( - () => samples.map((s): Item => ({ id: `showcase:${s.file}`, name: s.file, source: { url: `${BASE}showcase/${s.file}` } })), - [samples], - ); + const showcase = useMemo((): Listed[] => { + // An example wins over a sample of the same file name. + const named = new Set(examples.map((e) => e.file)); + return [ + ...samples + .filter((s) => !named.has(s.file)) + .map((s): Listed => ({ group: s.group, note: `${s.credit}. ${s.licence}`, item: { id: `showcase:${s.file}`, name: s.file, source: { url: `${BASE}showcase/${s.file}` } } })), + ...examples.map((e): Listed => ({ group: e.group, note: e.attribution || `${credit(e)}. ${e.licence}`, item: { id: `example:${e.file}`, name: e.file, source: { url: `${BASE}examples/${e.file}` } } })), + ]; + }, [samples, examples]); // Known groups in their order, then any other, in the order they come. - const groups = useMemo(() => [...new Set([...Object.keys(GROUPS), ...samples.map((s) => s.group)])], [samples]); + const groups = useMemo(() => [...new Set([...Object.keys(GROUPS), ...showcase.map((l) => l.group)])], [showcase]); const linked = useMemo((): Item[] => { if (!LINKED) return []; const name = decodeURIComponent(new URL(LINKED, location.href).pathname.split("/").pop() || "file"); return [{ id: "linked", name, source: { url: LINKED } }]; }, []); - const items = useMemo(() => [...linked, ...own, ...showcase], [linked, own, showcase]); + const items = useMemo(() => [...linked, ...own, ...showcase.map((l) => l.item)], [linked, own, showcase]); const current = items.find((i) => i.id === selected) ?? null; + const note = showcase.find((l) => l.item.id === selected)?.note; useEffect(() => { document.documentElement.lang = locale; }, [locale]); const add = useCallback( - (files: FileList | File[]) => { - const picked = Array.from(files); - if (!picked.length) return; + (batch: Picked[]) => { + if (!batch.length) return; // A file given again under the name of one already listed is the same // document with other bytes: it replaces it in place, and the viewer // keeps the zoom and scroll position where it can. const next = [...own]; const added: Item[] = []; const shown: string[] = []; - for (const f of picked) { - const fields = { name: f.name, type: f.type, size: f.size, source: { blob: f } }; + for (const p of batch) { + const f = p.file; + const tree = around(batch, p); + const fields = { name: f.name, type: f.type, size: f.size, source: { blob: f }, ...(tree && { tree }) }; const same = next.findIndex((i) => i.name === f.name); if (same >= 0) { next[same] = { ...next[same]!, ...fields }; @@ -151,12 +298,12 @@ export function App() { const onDrop = (e: DragEvent) => { e.preventDefault(); setDragging(false); - add(e.dataTransfer.files); + void dropped(e.dataTransfer).then(add); }; - const entry = (item: Item) => ( + const entry = (item: Item, note?: string) => (
    1. -
    2. @@ -202,24 +349,37 @@ export function App() { hidden data-testid="file-input" onChange={(e) => { - if (e.target.files) add(e.target.files); + if (e.target.files) add(picked(e.target.files)); + e.target.value = ""; + }} + /> + + { + if (e.target.files) add(picked(e.target.files)); e.target.value = ""; }} /> {own.length > 0 && ( <>

      {t.yours}

      -
        {own.map(entry)}
      +
        {own.map((item) => entry(item))}
      )} {groups.map((group) => { - // `showcase` follows `samples`' order. - const list = showcase.filter((_, i) => samples[i]!.group === group); + const list = showcase.filter((l) => l.group === group); return ( list.length > 0 && (

      {GROUPS[group]?.[locale] ?? group}

      -
        {list.map(entry)}
      +
        {list.map((l) => entry(l.item, l.note))}
      ) ); @@ -229,12 +389,20 @@ export function App() { {current ? ( <>
      -

      - {current.name} -

      +
      +

      {current.name}

      + +
      + {note && ( + + {note} + + )}
      - + + {({ session }) => } +
      ) : ( diff --git a/crates/exav-viewer/demo/src/ExportImage.tsx b/crates/exav-viewer/demo/src/ExportImage.tsx new file mode 100644 index 00000000..26841ee2 --- /dev/null +++ b/crates/exav-viewer/demo/src/ExportImage.tsx @@ -0,0 +1,184 @@ +/** + * "Export image": the session's pictures (`controllers.pictures`) saved as a + * file, for any format that draws something. A button beside the file's name, + * as it belongs to the host and not to the viewer, opening a dialog of + * options. The region is the whole page or what the viewer shows when the + * dialog opens (`controllers.viewport`). `window.exavDemo` is for the + * browser tests. + */ +import { useEffect, useId, useRef, useState } from "react"; + +import { RasterError, type Raster, type RasterOptions, type RasterType, type Session } from "@exav/viewer"; +import { useStore } from "@exav/viewer/react"; + +type Result = { url: string; name: string; info: string } | { error: string }; + +/** The session of the file on screen, or of the member of an archive that is open in it. */ +function Export({ session, base }: { session: Session; base: string }) { + const controllers = useStore(session.controllers); + const opened = useStore(controllers?.archive)?.opened; + const pictures = controllers?.pictures; + const count = useStore(pictures)?.count ?? 0; + const space = useStore(pictures)?.space; + const [page, setPage] = useState(1); + const [longEdge, setLongEdge] = useState(1600); + const [region, setRegion] = useState<"whole" | "current">("whole"); + const dialog = useRef(null); + const titleId = useId(); + const [open, setOpen] = useState(false); + // Working out the viewport costs: it is followed only while the dialog shows "What I see". + const viewport = useStore(open && region === "current" ? controllers?.viewport : undefined); + const [type, setType] = useState("png"); + const [background, setBackground] = useState<"default" | "transparent" | "white">("default"); + const [fit, setFit] = useState<"contain" | "cover">("contain"); + const [lineweight, setLineweight] = useState<"screen" | "scaled">("screen"); + const [busy, setBusy] = useState(null); + const [result, setResult] = useState(null); + useEffect(() => () => void (result && "url" in result && URL.revokeObjectURL(result.url)), [result]); + // The page in view follows the viewer when the region is what it shows. + const shown = region === "current" && viewport ? viewport.page : page; + + if (opened) return ; + if (!pictures || count < 1) return null; + + const run = async () => { + const abort = new AbortController(); + setBusy(abort); + setResult(null); + const options: RasterOptions & { type: RasterType } = { + longEdge, + fit, + type, + signal: abort.signal, + ...(region === "current" && { region: "current" as const }), + ...(background !== "default" && { background: background === "white" ? "#ffffff" : "transparent" }), + ...(space === "drawing" && { lineweight }), + ...(region === "current" && viewport?.layout !== undefined && { layout: viewport.layout }), + }; + try { + const { blob, ...raster } = await pictures.toBlob(shown, options); + const info = describe(raster); + setResult({ url: URL.createObjectURL(blob), name: `${base}-${shown}.${type === "jpeg" ? "jpg" : type}`, info }); + } catch (error) { + setResult({ error: error instanceof RasterError ? `${error.code}: ${error.message}` : String(error) }); + } finally { + setBusy(null); + } + }; + + return ( + <> + + setOpen(false)} + onClick={(e) => e.target === e.currentTarget && dialog.current?.close()} + > +
      +

      Export image

      + +
      +
      + + + + + + + {space === "drawing" && ( + + )} + {busy ? ( + + ) : ( + + )} + {result && + ("error" in result ? ( +

      + {result.error} +

      + ) : ( +

      + + {result.name} + {" "} + {result.info} +

      + ))} +
      +
      + + ); +} + +function describe(r: Omit): string { + const region = r.region ? ` of ${JSON.stringify(r.region, (_, v) => (typeof v === "number" ? Math.round(v * 1000) / 1000 : v))}` : ""; + return `${r.width} x ${r.height} px, page ${r.page}${r.layout !== undefined ? ` (${r.layout})` : ""}${region}`; +} + +export function ExportImage({ session, name }: { session: Session | null; name: string }) { + useEffect(() => { + Object.assign(window, { exavDemo: { session: () => session } }); + }, [session]); + return session ? : null; +} diff --git a/crates/exav-viewer/demo/src/demo.css b/crates/exav-viewer/demo/src/demo.css index 69f46a63..bd801da9 100644 --- a/crates/exav-viewer/demo/src/demo.css +++ b/crates/exav-viewer/demo/src/demo.css @@ -19,18 +19,18 @@ --demo-selected: #134e4a; color-scheme: dark; } - .demo .exv-body, - .exv-dialog { - --exv-surface: #1e293b; - --exv-panel: #111827; - --exv-border: #334155; - --exv-text: #cbd5e1; - --exv-text-strong: #f8fafc; - --exv-muted: #94a3b8; - --exv-hover: #1f2937; - --exv-active: #334155; - --exv-accent: #5eead4; - --exv-on-accent: #0f172a; + .demo .exav-viewer-body, + .exav-viewer-dialog { + --exav-viewer-surface: #1e293b; + --exav-viewer-panel: #111827; + --exav-viewer-border: #334155; + --exav-viewer-text: #cbd5e1; + --exav-viewer-text-strong: #f8fafc; + --exav-viewer-muted: #94a3b8; + --exav-viewer-hover: #1f2937; + --exav-viewer-active: #334155; + --exav-viewer-accent: #5eead4; + --exav-viewer-on-accent: #0f172a; } } @@ -156,12 +156,23 @@ body { padding: 0.5rem 1rem; } .demo-bar h1 { + min-width: 0; margin: 0; overflow: hidden; font-size: 1.125rem; text-overflow: ellipsis; white-space: nowrap; } +.demo-credit { + min-width: 0; + overflow: hidden; + color: var(--demo-muted); + font-size: 0.75rem; + text-align: right; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} .demo-frame { flex: 1; min-height: 0; @@ -170,6 +181,83 @@ body { border: 1px solid var(--demo-border); border-radius: 0.5rem; } +.demo-title { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} +.demo-export-open { + flex: none; + padding: 0.25rem 0.75rem; + border: 1px solid var(--demo-border); + border-radius: 0.375rem; + background: var(--demo-panel); + color: var(--demo-text); + font: inherit; + font-size: 0.8125rem; + cursor: pointer; +} +.demo-export-open:hover, +.demo-export-open:focus-visible { + border-color: var(--demo-accent); +} +.demo-export { + width: 36rem; + max-width: calc(100% - 32px); + padding: 0; + border: 1px solid var(--demo-border); + border-radius: 0.5rem; + background: var(--demo-panel); + color: var(--demo-text); + font-size: 0.8125rem; +} +.demo-export::backdrop { + background: rgb(15 23 42 / 0.45); +} +.demo-export-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.5rem 0.5rem 1rem; + border-bottom: 1px solid var(--demo-border); +} +.demo-export-head h2 { + margin: 0; + font-size: 1rem; +} +.demo-export-close { + width: 2rem; + height: 2rem; + border: 0; + border-radius: 0.375rem; + background: transparent; + color: var(--demo-muted); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; +} +.demo-export-close:hover, +.demo-export-close:focus-visible { + color: var(--demo-text); +} +.demo-export-body { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: 0.75rem; + max-height: 70vh; + padding: 1rem; + overflow: auto; +} +.demo-export-body label { + display: grid; + gap: 0.125rem; +} +.demo-export-body p { + grid-column: 1 / -1; + margin: 0; + overflow-wrap: anywhere; +} .demo-upload { display: flex; flex: 1; diff --git a/crates/exav-viewer/demo/src/headless.css b/crates/exav-viewer/demo/src/headless.css new file mode 100644 index 00000000..947659eb --- /dev/null +++ b/crates/exav-viewer/demo/src/headless.css @@ -0,0 +1,110 @@ +/* The page's own look: the frame is given these values (`theme: "inherit"`). */ +:root { + color-scheme: light; + --page-bg: #fbfaf7; + --page-text: #2b2a27; + --exav-viewer-surface: #ece8df; + --exav-viewer-panel: #fffdf8; + --exav-viewer-text: #3d3a33; + --exav-viewer-text-strong: #1d1b17; + --exav-viewer-border: #d9d2c3; + --exav-viewer-accent: #9a3412; + --exav-viewer-pager-room: 1rem; + --exav-viewer-font: Georgia, serif; +} +:root.dark { + color-scheme: dark; + --page-bg: #17161a; + --page-text: #e8e6ef; + --exav-viewer-surface: #212026; + --exav-viewer-panel: #2a2930; + --exav-viewer-text: #d6d3df; + --exav-viewer-text-strong: #f4f2fa; + --exav-viewer-border: #3b3944; + --exav-viewer-accent: #f59e0b; +} +body { + margin: 0; + padding: 1rem; + background: var(--page-bg); + color: var(--page-text); + font: 0.9375rem/1.5 system-ui, sans-serif; +} +h1 { + margin: 0; + font-size: 1.25rem; +} +main { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; + margin-top: 1rem; +} +#thumbs { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 75vh; + overflow-y: auto; +} +#thumbs:empty { + display: none; +} +#thumbs button { + padding: 0; + border: 1px solid var(--exav-viewer-border); + background: white; + cursor: pointer; +} +#thumbs canvas { + display: block; + max-width: 80px; + height: auto; +} +#viewer { + display: flex; + flex-direction: column; + min-width: 0; + background: var(--exav-viewer-surface); +} +#stage { + position: relative; + height: 65vh; +} +#viewer:fullscreen #stage { + height: auto; + flex: 1; +} +#sheets { + display: flex; + gap: 0.25rem; + overflow-x: auto; +} +#sheets button[aria-selected="true"] { + border-bottom: 2px solid var(--exav-viewer-accent); + font-weight: 600; +} +#controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 0.5rem; + background: var(--exav-viewer-panel); +} +#controls .group { + display: flex; + align-items: center; + gap: 0.25rem; +} +#controls input[type="number"] { + width: 4rem; +} +.members { + margin: 0; + padding: 0; + list-style: none; +} +#keys { + font-size: 0.8125rem; + opacity: 0.7; +} diff --git a/crates/exav-viewer/demo/src/headless.ts b/crates/exav-viewer/demo/src/headless.ts new file mode 100644 index 00000000..d27ed05b --- /dev/null +++ b/crates/exav-viewer/demo/src/headless.ts @@ -0,0 +1,234 @@ +/** + * A host that draws its own UI: each file in the sandboxed frame, and every + * control on this page, made from the session's controllers. The frame + * draws the document alone (the player's controls too are this page's: + * `media.controls: "none"`), takes this page's colours (`theme`, read from + * its CSS custom properties) and hands back the keys the document does not + * use. `window.headless` is for the browser tests. + */ +import "./headless.css"; + +import { builtinTranslate, printPictures, type Controllers, type MessageKey, type Session, type Status, type Store } from "@exav/viewer"; +import { createSandboxedViewer } from "@exav/viewer/frame"; + +declare const __DEMO_ORIGIN__: string; + +const BASE = import.meta.env.BASE_URL; +const t = builtinTranslate(new URLSearchParams(location.search).get("lang") ?? navigator.language); + +const viewer = createSandboxedViewer({ + url: `${BASE}frame/index.html`, + origins: { media: [__DEMO_ORIGIN__], connect: [__DEMO_ORIGIN__] }, + options: { media: { controls: "none" } }, +}); + +const $ = (id: string) => document.getElementById(id)!; +const stage = $("stage"); +const controls = $("controls"); +const sheetTabs = $("sheets"); +const thumbs = $("thumbs"); +const statusLine = $("status"); + +function el(tag: K, props: Partial = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] { + const e = Object.assign(document.createElement(tag), props); + e.append(...children); + return e; +} + +const button = (label: string, onclick: () => void, title = label) => el("button", { type: "button", textContent: label, title, onclick }); + +/** Calls `draw` with the store's value now and on each change; returns the unsubscribe. */ +function follow(store: Store | undefined, draw: (value: T) => void): () => void { + if (!store) return () => {}; + draw(store.get()); + return store.subscribe(draw); +} + +function statusText(s: Status): string { + switch (s.phase) { + case "loading": + case "converting": + return t("loading"); + case "error": + return t(s.error.code === "pdf" ? "error" : (`error_${s.error.code}` as MessageKey)); + case "empty": + return t("empty_model"); + default: + return ""; + } +} + +const seconds = (s: number) => (Number.isFinite(s) ? `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}` : "∞"); + +let session: Session | null = null; +/** What follows the file on screen, dropped on each redraw. */ +let widgets: (() => void)[] = []; +let thumbsAbort: AbortController | null = null; + +const clearWidgets = () => { + for (const stop of widgets) stop(); + widgets = []; + thumbsAbort?.abort(); + controls.replaceChildren(); + sheetTabs.replaceChildren(); + thumbs.replaceChildren(); +}; + +/** + * The controls of the session shown: an archive's open member, down as many + * levels as are open, drawn again when anything on the way changes. + */ +function render(root: Session) { + clearWidgets(); + let scheduled = false; + const redraw = () => { + if (scheduled) return; + scheduled = true; + queueMicrotask(() => session === root && render(root)); + }; + let shown = root; + let back: Controllers["archive"] | null = null; + for (;;) { + const c = shown.controllers.get(); + widgets.push(shown.controllers.subscribe(redraw)); + const opened = c.archive?.get().opened; + if (c.archive) { + // Redrawn when a member opens or closes, not on each extraction step. + const was = opened; + widgets.push(c.archive.subscribe((a) => a.opened !== was && redraw())); + } + if (!opened) break; + back = c.archive!; + shown = opened.session; + } + widgets.push(follow(shown.status, (status) => (statusLine.textContent = statusText(status)))); + draw(shown.controllers.get(), back); +} + +function draw(c: Controllers, archiveOf: Controllers["archive"] | null) { + const bar = (...children: (Node | string)[]) => controls.append(el("div", { className: "group" }, ...children)); + if (archiveOf) bar(button(t("archive_back"), () => archiveOf.back())); + if (c.archive) { + const list = el("ul", { className: "members" }); + widgets.push( + follow(c.archive, (a) => + list.replaceChildren( + ...a.members.map((m) => el("li", {}, button(m.name, () => void c.archive!.open(m.index)))), + ...(a.refused ? [el("li", { textContent: t(a.refused.key) })] : []), + ), + ), + ); + bar(list); + } + if (c.pages) { + const pages = c.pages; + const field = el("input", { type: "number", min: "1", ariaLabel: t("page_short", { page: "" }).trim() }); + const total = el("span"); + field.onchange = () => pages.goTo?.(Number(field.value)); + widgets.push( + follow(pages, (p) => { + if (document.activeElement !== field) field.value = String(p?.current ?? ""); + total.textContent = `/ ${p?.total ?? "…"}`; + }), + ); + bar(...(pages.prev ? [button("‹", () => void pages.prev!(), t("prev"))] : []), field, total, ...(pages.next ? [button("›", () => void pages.next!(), t("next"))] : [])); + } + if (c.zoom) { + const zoom = c.zoom; + const label = el("span", { className: "zoom" }); + widgets.push(follow(zoom, (z) => (label.textContent = `${Math.round(z.scale * 100)} %`))); + bar(button("−", () => zoom.setScale(zoom.get().scale / 1.25), t("zoom_out")), label, button("+", () => zoom.setScale(zoom.get().scale * 1.25), t("zoom_in")), button("⤢", () => zoom.fit(), t("zoom_fit"))); + } + if (c.media) { + const media = c.media; + const play = button("▶", () => void (media.get().paused ? media.play() : media.pause())); + play.dataset.testid = "play"; + const time = el("input", { type: "range", min: "0", step: "0.1", ariaLabel: "Time" }); + const clock = el("span", { className: "clock" }); + const mute = button("🔊", () => media.setMuted(!media.get().muted)); + time.oninput = () => media.seek(Number(time.value)); + widgets.push( + follow(media, (m) => { + play.textContent = m.paused ? "▶" : "�"; + time.max = String(Number.isFinite(m.duration) ? m.duration : 0); + time.value = String(m.currentTime); + clock.textContent = `${seconds(m.currentTime)} / ${seconds(m.duration)}`; + mute.textContent = m.muted ? "🔇" : "🔊"; + }), + ); + // Fullscreen is the page's: the frame may not, the element holding it may. + bar(play, time, clock, mute, button("⛶", () => void $("viewer").requestFullscreen())); + } + if (c.sheets) { + const sheets = c.sheets; + widgets.push( + follow(sheets, (s) => + sheetTabs.replaceChildren( + ...s.items.map((sheet) => { + const tab = button(sheet.name, () => void sheets.select(sheet.index)); + tab.role = "tab"; + tab.ariaSelected = String(sheet.index === s.current); + return tab; + }), + ), + ), + ); + } + if (c.pictures) { + const pictures = c.pictures; + bar(button("⎙", () => void printPictures(pictures), "Print")); + drawThumbs(pictures, c.pages); + } +} + +/** A picture of each page (the first 50), drawn one after the other, a click going to it. */ +function drawThumbs(pictures: NonNullable, pages: Controllers["pages"]) { + const abort = new AbortController(); + thumbsAbort = abort; + const count = Math.min(pictures.get().count, 50); + if (count < 2) return; + void (async () => { + for (let page = 1; page <= count && !abort.signal.aborted; page++) { + const bitmap = await pictures.render(page, 160 * Math.min(devicePixelRatio || 1, 2)); + if (!bitmap) continue; + if (abort.signal.aborted) { + bitmap.close(); + return; + } + const canvas = el("canvas", { width: bitmap.width, height: bitmap.height }); + canvas.getContext("bitmaprenderer")?.transferFromImageBitmap(bitmap); + thumbs.append(el("button", { type: "button", title: t("page_short", { page }), onclick: () => pages?.goTo?.(page) }, canvas)); + } + })(); +} + +function showFile(file: File) { + session?.destroy(); + clearWidgets(); + session = viewer.mount(stage, { id: `${file.name}:${file.size}:${file.lastModified}`, name: file.name, type: file.type, source: { blob: file } }); + render(session); +} + +$("scheme").onclick = () => { + const dark = document.documentElement.classList.toggle("dark"); + $("scheme").textContent = dark ? "Light" : "Dark"; + viewer.restyle(); +}; + +(document.querySelector('[data-testid="file-input"]') as HTMLInputElement).onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) showFile(file); +}; + +// The keys the frame hands back reach this page's listeners as the page's own. +document.addEventListener("keydown", (e) => { + $("keys").append(el("li", { textContent: `${e.key}${e.isTrusted ? "" : " (from the frame)"}` })); + if (e.key === "Escape" && session) { + session.destroy(); + session = null; + clearWidgets(); + statusLine.textContent = ""; + } +}); + +Object.assign(window, { headless: { viewer, session: () => session } }); diff --git a/crates/exav-viewer/demo/src/showcase.ts b/crates/exav-viewer/demo/src/showcase.ts index 7f186a73..9e391095 100644 --- a/crates/exav-viewer/demo/src/showcase.ts +++ b/crates/exav-viewer/demo/src/showcase.ts @@ -1,14 +1,22 @@ /** * The files the demo lists: demo/public/showcase/, copied from * github.com/sylvinus/exav-samples by `npm run demo:showcase`, and described - * by its samples.json. The browser tests use the generated files of - * e2e/.out/samples/ instead (e2e/fixtures/make-samples.mjs). + * by its samples.json; and demo/public/examples/, put there by `npm run + * demo:examples` and described by examples.json (demo/examples/). When both + * have a file of the same name, the example is listed and the sample is not. + * The browser tests use the generated files of e2e/.out/samples/ instead + * (e2e/fixtures/make-samples.mjs). */ /** The sidebar's sections, in this order; a group not named here comes last, under its own name. */ export const GROUPS: Record = { documents: { en: "Documents", fr: "Documents" }, + pdf: { en: "PDF", fr: "PDF" }, + office: { en: "Word, Excel, PowerPoint", fr: "Word, Excel, PowerPoint" }, + converted: { en: "Other office formats", fr: "Autres formats bureautiques" }, + text: { en: "Text and code", fr: "Texte et code" }, images: { en: "Images", fr: "Images" }, + raw: { en: "Camera RAW", fr: "Photos RAW" }, cad: { en: "CAD", fr: "CAO" }, "3d": { en: "3D", fr: "3D" }, media: { en: "Audio and video", fr: "Audio et vidéo" }, @@ -29,3 +37,24 @@ export interface Sample { bytes: number; sha256: string; } + +/** One entry of examples.json (demo/examples/): the facts the demo shows, a few of those it keeps. */ +export interface Example { + file: string; + group: string; + family: string; + title: string; + author: string; + source: string; + /** The SPDX id, or "public-domain (reason)". */ + licence: string; + /** The line CC-BY, MIT, BSD and Apache ask to keep; "" for the others. */ + attribution: string; + bytes: number; + sha256: string; +} + +/** Who made a file and what it is, one line (the line the licence asks to keep is `attribution`). */ +export function credit(e: Example): string { + return `${e.author}: ${e.title}`; +} diff --git a/crates/exav-viewer/demo/vite.config.ts b/crates/exav-viewer/demo/vite.config.ts index 8eca8e07..ca2d43c2 100644 --- a/crates/exav-viewer/demo/vite.config.ts +++ b/crates/exav-viewer/demo/vite.config.ts @@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url"; import { defineConfig, type Plugin } from "vite"; import { exavViewer } from "../dist/vite/index.js"; -import type { Sample } from "./src/showcase.js"; +import type { Example, Sample } from "./src/showcase.js"; const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8")); @@ -29,6 +29,14 @@ const nodeModules = fs.realpathSync(path.join(pkgDir, "node_modules")); // A `file:` dependency resolves outside node_modules (to its crate), and the // dev server serves its worker and wasm from there. const unpackWasm = fs.realpathSync(path.join(nodeModules, "@exav", "unpack-wasm")); +/** + * An external converter for the formats the package's own converters do not + * read: the name of an installed package, from `EXAV_EXTERNAL_CONVERTER` when + * the demo is built. None by default. + */ +const externalConverter = process.env.EXAV_EXTERNAL_CONVERTER || undefined; +// Its files are served from where it is installed (a `file:` dependency lives outside node_modules). +const externalDir = externalConverter && fs.existsSync(path.join(nodeModules, externalConverter)) ? fs.realpathSync(path.join(nodeModules, externalConverter)) : undefined; /** The packages the demo bundles or copies, whose licences travel with it. */ const ENGINES = ["pdfjs-dist", "three", "@silurus/ooxml", "@exav/unpack-wasm", "react", "react-dom"]; @@ -58,12 +66,25 @@ function licenceFiles(): Map { "Of pdfjs-dist's wasm/, only its ICC engine (qcms, MIT; LICENSE_QCMS beside it under exav-viewer/pdfjs/) is in the build.", "Its OpenJPEG and PDFium image decoders are not: @exav/viewer's own decoders take their place (see its NOTICE).", ); - // The samples copied in by `npm run demo:showcase`, if they were. + // The example files copied in by `npm run demo:examples`, if they were (an + // example replaces a sample of the same file name in the list), and the samples + // copied in by `npm run demo:showcase`. + const examplesList = path.join(pkgDir, "demo", "public", "examples", "examples.json"); + const examples = fs.existsSync(examplesList) ? (JSON.parse(fs.readFileSync(examplesList, "utf8")) as { examples: Example[] }).examples : []; + if (examples.length) { + index.push("", "The example files, under examples/ (the full list is examples-NOTICE.md; one that has the name of a sample replaces it):"); + for (const e of examples) { + index.push("", e.file, ` ${e.author}: ${e.title}`, ` ${e.source}`, ` ${e.licence}`); + if (e.attribution) index.push(` ${e.attribution}`); + } + add("examples-NOTICE.md", path.join(pkgDir, "demo", "examples", "NOTICE.md")); + } const list = path.join(pkgDir, "demo", "public", "showcase", "samples.json"); if (fs.existsSync(list)) { const { samples } = JSON.parse(fs.readFileSync(list, "utf8")) as { samples: Sample[] }; + const replaced = new Set(examples.map((e) => e.file)); index.push("", "The sample files, under showcase/, from https://github.com/sylvinus/exav-samples:"); - for (const s of samples) index.push("", s.file, ` ${s.credit}`, ` ${s.source}`, ` ${s.licence}`); + for (const s of samples) index.push("", s.file, ` ${s.credit}`, ` ${s.source}`, ` ${s.licence}`, ...(replaced.has(s.file) ? [" Not listed: an example of the same name replaces it."] : [])); } files.set("README.txt", Buffer.from(`${index.join("\n")}\n`)); return files; @@ -102,7 +123,7 @@ function licences(): Plugin { */ function e2eFiles(): Plugin { const dir = path.join(pkgDir, "e2e", ".out"); - const TYPES: Record = { ".pdf": "application/pdf", ".zip": "application/zip", ".webm": "video/webm", ".png": "image/png" }; + const TYPES: Record = { ".pdf": "application/pdf", ".zip": "application/zip", ".webm": "video/webm", ".png": "image/png", ".js": "text/javascript" }; // A third above clip.webm's bitrate (10 MB for 13 s): it plays without stalling. const PACE = 1024 * 1024; const send = (file: string, res: ServerResponse, paced: boolean, range?: { start: number; end: number }) => { @@ -167,11 +188,12 @@ export default defineConfig({ base: process.env.DEMO_BASE ?? "/", resolve: { alias }, esbuild: { jsx: "automatic" }, - define: { __DEMO_ORIGIN__: JSON.stringify(origin) }, + define: { __DEMO_ORIGIN__: JSON.stringify(origin), __EXTERNAL_CONVERTER__: JSON.stringify(externalConverter ?? null) }, // The sandboxed frame at `${base}frame/`, served with its headers by `vite // preview`. It may play the demo's own media by URL and fetch from it. - plugins: [exavViewer({ frameDir: "frame", frameOrigin: origin, mediaOrigins: [origin], connectOrigins: [origin] }), licences(), e2eFiles()], + plugins: [exavViewer({ frameDir: "frame", frameOrigin: origin, mediaOrigins: [origin], connectOrigins: [origin], externalConverter }), licences(), e2eFiles()], // Beside the docs site's 4321. - server: { port: 4322, strictPort: true, host: true, fs: { allow: [pkgDir, nodeModules, unpackWasm] } }, - build: { outDir: "dist", emptyOutDir: true, target: "es2022" }, + server: { port: 4322, strictPort: true, host: true, fs: { allow: [pkgDir, nodeModules, unpackWasm, ...(externalDir ? [externalDir] : [])] } }, + // headless.html: a host drawing its own controls, without React. + build: { outDir: "dist", emptyOutDir: true, target: "es2022", rollupOptions: { input: { main: path.join(pkgDir, "demo", "index.html"), headless: path.join(pkgDir, "demo", "headless.html") } } }, }); diff --git a/crates/exav-viewer/e2e/cad-raster.spec.ts b/crates/exav-viewer/e2e/cad-raster.spec.ts new file mode 100644 index 00000000..779a64f8 --- /dev/null +++ b/crates/exav-viewer/e2e/cad-raster.spec.ts @@ -0,0 +1,684 @@ +// Pictures of DWG and DXF drawings (`controllers.pictures`) and the viewport +// store: drawn again from the vectors in a context of their own, at the asked +// size and region, whatever the viewer shows. In the page (`mode=page`), where +// `rasterTile=64` forces tiles; the sandboxed frame is the last test. +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { failOnViolations } from "./csp.js"; +import { decodePng, difference, find, type Pixels } from "./pixels.js"; +import { openSample, sample } from "./samples.js"; + +failOnViolations(); + +interface Info { + width: number; + height: number; + page: number; + layout?: string; + space: string; + region: { x: number; y: number; width: number; height: number }; +} +interface Picture extends Pixels { + info: Info; +} + +type Options = Record; + +/** `pictures.raster(page, options)` in the page, its bitmap read back as pixels. */ +async function raster(page: Page, n: number, options: Options): Promise { + const out = await page.evaluate( + async ([n, options]) => { + const session = (window as unknown as { exavDemo: { session(): { controllers: { get(): { pictures: { raster(n: number, o: object): Promise<{ bitmap: ImageBitmap } & Record> } } } } } }).exavDemo.session(); + const r = await session.controllers.get().pictures.raster(n, options as object); + const canvas = new OffscreenCanvas(r.bitmap.width, r.bitmap.height); + canvas.getContext("2d")!.drawImage(r.bitmap, 0, 0); + r.bitmap.close(); + const bytes = new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer()); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + const { bitmap: _bitmap, ...info } = r; + return { png: btoa(binary), info }; + }, + [n, options] as const, + ); + return { ...decodePng(Buffer.from(out.png, "base64")), info: out.info as unknown as Info }; +} + +/** The code of the RasterError a raster is refused with. */ +async function refused(page: Page, n: number, options: Options): Promise { + return page.evaluate( + async ([n, options]) => { + const session = (window as unknown as { exavDemo: { session(): { controllers: { get(): { pictures: { raster(n: number, o: object): Promise } } } } } }).exavDemo.session(); + try { + await session.controllers.get().pictures.raster(n, options as object); + return "none"; + } catch (e) { + return (e as { code?: string }).code ?? String(e); + } + }, + [n, options] as const, + ); +} + +interface View { + space: string; + page: number; + layout?: string; + region: { x: number; y: number; width: number; height: number }; +} +const viewport = (page: Page) => + page.evaluate(() => { + const s = (window as unknown as { exavDemo: { session(): { controllers: { get(): { viewport: { get(): unknown } } } } } }).exavDemo.session(); + return s.controllers.get().viewport.get() as View | null; + }); + +async function shot(target: Locator): Promise { + const tools = target.page().locator(".exav-viewer-tools"); + await tools.evaluateAll((els) => els.forEach((e) => ((e as HTMLElement).style.visibility = "hidden"))); + try { + return decodePng(await target.screenshot({ animations: "disabled" })); + } finally { + await tools.evaluateAll((els) => els.forEach((e) => ((e as HTMLElement).style.visibility = ""))); + } +} + +/** Two screenshots in a row that agree: the canvas has settled. */ +async function settled(target: Locator): Promise { + // A zoom scales the canvas as drawn until it pauses: its box is not its own. + await expect.poll(() => target.evaluate((e) => (e as HTMLElement).style.transform)).toBe(""); + let last = await shot(target); + for (let i = 0; i < 20; i++) { + await target.page().waitForTimeout(150); + const next = await shot(target); + if (difference(last, next) === 0) return next; + last = next; + } + return last; +} + +async function open(page: Page, name: string, params: Record = {}): Promise { + await openSample(page, name, { mode: "page", ...params }); + await expect(page.locator(".demo-frame .exav-viewer-body")).toHaveAttribute("data-phase", "ready"); + await expect.poll(() => viewport(page)).not.toBeNull(); + const canvas = page.locator(".demo-frame .exav-viewer-cad-canvas"); + // A whole number of pixels, so that a screenshot of the canvas is its own pixels. + await page.locator(".demo-frame .exav-viewer-cad").evaluate((e) => Object.assign(e.style, { position: "fixed", left: "0px", top: "0px", zIndex: "10", flex: "none", width: "700px", height: "500px" })); + await expect.poll(() => canvas.evaluate((c: HTMLCanvasElement) => [c.width, c.height])).toEqual([700, 500]); + return canvas; +} + +/** The page a layout is: its place in the layouts controller, model space first. */ +const pageOf = (page: Page, id: string) => + page.evaluate((id) => { + const s = (window as unknown as { exavDemo: { session(): { controllers: { get(): { layouts: { get(): { items: { id: string }[] } } } } } } }).exavDemo.session(); + return s.controllers.get().layouts.get().items.findIndex((l) => l.id === id) + 1; + }, id); + +async function layout(page: Page, name: string): Promise { + // By the DOM: the canvas is fixed over the corner of the page (see `open`). + await page.locator(".exav-viewer-layouts").getByRole("button", { name }).evaluate((b) => (b as HTMLElement).click()); + await expect(page.locator(".demo-frame .exav-viewer-body")).toHaveAttribute("data-phase", "ready"); + await expect.poll(async () => (await viewport(page))?.layout).toBe(name); +} + +const at = (p: Pixels, x: number, y: number): [number, number, number, number] => { + const i = (y * p.width + x) * 4; + return [p.data[i]!, p.data[i + 1]!, p.data[i + 2]!, p.data[i + 3]!]; +}; +const near = (a: readonly number[], b: readonly number[], tolerance = 24) => a.every((v, i) => Math.abs(v - b[i]!) <= tolerance); +const ink = (r: number, g: number, b: number) => r < 160 && g < 160 && b < 160; +/** The largest difference of any channel of any pixel. */ +function worst(a: Pixels, b: Pixels): number { + expect([a.width, a.height]).toEqual([b.width, b.height]); + let m = 0; + for (let i = 0; i < a.data.length; i++) m = Math.max(m, Math.abs(a.data[i]! - b.data[i]!)); + return m; +} +/** Rows from `from` to `to` (inclusive) of column `x` that `test` accepts. */ +function column(p: Pixels, x: number, test: (r: number, g: number, b: number) => boolean): number[] { + const rows: number[] = []; + for (let y = 0; y < p.height; y++) { + const [r, g, b] = at(p, x, y); + if (test(r, g, b)) rows.push(y); + } + return rows; +} +function row(p: Pixels, y: number, test: (r: number, g: number, b: number) => boolean): number[] { + const cols: number[] = []; + for (let x = 0; x < p.width; x++) { + const [r, g, b] = at(p, x, y); + if (test(r, g, b)) cols.push(x); + } + return cols; +} + +const red = (r: number, g: number, b: number) => r > 200 && g < 80 && b < 80; +const cyan = (r: number, g: number, b: number) => r < 80 && g > 200 && b > 200; +const notWhite = (r: number, g: number, b: number) => r < 250 || g < 250 || b < 250; + +test.describe("a region of the drawing", () => { + // colours.dxf: a yellow line (y 0), a red one (y 20), both 100 long and 2.11 mm + // wide, and a cyan band from y 40 to 60, all from x 0 to 100. + test("is drawn where its drawing units are: x to the right, y up", async ({ page }) => { + await open(page, "cad-colours.dxf"); + // 200 x 50 units in 800 x 200 px: 4 px a unit, x from -50, y from 15 to 65. + const p = await raster(page, 1, { region: { x: -50, y: 15, width: 200, height: 50 }, width: 800, height: 200 }); + expect(p.info).toMatchObject({ width: 800, height: 200, page: 1, layout: "", space: "drawing" }); + expect(p.info.region).toMatchObject({ x: -50, y: 15, width: 200, height: 50 }); + // The band, from y 60 (row 20) to 40 (row 100), across x 0..100 (columns 200..600). + const band = find(p, cyan).box!; + expect(band.minX).toBeGreaterThanOrEqual(198); + expect(band.maxX).toBeLessThanOrEqual(602); + expect(Math.abs(band.minY - 20)).toBeLessThanOrEqual(2); + expect(Math.abs(band.maxY - 100)).toBeLessThanOrEqual(2); + // The red line at y 20: row 180, below the band. Round caps reach 4 px past its ends. + const line = find(p, red).box!; + expect(Math.abs((line.minY + line.maxY) / 2 - 180)).toBeLessThanOrEqual(2); + expect(Math.abs(line.minX - 196)).toBeLessThanOrEqual(3); + expect(Math.abs(line.maxX - 604)).toBeLessThanOrEqual(3); + // The paper outside is the ground. + expect(near(at(p, 20, 100), [255, 255, 255, 255], 2)).toBe(true); + expect(near(at(p, 700, 20), [255, 255, 255, 255], 2)).toBe(true); + }); + + test("is widened to the picture's shape (contain) or narrowed to it (cover), and reported as drawn", async ({ page }) => { + await open(page, "cad-colours.dxf"); + const region = { x: 0, y: 40, width: 100, height: 20 }; + const wide = await raster(page, 1, { region, width: 400, height: 400 }); + expect(wide.info.region.width).toBeCloseTo(100, 6); + expect(wide.info.region.height).toBeCloseTo(100, 6); + expect(wide.info.region.x).toBeCloseTo(0, 6); + expect(wide.info.region.y).toBeCloseTo(0, 6); + // The band is in the middle of the picture, a fifth of its height. + const band = find(wide, cyan).box!; + expect(Math.abs(band.minY - 160)).toBeLessThanOrEqual(2); + expect(Math.abs(band.maxY - 240)).toBeLessThanOrEqual(2); + const cover = await raster(page, 1, { region, width: 400, height: 400, fit: "cover" }); + expect(cover.info.region.width).toBeCloseTo(20, 6); + expect(cover.info.region.height).toBeCloseTo(20, 6); + expect(cover.info.region.x).toBeCloseTo(40, 6); + // Cover: the band fills the picture. + expect(find(cover, cyan).count).toBe(400 * 400); + }); + + test("takes its shape from a longEdge alone, and refuses what is no size or region", async ({ page }) => { + await open(page, "cad-colours.dxf"); + const p = await raster(page, 1, { region: { x: 0, y: 0, width: 100, height: 25 }, longEdge: 200 }); + expect([p.width, p.height]).toEqual([200, 50]); + expect(p.info.region.width / p.info.region.height).toBeCloseTo(4, 2); + expect(await refused(page, 1, { longEdge: 0 })).toBe("invalid"); + expect(await refused(page, 1, { longEdge: 100, region: { x: 0, y: 0, width: 0, height: 5 } })).toBe("invalid"); + expect(await refused(page, 1, { longEdge: 100, lineweight: "heavy" })).toBe("invalid"); + expect(await refused(page, 1, { longEdge: 100, background: "url(x)" })).toBe("invalid"); + expect(await refused(page, 1, { width: 20000, height: 20000 })).toBe("too-large"); + expect(await refused(page, 99, { longEdge: 100 })).toBe("invalid"); + expect(await refused(page, 1, { longEdge: 100, layout: "Nope" })).toBe("invalid"); + // So close that the f32 coordinates cannot place it. + expect(await refused(page, 1, { longEdge: 100, region: { x: 0, y: 0, width: 1e-9, height: 1e-9 } })).toBe("invalid"); + }); + + test("the whole layout by default, on the viewer's ground", async ({ page }) => { + await open(page, "cad-colours.dxf"); + const p = await raster(page, 1, { longEdge: 600 }); + expect(p.info.region.width / p.info.region.height).toBeGreaterThan(1.5); + // The extents with the viewer's margin around them: the drawing is inside, the edge is ground. + const box = find(p, notWhite).box!; + expect(box.minX).toBeGreaterThan(5); + expect(box.maxX).toBeLessThan(p.width - 5); + expect(near(at(p, 0, 0), [255, 255, 255, 255], 2)).toBe(true); + }); +}); + +test.describe("the viewport", () => { + test("follows the camera, in drawing units, and 'current' draws it as the viewer shows it", async ({ page }) => { + const canvas = await open(page, "cad-colours.dxf"); + const box = (await canvas.boundingBox())!; + const fitted = (await viewport(page))!; + expect(fitted).toMatchObject({ space: "drawing", page: 1, layout: "" }); + // The drawing's extents (x 0..100, y 0..60 less the band's edge) are inside it. + expect(fitted.region.x).toBeLessThan(0); + expect(fitted.region.x + fitted.region.width).toBeGreaterThan(100); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, -500); + await expect.poll(async () => (await viewport(page))!.region.width).toBeLessThan(fitted.region.width * 0.6); + const screen = await settled(canvas); + const view = (await viewport(page))!; + const [cw, ch] = await canvas.evaluate((c: HTMLCanvasElement) => [c.width, c.height]); + expect([screen.width, screen.height]).toEqual([cw, ch]); + const p = await raster(page, 1, { region: "current", width: cw, height: ch }); + expect(p.info.region.x).toBeCloseTo(view.region.x, 6); + expect(p.info.region.y).toBeCloseTo(view.region.y, 6); + expect(p.info.region.width).toBeCloseTo(view.region.width, 6); + expect(p.info.region.height).toBeCloseTo(view.region.height, 6); + expect(difference(screen, p)).toBeLessThan(0.5); + expect(worst(screen, p)).toBeLessThan(80); + }); + + test("a drawing with text: 'current' at the canvas's size is the picture on screen", async ({ page }) => { + const canvas = await open(page, "plan.dxf"); + const box = (await canvas.boundingBox())!; + await page.mouse.move(box.x + box.width * 0.25, box.y + box.height * 0.5); + await page.mouse.wheel(0, -600); + await page.waitForTimeout(600); + const screen = await settled(canvas); + const [cw, ch] = await canvas.evaluate((c: HTMLCanvasElement) => [c.width, c.height]); + const p = await raster(page, 1, { region: "current", width: cw, height: ch }); + // Another glyph atlas, at the size the picture wants: a hair apart. + expect(difference(screen, p)).toBeLessThan(1.5); + expect(find(p, notWhite).count).toBeGreaterThan(200); + }); + + test("is a pan, a zoom and a resize away from where it was, and dedupes", async ({ page }) => { + const canvas = await open(page, "cad-colours.dxf"); + const seen = await page.evaluate(() => { + const s = (window as unknown as { exavDemo: { session(): { controllers: { get(): { viewport: { subscribe(f: (v: unknown) => void): () => void } } } } } }).exavDemo.session(); + const log: unknown[] = []; + (window as unknown as { log: unknown[] }).log = log; + s.controllers.get().viewport.subscribe((v) => log.push(v)); + return log.length; + }); + expect(seen).toBe(0); + const before = (await viewport(page))!; + const box = (await canvas.boundingBox())!; + await page.mouse.move(box.x + 100, box.y + 100); + await page.mouse.down(); + await page.mouse.move(box.x + 160, box.y + 100, { steps: 4 }); + await page.mouse.up(); + const after = (await viewport(page))!; + expect(after.region.width).toBeCloseTo(before.region.width, 6); + // Dragged to the right by 60 px: the view moves left. + expect(after.region.x).toBeLessThan(before.region.x); + const notified = await page.evaluate(() => (window as unknown as { log: unknown[] }).log.length); + expect(notified).toBeGreaterThan(0); + // A store set again with what it had does not notify. + await page.evaluate(() => window.dispatchEvent(new Event("resize"))); + await page.waitForTimeout(300); + expect(await page.evaluate(() => (window as unknown as { log: unknown[] }).log.length)).toBe(notified); + }); + + test("is the sheet's, in paper units, once a layout is shown; 'current' is refused for the layout not shown", async ({ page }) => { + await open(page, "cad-sheets.dxf"); + const metric = await pageOf(page, "Metric"); + const imperial = await pageOf(page, "Imperial"); + expect(await refused(page, metric, { longEdge: 100, region: "current" })).toBe("invalid"); + await layout(page, "Metric"); + const view = (await viewport(page))!; + expect(view).toMatchObject({ space: "drawing", page: metric, layout: "Metric" }); + // A sheet of 297 by 210 mm is inside the view. + expect(view.region.width).toBeGreaterThan(297); + const p = await raster(page, metric, { region: "current", longEdge: 300 }); + expect(p.info.layout).toBe("Metric"); + // The sheet's view, to the hair a picture of whole pixels widens it by. + expect(Math.abs(p.info.region.width / view.region.width - 1)).toBeLessThan(0.01); + expect(await refused(page, 1, { longEdge: 100, region: "current" })).toBe("invalid"); + expect(await refused(page, imperial, { longEdge: 100, region: "current" })).toBe("invalid"); + }); +}); + +test.describe("layouts", () => { + // sheets.dxf: model space holds a line; "Metric" and "Imperial" are sheets + // (A4 in mm, letter in inches) with a square of an inch and, below it, a line + // 2.11 mm wide: the square is 25.4 units on the first, 1 on the other. + test("a paper layout is drawn as its sheet: the paper on a surround, in paper units", async ({ page }) => { + const canvas = await open(page, "cad-sheets.dxf"); + const before = await settled(canvas); + const p = await raster(page, await pageOf(page, "Metric"), { longEdge: 900 }); + expect(p.info).toMatchObject({ page: await pageOf(page, "Metric"), layout: "Metric" }); + const surround = (r: number, g: number, b: number) => Math.abs(r - 224) < 8 && Math.abs(g - 224) < 8 && Math.abs(b - 224) < 8; + const white = (r: number, g: number, b: number) => r > 250 && g > 250 && b > 250; + expect(find(p, surround).count).toBeGreaterThan(1000); + const sheet = find(p, white).box!; + expect(sheet.width / sheet.height).toBeGreaterThan(1.37); + expect(sheet.width / sheet.height).toBeLessThan(1.46); + // Paper units: the sheet is 297 wide, so it is that many units of the region. + const unit = p.width / p.info.region.width; + expect(sheet.width / unit).toBeGreaterThan(290); + expect(sheet.width / unit).toBeLessThan(300); + // What the viewer shows was not touched. + expect(await refused(page, 1, { longEdge: 50 })).toBe("none"); + expect(difference(before, await settled(canvas))).toBe(0); + expect((await viewport(page))!.layout).toBe(""); + }); + + test("the layout option selects by id and wins over the page", async ({ page }) => { + await open(page, "cad-sheets.dxf"); + const p = await raster(page, 1, { layout: "Imperial", longEdge: 200 }); + expect(p.info).toMatchObject({ page: await pageOf(page, "Imperial"), layout: "Imperial" }); + const q = await raster(page, 2, { layout: "", longEdge: 200 }); + expect(q.info).toMatchObject({ page: 1, layout: "" }); + }); + + test("a lineweight on a sheet is millimetres on the page; screen follows the viewer, scaled the picture", async ({ page }) => { + const canvas = await open(page, "cad-sheets.dxf"); + // Metric: 10 px a unit; the line, 2.11 mm wide, at y -25.4: row 554. + const region = { x: -10, y: -30, width: 60, height: 60 }; + const size = { width: 600, height: 600 }; + const thickness = (p: Pixels) => column(p, 250, ink).length; + const scaled = await raster(page, 1, { layout: "Metric", region, ...size, lineweight: "scaled" }); + const lines = column(scaled, 250, ink); + // The line is the lowest ink in the column. + const bottom = lines.filter((y) => y > 450); + expect(bottom.length).toBeGreaterThan(18); + expect(bottom.length).toBeLessThan(24); + expect(Math.abs((bottom[0]! + bottom.at(-1)!) / 2 - 554)).toBeLessThanOrEqual(3); + // On screen: the width the viewer would have when its canvas shows the region. + const screen = await raster(page, 1, { layout: "Metric", region, ...size }); + const cw = (await canvas.evaluate((c: HTMLCanvasElement) => [c.width, c.height])) as [number, number]; + const k = Math.max(60 / cw[0], 60 / cw[1]) / 0.1; + const wanted = 21.1 / k; + const got = column(screen, 250, ink).filter((y) => y > 450).length; + expect(Math.abs(got - Math.max(wanted, 1))).toBeLessThanOrEqual(3); + expect(thickness(scaled)).toBeGreaterThan(got); + }); + + test("a viewport's content is clipped to its window, as the viewer shows it", async ({ page }) => { + // A text 60 long and a viewport onto x -10 to 30 of it, 40 by 20 on the sheet. + await open(page, "cad-viewport_text.dxf"); + const p = await raster(page, 1, { layout: "Sheet", longEdge: 1200 }); + expect(p.info.layout).toBe("Sheet"); + const box = find(p, ink).box!; + expect(box.width / box.height).toBeGreaterThan(4.5); + expect(box.width / box.height).toBeLessThan(7); + // The model itself has all the text, and the other one far to the right. + const model = await raster(page, 1, { longEdge: 1200 }); + expect(find(model, ink).box!.width / find(model, ink).box!.height).toBeGreaterThan(10); + }); +}); + +test.describe("tiles", () => { + /** The same picture in one pass, and in tiles of 64 px (the page's `rasterTile`). */ + async function both(browserPage: Page, name: string, n: number, options: Options) { + // The tiles drawn: one GL canvas read back each. + await browserPage.addInitScript(() => { + if ((window as unknown as { tiles?: number }).tiles !== undefined) return; + (window as unknown as { tiles: number }).tiles = 0; + const original = OffscreenCanvas.prototype.transferToImageBitmap; + OffscreenCanvas.prototype.transferToImageBitmap = function (this: OffscreenCanvas) { + (window as unknown as { tiles: number }).tiles++; + return original.call(this); + }; + }); + const drawn = () => browserPage.evaluate(() => (window as unknown as { tiles: number }).tiles); + await open(browserPage, name); + const whole = await raster(browserPage, n, options); + const single = await drawn(); + await open(browserPage, name, { rasterTile: "64" }); + const tiled = await raster(browserPage, n, options); + // Tiles of 64 px, so many of them: the tiled picture is not the single pass. (One more read, of the composed picture.) + expect([single - 1, (await drawn()) - 1]).toEqual([1, Math.ceil(tiled.width / 64) * Math.ceil(tiled.height / 64)]); + return { whole, tiled }; + } + + test("a picture drawn in small tiles is the picture drawn in one pass, no gap and no line twice at the seams", async ({ page }) => { + const { whole, tiled } = await both(page, "cad-halo.dxf", 1, { longEdge: 500 }); + expect([tiled.width, tiled.height]).toEqual([whole.width, whole.height]); + expect(tiled.info.region).toEqual(whole.info.region); + // A red band 8 px wide and a thin line cross the seams every 64 px. + expect(find(whole, red).count).toBeGreaterThan(500); + expect(worst(whole, tiled)).toBeLessThanOrEqual(2); + expect(difference(whole, tiled)).toBeLessThan(0.01); + }); + + test("with text, fills and the sheet's paper", async ({ page }) => { + const text = await both(page, "plan.dxf", 1, { longEdge: 600 }); + expect(find(text.whole, ink).count).toBeGreaterThan(300); + expect(worst(text.whole, text.tiled)).toBeLessThanOrEqual(6); + expect(difference(text.whole, text.tiled)).toBeLessThan(0.05); + const sheet = await both(page, "cad-sheets.dxf", 1, { layout: "Metric", longEdge: 700 }); + expect(worst(sheet.whole, sheet.tiled)).toBeLessThanOrEqual(2); + expect(difference(sheet.whole, sheet.tiled)).toBeLessThan(0.01); + const fills = await both(page, "cad-colours.dxf", 1, { region: { x: 10, y: 30, width: 50, height: 40 }, width: 330, height: 270 }); + expect(find(fills.whole, cyan).count).toBeGreaterThan(1000); + expect(worst(fills.whole, fills.tiled)).toBeLessThanOrEqual(2); + }); + + test("a picture past the default tile, over two sides, is composed", async ({ page }) => { + await open(page, "cad-colours.dxf"); + // 2600 px wide: two tiles of 2048 across. + const p = await raster(page, 1, { region: { x: 0, y: 0, width: 100, height: 60 }, width: 2600, height: 1560 }); + expect([p.width, p.height]).toEqual([2600, 1560]); + const band = find(p, cyan).box!; + // x 0..100 is the whole width; y 40..60 is the top third of the region. + expect(band.minX).toBeLessThanOrEqual(1); + expect(band.maxX).toBeGreaterThanOrEqual(2598); + expect(Math.abs(band.minY - 0)).toBeLessThanOrEqual(2); + expect(Math.abs(band.maxY - 520)).toBeLessThanOrEqual(2); + // Across the tile seam at column 2048, nothing is missing. + expect(column(p, 2047, cyan).length).toBe(column(p, 2048, cyan).length); + expect(column(p, 2047, cyan).length).toBeGreaterThan(500); + }); +}); + +test.describe("sharpness", () => { + test("a hairline stays a pixel wide at four times the zoom on screen", async ({ page }) => { + const canvas = await open(page, "cad-halo.dxf"); + const screen = await settled(canvas); + const view = (await viewport(page))!; + // halo.dxf: a thin vertical line at x 50 below a wide red one. + const w = screen.width * 4; + const h = screen.height * 4; + const p = await raster(page, 1, { region: "current", width: w, height: h }); + const y = Math.round(h * 0.85); + const touched = (r: number, g: number, b: number) => r < 235 || g < 235 || b < 235; + // How much ink a row has, in pixels of the line's colour (a grey of 51: 204 under white). + const inkOf = (q: Pixels) => row(q, y, touched).reduce((n, x) => n + (255 - at(q, x, y)[0]) / 204, 0); + const dark = row(p, y, touched); + expect(dark.length).toBeGreaterThan(0); + expect(dark.length).toBeLessThanOrEqual(3); + // One pixel of ink, not an upscaled one's four. + expect(inkOf(p)).toBeGreaterThan(0.75); + expect(inkOf(p)).toBeLessThan(1.35); + // In the picture where the line is: the middle of the region. + const centre = Math.round(((50 - view.region.x) / view.region.width) * w); + expect(Math.abs(dark[0]! - centre)).toBeLessThanOrEqual(3); + // Scaled with the picture it is four times as wide. + const scaled = await raster(page, 1, { region: "current", width: w, height: h, lineweight: "scaled" }); + expect(inkOf(scaled)).toBeGreaterThan(3.4); + expect(inkOf(scaled)).toBeLessThan(4.7); + }); + + test("text is drawn at the picture's scale, not magnified: edges a pixel or two wide", async ({ page }) => { + await open(page, "cad-text_many.dxf"); + const view = (await viewport(page))!; + // The middle of the drawing, zoomed thirty times. + const cx = view.region.x + view.region.width / 2; + const cy = view.region.y + view.region.height / 2; + const w = view.region.width / 30; + const p = await raster(page, 1, { region: { x: cx - w / 2, y: cy - w / 4, width: w, height: w / 2 }, width: 1200, height: 600 }); + const lum = (r: number, g: number, b: number) => (r + g + b) / 3; + const dark = find(p, (r, g, b) => lum(r, g, b) < 80).count; + const edge = find(p, (r, g, b) => lum(r, g, b) >= 80 && lum(r, g, b) < 200).count; + expect(dark).toBeGreaterThan(2000); + expect(edge / dark).toBeLessThan(0.15); + }); +}); + +test.describe("the ground", () => { + test("is any CSS colour, the viewer's ground by default", async ({ page }) => { + await open(page, "cad-colours.dxf"); + const p = await raster(page, 1, { region: { x: 200, y: 200, width: 10, height: 10 }, width: 20, height: 20, background: "rgb(10 200 30)" }); + expect(near(at(p, 10, 10), [10, 200, 30, 255], 1)).toBe(true); + const q = await raster(page, 1, { region: { x: 200, y: 200, width: 10, height: 10 }, width: 20, height: 20, background: "#ff000080" }); + expect(near(at(q, 10, 10), [255, 0, 0, 128], 2)).toBe(true); + // The dark ground of the session: a drawing resolved against it. + const dark = await raster(page, 1, { region: { x: 200, y: 200, width: 10, height: 10 }, width: 20, height: 20, background: "#212830" }); + expect(near(at(dark, 3, 3), [0x21, 0x28, 0x30, 255], 1)).toBe(true); + }); + + test("transparent has no ground, and its edges and text are premultiplied right", async ({ page }) => { + await open(page, "plan.dxf"); + const options = { longEdge: 700 }; + const opaque = await raster(page, 1, { ...options, background: "#ffffff" }); + const clear = await raster(page, 1, { ...options, background: "transparent" }); + expect(at(clear, 0, 0)[3]).toBe(0); + let partial = 0; + let solid = 0; + for (let i = 0; i < clear.data.length; i += 4) { + const a = clear.data[i + 3]!; + if (a === 255) solid++; + else if (a > 0) partial++; + } + expect(solid).toBeGreaterThan(300); + expect(partial).toBeGreaterThan(300); + // The picture on white is the picture with a white ground: a premultiplied edge + // read as straight, or a glyph's twice over, would darken or lighten it. + const over = new Uint8Array(clear.data.length); + for (let i = 0; i < over.length; i += 4) { + const a = clear.data[i + 3]! / 255; + for (let c = 0; c < 3; c++) over[i + c] = Math.round(clear.data[i + c]! * a + 255 * (1 - a)); + over[i + 3] = 255; + } + const composed = { width: clear.width, height: clear.height, data: over }; + expect(difference(composed, opaque)).toBeLessThan(0.1); + expect(worst(composed, opaque)).toBeLessThanOrEqual(24); + // A pixel that is part ink keeps the ink's own colour (the circle's cyan, 0 194 194 on a light ground), not a darker one. + let tinted = 0; + for (let i = 0; i < clear.data.length; i += 4) { + const a = clear.data[i + 3]!; + if (a > 60 && a < 200 && clear.data[i]! < 20 && Math.abs(clear.data[i + 1]! - 194) < 8 && Math.abs(clear.data[i + 2]! - 194) < 8) tinted++; + } + expect(tinted).toBeGreaterThan(20); + }); + + test("a sheet on transparency is its content alone, without paper or surround", async ({ page }) => { + await open(page, "cad-sheets.dxf"); + const p = await raster(page, 1, { layout: "Metric", longEdge: 400, background: "transparent" }); + expect(at(p, 2, 2)[3]).toBe(0); + expect(at(p, p.width / 2, p.height / 2)[3]).toBe(0); + }); +}); + +test.describe("resources", () => { + /** Records the WebGL2 contexts of off screen canvases (the exports'), and how many were live when each was made. */ + async function track(page: Page) { + await page.addInitScript(() => { + const log: { gl: WebGL2RenderingContext; before: number }[] = []; + (window as unknown as { exports: typeof log }).exports = log; + const original = OffscreenCanvas.prototype.getContext; + OffscreenCanvas.prototype.getContext = function (this: OffscreenCanvas, kind: string, ...rest: unknown[]) { + const gl = (original as (...a: unknown[]) => unknown).call(this, kind, ...rest); + if (kind === "webgl2" && gl) log.push({ gl: gl as WebGL2RenderingContext, before: log.filter((e) => !e.gl.isContextLost()).length }); + return gl; + } as typeof original; + }); + } + const contexts = (page: Page) => + page.evaluate(() => { + const log = (window as unknown as { exports: { gl: WebGL2RenderingContext; before: number }[] }).exports; + return { made: log.length, live: log.filter((e) => !e.gl.isContextLost()).length, before: log.map((e) => e.before) }; + }); + + test("a context is made for a picture and given back, on success and on error", async ({ page }) => { + await track(page); + await open(page, "cad-colours.dxf"); + await raster(page, 1, { longEdge: 300 }); + expect(await contexts(page)).toMatchObject({ made: 1, live: 0 }); + expect(await refused(page, 1, { longEdge: 300, region: { x: 0, y: 0, width: 1e-9, height: 1e-9 } })).toBe("invalid"); + expect(await contexts(page)).toMatchObject({ live: 0 }); + }); + + test("pictures are drawn one at a time", async ({ page }) => { + await track(page); + await open(page, "cad-colours.dxf", { rasterTile: "64" }); + const sizes = await page.evaluate(async () => { + const s = (window as unknown as { exavDemo: { session(): { controllers: { get(): { pictures: { raster(n: number, o: object): Promise<{ bitmap: ImageBitmap; width: number }> } } } } } }).exavDemo.session(); + const p = s.controllers.get().pictures; + const all = await Promise.all([p.raster(1, { longEdge: 300 }), p.raster(1, { longEdge: 200 }), p.raster(1, { longEdge: 100 })]); + all.forEach((r) => r.bitmap.close()); + return all.map((r) => r.width); + }); + expect(sizes).toEqual([300, 200, 100]); + const { made, before, live } = await contexts(page); + expect(made).toBe(3); + expect(before).toEqual([0, 0, 0]); + expect(live).toBe(0); + }); + + test("an abort in the middle of tiles stops the drawing and frees the context", async ({ page }) => { + await track(page); + await open(page, "plan.dxf", { rasterTile: "32" }); + const result = await page.evaluate(async () => { + const s = (window as unknown as { exavDemo: { session(): { controllers: { get(): { pictures: { raster(n: number, o: object): Promise } } } } } }).exavDemo.session(); + const abort = new AbortController(); + const started = performance.now(); + const done = s.controllers.get().pictures.raster(1, { width: 1500, height: 1000, signal: abort.signal }).then( + () => "drawn", + (e: { code?: string }) => e.code ?? String(e), + ); + // Once the first context has been made, the tiles are being drawn. + const log = (window as unknown as { exports: unknown[] }).exports; + while (log.length === 0 && performance.now() - started < 20000) await new Promise((r) => setTimeout(r, 5)); + await new Promise((r) => setTimeout(r, 150)); + abort.abort(); + return { code: await done, ms: performance.now() - started }; + }); + expect(result.code).toBe("aborted"); + await page.waitForTimeout(200); + expect(await contexts(page)).toMatchObject({ made: 1, live: 0 }); + // The session goes on, and a picture can be asked again. + const p = await raster(page, 1, { longEdge: 100 }); + expect(p.width).toBe(100); + }); + + test("an abort while the worker draws the layout is at once, before any context", async ({ page }) => { + await track(page); + await open(page, "cad-sheets.dxf"); + const code = await page.evaluate(async () => { + const s = (window as unknown as { exavDemo: { session(): { controllers: { get(): { pictures: { raster(n: number, o: object): Promise } } } } } }).exavDemo.session(); + const abort = new AbortController(); + const done = s.controllers.get().pictures.raster(2, { longEdge: 300, signal: abort.signal }).then( + () => "drawn", + (e: { code?: string }) => e.code ?? String(e), + ); + abort.abort(); + return done; + }); + expect(code).toBe("aborted"); + const p = await raster(page, 1, { layout: "Metric", longEdge: 100 }); + expect(p.width).toBeGreaterThan(0); + expect((await contexts(page)).live).toBe(0); + }); + + test("the visible canvas keeps no drawing buffer", async ({ page }) => { + const canvas = await open(page, "cad-colours.dxf"); + expect(await canvas.evaluate((c: HTMLCanvasElement) => c.getContext("webgl2")!.getContextAttributes()!.preserveDrawingBuffer)).toBe(false); + await raster(page, 1, { longEdge: 200 }); + expect(await canvas.evaluate((c: HTMLCanvasElement) => c.getContext("webgl2")!.getContextAttributes()!.preserveDrawingBuffer)).toBe(false); + }); +}); + +test.describe("in the sandboxed frame", () => { + test("a picture has the size, region and pixels it has in the page", async ({ page }) => { + await open(page, "cad-colours.dxf"); + const options = { region: { x: -50, y: 15, width: 200, height: 50 }, width: 800, height: 200 }; + const inPage = await raster(page, 1, options); + + await page.goto("./headless.html?lang=en"); + await page.getByTestId("file-input").setInputFiles({ name: "cad-colours.dxf", mimeType: "", buffer: sample("cad-colours.dxf") }); + await expect + .poll(() => page.evaluate(() => (window as unknown as { headless: { session(): { status: { get(): { phase: string } } } | null } }).headless.session()?.status.get().phase)) + .toBe("ready"); + const out = await page.evaluate(async (options) => { + const s = (window as unknown as { headless: { session(): { controllers: { get(): { pictures: { raster(n: number, o: object): Promise<{ bitmap: ImageBitmap } & Record> }; viewport: { get(): unknown } } } } } }).headless.session(); + let c = s.controllers.get(); + for (let i = 0; i < 100 && !(c.pictures && c.viewport.get()); i++) { + await new Promise((r) => setTimeout(r, 100)); + c = s.controllers.get(); + } + const r = await c.pictures.raster(1, options); + const canvas = new OffscreenCanvas(r.bitmap.width, r.bitmap.height); + canvas.getContext("2d")!.drawImage(r.bitmap, 0, 0); + r.bitmap.close(); + const bytes = new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer()); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + const { bitmap: _bitmap, ...info } = r; + return { png: btoa(binary), info, view: c.viewport.get() }; + }, options); + const inFrame = decodePng(Buffer.from(out.png, "base64")); + expect([inFrame.width, inFrame.height]).toEqual([inPage.width, inPage.height]); + expect(out.info).toMatchObject({ width: 800, height: 200, page: 1, layout: "", space: "drawing", region: options.region }); + expect(out.view).toMatchObject({ space: "drawing", page: 1, layout: "" }); + expect(difference(inPage, inFrame)).toBeLessThan(0.05); + }); +}); diff --git a/crates/exav-viewer/e2e/cad.spec.ts b/crates/exav-viewer/e2e/cad.spec.ts new file mode 100644 index 00000000..76d1246a --- /dev/null +++ b/crates/exav-viewer/e2e/cad.spec.ts @@ -0,0 +1,372 @@ +// What the DWG and DXF renderer draws its own way, on exav-render's drawings +// built to show each thing (its tests/fixtures/dwg/make.py, copied as +// cad-*.dxf by make-samples.mjs), measured on the picture. In the page: the +// sandboxed frame draws the same pixels (demo.spec.ts compares the two). +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { failOnViolations } from "./csp.js"; +import { bands, decodePng, difference, find, type Pixels } from "./pixels.js"; +import { openSample, sample } from "./samples.js"; + +failOnViolations(); + +const ink = (r: number, g: number, b: number) => r < 160 && g < 160 && b < 160; + +/** One of the demo's example drawings (demo/examples/generate/make-cad.py). */ +const HOUSE = fileURLToPath(new URL("../demo/examples/files/cad/house-plan.dxf", import.meta.url)); + +async function shot(target: Locator): Promise { + const page = target.page(); + // The viewer's tools float over the canvas. + const tools = page.locator(".exav-viewer-tools"); + await tools.evaluateAll((els) => els.forEach((e) => ((e as HTMLElement).style.visibility = "hidden"))); + try { + return decodePng(await target.screenshot({ animations: "disabled" })); + } finally { + await tools.evaluateAll((els) => els.forEach((e) => ((e as HTMLElement).style.visibility = ""))); + } +} + +/** Two screenshots in a row that agree: the canvas has settled. */ +async function settled(target: Locator): Promise { + // A zoom scales the canvas as drawn until it pauses: its box is not its own. + await expect.poll(() => target.evaluate((e) => (e as HTMLElement).style.transform)).toBe(""); + let last = await shot(target); + for (let i = 0; i < 20; i++) { + await target.page().waitForTimeout(150); + const next = await shot(target); + if (difference(last, next) === 0) return next; + last = next; + } + return last; +} + +async function open(page: Page, name: string): Promise { + await openSample(page, name, { mode: "page" }); + await expect(page.locator(".demo-frame .exav-viewer-body")).toHaveAttribute("data-phase", "ready"); + return page.locator(".demo-frame .exav-viewer-cad-canvas"); +} + +async function layout(page: Page, name: string): Promise { + await page.locator(".exav-viewer-layouts").getByRole("button", { name }).click(); + await expect(page.locator(".demo-frame .exav-viewer-body")).toHaveAttribute("data-phase", "ready"); +} + +test.describe("clipping", () => { + // A viewport onto model x -10 to 30 of a text 5 high running from 0 to + // about 66: 30 of it shows, some 6 times its height, not 13. + test("text crossing a viewport's edge is cut at the edge", async ({ page }) => { + const canvas = await open(page, "cad-viewport_text.dxf"); + await layout(page, "Sheet"); + const box = find(await settled(canvas), ink).box!; + expect(box.width / box.height).toBeGreaterThan(4.5); + expect(box.width / box.height).toBeLessThan(7); + }); +}); + +type Test = (r: number, g: number, b: number) => boolean; + +/** The pixels `test` accepts inside `box`, less `inset` on every side. */ +function countIn(p: Pixels, box: { minX: number; minY: number; maxX: number; maxY: number }, inset: number, test: Test): number { + let n = 0; + for (let y = box.minY + inset; y <= box.maxY - inset; y++) + for (let x = box.minX + inset; x <= box.maxX - inset; x++) { + const i = (y * p.width + x) * 4; + if (test(p.data[i]!, p.data[i + 1]!, p.data[i + 2]!)) n++; + } + return n; +} + +const near = (v: number, to: number) => Math.abs(v - to) < 32; + +test.describe("transparency", () => { + // Each at half opacity: a red hatch over a green line, a blue V, a cyan + // hatch over magenta text, each drawn after what it covers. + test("an opaque line behind a translucent hatch shows through it", async ({ page }) => { + const p = await settled(await open(page, "cad-transparency.dxf")); + const pink = find(p, (r, g, b) => r > 220 && near(g, 128) && near(b, 128)).box!; + expect(countIn(p, pink, 2, (r, g, b) => near(r, 128) && near(g, 128) && b < 40)).toBeGreaterThan(0); + expect(countIn(p, pink, 2, (r, g, b) => g > 200 && r < 60 && b < 60)).toBe(0); + }); + + test("a translucent polyline is no darker where its segments meet", async ({ page }) => { + const p = await settled(await open(page, "cad-transparency.dxf")); + expect(find(p, (r, g, b) => near(r, 128) && near(g, 128) && b > 220).count).toBeGreaterThan(0); + expect(find(p, (r, g, b) => r < 90 && g < 90 && b > 200).count).toBe(0); + }); + + test("text behind a translucent hatch is tinted by it", async ({ page }) => { + const p = await settled(await open(page, "cad-transparency.dxf")); + const cyan = find(p, (r, g, b) => near(r, 128) && g > 220 && b > 220).box!; + expect(countIn(p, cyan, 2, (r, g, b) => near(r, 128) && near(g, 128) && b > 220)).toBeGreaterThan(0); + expect(countIn(p, cyan, 2, (r, g, b) => r > 200 && g < 60 && b > 200)).toBe(0); + }); +}); + +test.describe("what is not drawn", () => { + test("is said by type, and the files the drawing points at by name", async ({ page }) => { + await open(page, "cad-not_drawn.dxf"); + const warnings = page.locator(".demo-frame .exav-viewer-warnings"); + await expect(warnings).toContainText("3 objects are of a kind not drawn here. 3DSOLID (2), REGION"); + await expect(warnings).toContainText("3 external references are not drawn. photo.png, walls.dwg"); + }); + + test("a drawing with nothing drawn here keeps the thumbnail it was saved with", async ({ page }) => { + await open(page, "cad-solid_only.dxf"); + const preview = page.locator(".demo-frame .exav-viewer-cad-preview"); + await expect(preview).toHaveCount(1); + expect(await preview.evaluate((img: HTMLImageElement) => [img.naturalWidth, img.naturalHeight])).toEqual([8, 8]); + await expect(page.locator(".demo-frame .exav-viewer-warnings")).toContainText("the picture saved with it is shown"); + }); +}); + +test.describe("antialiasing", () => { + // A thin dark line drawn before the wide red one it crosses in front of: + // its anti-aliased edges blend over the red, not over the paper. + test("a line's soft edge shows what lies behind it, not the background", async ({ page }) => { + const p = await settled(await open(page, "cad-halo.dxf")); + const band = find(p, (r, g, b) => r > 200 && g < 80 && b < 80).box!; + const grey = (r: number, g: number, b: number) => + Math.max(r, g, b) - Math.min(r, g, b) < 30 && r > 70 && r < 235; + expect(countIn(p, band, 2, grey)).toBe(0); + // The dark line is there, across the band: red darkened by it. + expect(countIn(p, band, 2, (r, g, b) => r < 210 && g < 70 && b < 70)).toBeGreaterThan(0); + }); + + // Nothing but filled bands: ink and paper only, but where a pixel is part + // of each, which a slanted or curved edge has all along it. + test("a fill's edge is anti-aliased, and no wider for it", async ({ page }) => { + const p = await settled(await open(page, "cad-wide.dxf")); + const grey = (r: number, g: number, b: number) => r > 60 && r < 200 && Math.max(r, g, b) - Math.min(r, g, b) < 30; + const dark = (r: number, g: number, b: number) => r < 60 && g < 60 && b < 60; + expect(find(p, grey).count).toBeGreaterThan(60); + // Partly covered pixels outside the bands, at most half each: the ink + // stays where it was. + expect(find(p, grey).count).toBeLessThan(find(p, dark).count / 2); + }); +}); + +test.describe("curves", () => { + // Zoomed thirtyfold onto a circle's rightmost point, its edge runs down + // the canvas: a true arc leaves the vertical by about six pixels by the + // canvas's top and bottom, the seventy-sided polygon it used to be by + // fifteen on at least one side. Deeper, the pixel the zoom anchors on + // sends the edge off the canvas. + test("a circle stays round however close the view", async ({ page }) => { + const canvas = await open(page, "cad-circle.dxf"); + const fitted = await settled(canvas); + const ring = find(fitted, ink).box!; + const box = (await canvas.boundingBox())!; + const right = { x: ring.maxX, y: Math.round((ring.minY + ring.maxY) / 2) }; + await page.mouse.move(box.x + right.x, box.y + right.y); + await page.mouse.wheel(0, -1100); + const p = await settled(canvas); + const inkAt = (y: number) => { + let x = -1; + for (let i = 0; i < p.width; i++) { + const o = (y * p.width + i) * 4; + if (ink(p.data[o]!, p.data[o + 1]!, p.data[o + 2]!)) x = i; + } + return x; + }; + const middle = inkAt(right.y); + const top = inkAt(4); + const bottom = inkAt(p.height - 5); + expect(middle).toBeGreaterThan(0); + expect(top).toBeGreaterThan(0); + expect(bottom).toBeGreaterThan(0); + expect(middle - top).toBeLessThan(10); + expect(middle - bottom).toBeLessThan(10); + }); +}); + +test.describe("colours", () => { + const brightYellow = (r: number, g: number, b: number) => r > 220 && g > 220 && b < 90; + const darkYellow = (r: number, g: number, b: number) => r > 110 && g > 110 && b < 60 && r < 200; + const cyan = (r: number, g: number, b: number) => r < 60 && g > 220 && b > 220; + const red = (r: number, g: number, b: number) => r > 200 && g < 60 && b < 60; + + test("a yellow line on white is darkened to be seen; a fill keeps its colour", async ({ page }) => { + const p = await settled(await open(page, "cad-colours.dxf")); + expect(find(p, brightYellow).count).toBe(0); + expect(find(p, darkYellow).count).toBeGreaterThan(100); + expect(find(p, cyan).count).toBeGreaterThan(1000); + }); + + test("monochrome draws everything in ink", async ({ page }) => { + await openSample(page, "cad-colours.dxf", { mode: "page", cad: "monochrome" }); + await expect(page.locator(".demo-frame .exav-viewer-body")).toHaveAttribute("data-phase", "ready"); + const p = await settled(page.locator(".demo-frame .exav-viewer-cad-canvas")); + expect(find(p, red).count + find(p, cyan).count + find(p, darkYellow).count).toBe(0); + expect(find(p, (r, g, b) => r < 40 && g < 40 && b < 40).count).toBeGreaterThan(1000); + }); +}); + +test.describe("context", () => { + test("a lost GL context comes back with the drawing on it", async ({ page }) => { + const canvas = await open(page, "cad-halo.dxf"); + const before = await settled(canvas); + await canvas.evaluate(async (c: HTMLCanvasElement) => { + const lose = c.getContext("webgl2")!.getExtension("WEBGL_lose_context")!; + lose.loseContext(); + await new Promise((r) => setTimeout(r, 300)); + lose.restoreContext(); + }); + await page.waitForTimeout(1500); + expect(difference(before, await settled(canvas))).toBeLessThan(1); + }); +}); + +test.describe("text", () => { + // Twelve faces' worth of characters, more than the atlas budget affords + // at the em a close view wants: "HE" in the middle, zoomed into, keeps + // edges a pixel or two wide rather than the blur of a magnified bitmap. + test("a character set too large for the atlas stays sharp zoomed in", async ({ page }) => { + const canvas = await open(page, "cad-text_many.dxf"); + const box = (await canvas.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, -1400); + await page.waitForTimeout(1000); + const p = await settled(canvas); + const lum = (r: number, g: number, b: number) => (r + g + b) / 3; + const dark = find(p, (r, g, b) => lum(r, g, b) < 80).count; + const edge = find(p, (r, g, b) => lum(r, g, b) >= 80 && lum(r, g, b) < 200).count; + expect(dark).toBeGreaterThan(2000); + expect(edge / dark).toBeLessThan(0.15); + }); + + // A drawing of a house, zoomed into by ten: the glyph page for that view is + // made of distance fields, which take the better part of a second. Made a + // slice at a time, the page keeps answering while they are (the longest + // wait was 940 ms in one piece, 95 ms in slices, in software GL). + test("the glyph page made for a closer view does not stop the page", async ({ page }) => { + await page.goto("./?lang=en&mode=page"); + await page.getByTestId("file-input").setInputFiles(HOUSE); + await expect(page.locator(".demo-frame .exav-viewer-body")).toHaveAttribute("data-phase", "ready"); + const canvas = page.locator(".demo-frame .exav-viewer-cad-canvas"); + // What loading left to do is done. + await settled(canvas); + const box = (await canvas.boundingBox())!; + await page.mouse.move(box.x + box.width * 0.6, box.y + box.height * 0.45); + await page.evaluate(() => { + const w = window as unknown as { gaps: number[] }; + w.gaps = []; + let last = performance.now(); + setInterval(() => { + const now = performance.now(); + w.gaps.push(now - last); + last = now; + }, 5); + }); + await page.mouse.wheel(0, -1000); + await page.waitForTimeout(2500); + const gaps = await page.evaluate(() => (window as unknown as { gaps: number[] }).gaps); + expect(gaps.length).toBeGreaterThan(100); + expect(Math.max(...gaps)).toBeLessThan(300); + }); +}); + +test.describe("paper", () => { + test("a layout is drawn on its sheet of paper, model space on the ground", async ({ page }) => { + const canvas = await open(page, "cad-sheets.dxf"); + const surround = (r: number, g: number, b: number) => Math.abs(r - 224) < 8 && Math.abs(g - 224) < 8 && Math.abs(b - 224) < 8; + const white = (r: number, g: number, b: number) => r > 250 && g > 250 && b > 250; + expect(find(await settled(canvas), surround).count).toBe(0); + await layout(page, "Metric"); + const p = await settled(canvas); + expect(find(p, surround).count).toBeGreaterThan(1000); + // A4 in landscape: the white sheet is 297 by 210. + const sheet = find(p, white).box!; + expect(sheet.width / sheet.height).toBeGreaterThan(1.37); + expect(sheet.width / sheet.height).toBeLessThan(1.46); + }); +}); + +test.describe("sheets", () => { + // Each layout has a square an inch across and, below it, a line 2.11 mm + // wide: a twelfth of the square on either sheet, whatever its units. + for (const name of ["Metric", "Imperial"]) { + test(`a lineweight on a sheet in ${name.toLowerCase()} units is millimetres on the page`, async ({ page }) => { + const canvas = await open(page, "cad-sheets.dxf"); + await layout(page, name); + const p = await settled(canvas); + // Rows with ink most of the way across: the square's two edges and the line. + const rows = bands(p, ink, 20); + expect(rows.length).toBe(3); + const [top, bottom, line] = rows as [{ from: number; to: number }, { from: number; to: number }, { from: number; to: number }]; + const side = bottom.from - top.from; + const width = line.to - line.from + 1; + expect(width / side).toBeGreaterThan(0.05); + expect(width / side).toBeLessThan(0.13); + }); + } +}); + +// A drawing shown with the files it refers to (cad-xref_*: the sheet refers to +// title.dwg, which refers to part.dwg). The viewer's file input takes several +// files at once, and a folder: they are one tree. +test.describe("files that refer to others", () => { + const red = (r: number, g: number, b: number) => r > 215 && g < 140 && b < 140; + const green = (r: number, g: number, b: number) => g > 180 && r < 120 && b < 120; + const files = [ + ["sheet.dxf", "cad-xref_sheet.dxf"], + ["title.dwg", "cad-xref_title.dxf"], + ["part.dwg", "cad-xref_part.dxf"], + ] as const; + + for (const mode of ["page", "sandbox"] as const) { + // The drawing is in the frame in one mode and in the page in the other; the warnings are the page's in both. + const canvas = (page: Page) => (mode === "sandbox" ? page.frameLocator(".demo-frame iframe.exav-viewer-sandbox") : page.locator(".demo-frame")).locator(".exav-viewer-cad-canvas"); + const warnings = (page: Page) => page.locator(".demo-frame .exav-viewer-warnings"); + const start = async (page: Page, input: "file-input" | "folder-input", given: Parameters[0]) => { + await page.goto(`./?${new URLSearchParams({ lang: "en", ...(mode === "page" ? { mode } : {}) })}`); + await page.getByTestId(input).setInputFiles(given); + await expect(page.locator(".demo-frame .exav-viewer-body")).toHaveAttribute("data-phase", "ready"); + }; + const picture = async (page: Page) => settled(canvas(page)); + + test(`in the ${mode}: the reference is drawn from the files given with it`, async ({ page }) => { + await start(page, "file-input", files.map(([name, from]) => ({ name, mimeType: "", buffer: sample(from) }))); + const p = await picture(page); + expect(find(p, red).count, "the frame of title.dwg").toBeGreaterThan(20); + expect(find(p, green).count, "part.dwg, which title.dwg refers to").toBeGreaterThan(0); + await expect(warnings(page)).toHaveCount(0); + }); + + test(`in the ${mode}: alone, it says what it could not draw`, async ({ page }) => { + await start(page, "file-input", [{ name: "sheet.dxf", mimeType: "", buffer: sample("cad-xref_sheet.dxf") }]); + expect(find(await picture(page), red).count).toBe(0); + await expect(warnings(page)).toContainText("title.dwg"); + }); + + test(`in the ${mode}: a reference not among the files is said, the others are drawn`, async ({ page }) => { + await start(page, "file-input", files.slice(0, 2).map(([name, from]) => ({ name, mimeType: "", buffer: sample(from) }))); + expect(find(await picture(page), red).count).toBeGreaterThan(20); + await expect(warnings(page)).toContainText("part.dwg"); + }); + + test(`in the ${mode}: it is found among the members of the archive the drawing is in`, async ({ page }) => { + await start(page, "file-input", [{ name: "project.zip", mimeType: "", buffer: sample("project.zip") }]); + await page.locator(".demo-frame").getByRole("button", { name: /sheet\.dxf/ }).click(); + // The canvas is first made for the box beside the member list and then + // for the one without it: a shot is taken until it has the frame. + await expect(canvas(page)).toHaveCount(1); + await expect.poll(async () => find(await shot(canvas(page)), red).count).toBeGreaterThan(20); + }); + + test(`in the ${mode}: it is found in a folder, below the drawing's own`, async ({ page }, info) => { + const dir = info.outputPath("project"); + for (const [name, from] of files) { + const at = path.join(dir, name === "sheet.dxf" ? "plans" : path.join("plans", "xrefs"), name); + fs.mkdirSync(path.dirname(at), { recursive: true }); + fs.writeFileSync(at, sample(from)); + } + await start(page, "folder-input", dir); + expect(find(await picture(page), red).count).toBeGreaterThan(20); + }); + } +}); diff --git a/crates/exav-viewer/e2e/converted.spec.ts b/crates/exav-viewer/e2e/converted.spec.ts new file mode 100644 index 00000000..00f6bdc7 --- /dev/null +++ b/crates/exav-viewer/e2e/converted.spec.ts @@ -0,0 +1,134 @@ +// The converted Office formats of `@exav/viewer/office` (`rtf()`, `doc()`...) +// with a real external converter, shown by the viewer's PDF and XLSX plugins, +// in the page and in the sandboxed frame. The converter is a package that +// exports the contract (see `ExternalConverter`) and carries its fixtures in +// `tests/fixtures`: name it in EXAV_EXTERNAL_CONVERTER when you build the demo +// (`EXAV_EXTERNAL_CONVERTER= npm run demo:build`) and run these tests. +// Skipped, saying why, when it is not set or the demo was built without it; +// external.spec.ts checks the contract itself with a converter of its own. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect, test, type FrameLocator, type Locator, type Page } from "@playwright/test"; + +import { failOnViolations, screenshotting } from "./csp.js"; +import { decodePng, find } from "./pixels.js"; + +const CONVERTER = process.env.EXAV_EXTERNAL_CONVERTER; + +/** The converter's own fixtures: a short letter, a two-slide presentation, a two-page drawing, a small table, and Keynote, Numbers and CorelDRAW files. */ +const FIXTURES = CONVERTER ? path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "node_modules", ...CONVERTER.split("/"), "tests", "fixtures") : ""; + +failOnViolations(); + +test.beforeEach(async ({ page }) => { + test.skip(!CONVERTER, "no real external converter: set EXAV_EXTERNAL_CONVERTER to an installed converter package, and build the demo with it"); + await page.goto("./?lang=en"); + const manifest = await page.evaluate(() => fetch("./exav-viewer/manifest.json").then((r) => r.json())); + test.skip(!manifest.converters?.[CONVERTER!], `the demo was built without ${CONVERTER}: build it with EXAV_EXTERNAL_CONVERTER=${CONVERTER}`); +}); + +const ink = (r: number, g: number, b: number) => r < 160 && g < 160 && b < 160; + +for (const mode of ["sandbox", "page"] as const) { + async function open(page: Page, name: string, as = name, params: Record = {}) { + await page.goto(`./?${new URLSearchParams({ lang: "en", ...params, ...(mode === "page" && { mode }) })}`); + await page.getByTestId("file-input").setInputFiles({ name: as, mimeType: "", buffer: fs.readFileSync(path.join(FIXTURES, name)) }); + await expect(page.locator(".demo-frame .exav-viewer-body")).toHaveAttribute("data-phase", "ready", { timeout: 60_000 }); + } + + /** Dark pixels on the surface: the document is drawn, not a blank page. */ + const drawn = async (page: Page) => { + screenshotting(page); + return find(decodePng(await page.locator(".demo-frame .exav-viewer-surface").screenshot()), ink).count; + }; + + /** What the engine put on screen: in the frame's document, or in the page's. */ + const engine = (page: Page): FrameLocator | Locator => + mode === "sandbox" ? page.frameLocator(".demo-frame iframe.exav-viewer-sandbox") : page.locator(".demo-frame"); + + test.describe(mode, () => { + test("a Word 97 document is converted to PDF and shown page by page", async ({ page }) => { + await open(page, "sample.doc"); + await expect(engine(page).locator(".exav-viewer-pdf-page")).toHaveCount(2); + await expect.poll(() => drawn(page)).toBeGreaterThan(200); + }); + + test("a conversion shows its steps, with a bar for those it counts", async ({ page }) => { + await page.goto(`./?${new URLSearchParams({ lang: "en", ...(mode === "page" && { mode }) })}`); + // Every message the status overlay shows, and whether a bar came with it. + await page.evaluate(() => { + const seen = new Set(); + (window as unknown as { steps: Set }).steps = seen; + new MutationObserver(() => { + const status = document.querySelector(".demo-frame .exav-viewer-status"); + const text = status?.querySelector("p")?.textContent; + if (text) seen.add(`${text}${status!.querySelector('[role="progressbar"]') ? " [bar]" : ""}`); + }).observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true }); + }); + await page.getByTestId("file-input").setInputFiles({ name: "sample.doc", mimeType: "", buffer: fs.readFileSync(path.join(FIXTURES, "sample.doc")) }); + await expect(page.locator(".demo-frame .exav-viewer-body")).toHaveAttribute("data-phase", "ready", { timeout: 60_000 }); + const steps = await page.evaluate(() => [...(window as unknown as { steps: Set }).steps]); + expect(steps).toContain("Downloading the viewer… [bar]"); + expect(steps).toContain("Starting the viewer…"); + expect(steps).toContain("Converting the document… [bar]"); + }); + + test("an RTF saved under .doc is read as the RTF it is", async ({ page }) => { + await open(page, "sample.rtf", "letter.doc"); + await expect(engine(page).locator(".exav-viewer-pdf-page")).toHaveCount(2); + }); + + test("an OpenDocument presentation is converted by exav-render's slides and shown as slides", async ({ page }) => { + await open(page, "sample.odp"); + await expect(engine(page).locator(".exav-viewer-pdf-page")).toHaveCount(0); + await expect(engine(page).locator("canvas").first()).toBeVisible(); + await expect.poll(() => drawn(page)).toBeGreaterThan(200); + }); + + test("a Keynote 1 presentation is converted by exav-render's slides and shown as slides, not as PDF", async ({ page }) => { + await open(page, "keynote1.key"); + await expect(engine(page).locator(".exav-viewer-pdf-page")).toHaveCount(0); + await expect(page.locator(".exav-viewer-pager span")).toHaveText("Slide 1 / 2"); + await expect.poll(() => drawn(page)).toBeGreaterThan(200); + }); + + test("an OpenDocument drawing is converted by exav-render's slides, a page to a slide, not to PDF", async ({ page }) => { + await open(page, "sample.odg"); + await expect(engine(page).locator(".exav-viewer-pdf-page")).toHaveCount(0); + await expect(page.locator(".exav-viewer-pager span")).toHaveText("Slide 1 / 2"); + await expect.poll(() => drawn(page)).toBeGreaterThan(50); + }); + + test("an OpenDocument drawing is converted to PDF, a page per page, when the external converter is asked for", async ({ page }) => { + await open(page, "sample.odg", "sample.odg", { converter: "external" }); + await expect(engine(page).locator(".exav-viewer-pdf-page")).toHaveCount(2); + await expect.poll(() => drawn(page)).toBeGreaterThan(50); + }); + + test("a CorelDRAW drawing is converted to PDF", async ({ page }) => { + await open(page, "corel.cdr"); + await expect(engine(page).locator(".exav-viewer-pdf-page")).toHaveCount(1); + await expect.poll(() => drawn(page)).toBeGreaterThan(200); + }); + + test("a Numbers spreadsheet is converted to XLSX and shown as a workbook", async ({ page }) => { + await open(page, "sample.numbers"); + await expect(engine(page).locator("canvas").first()).toBeVisible(); + await expect.poll(() => drawn(page)).toBeGreaterThan(200); + }); + + test("an Excel 97 workbook is converted to XLSX and shown as a workbook", async ({ page }) => { + await open(page, "sample.xls"); + await expect(engine(page).locator("canvas").first()).toBeVisible(); + await expect(engine(page).locator(".exav-viewer-pdf-page")).toHaveCount(0); + await expect.poll(() => drawn(page)).toBeGreaterThan(200); + }); + + test("an XLSX named .xls is shown as it is", async ({ page }) => { + await open(page, "sample.xlsx", "renamed.xls"); + await expect.poll(() => drawn(page)).toBeGreaterThan(200); + }); + }); +} diff --git a/crates/exav-viewer/e2e/csp.ts b/crates/exav-viewer/e2e/csp.ts index 688b3968..b68dba7e 100644 --- a/crates/exav-viewer/e2e/csp.ts +++ b/crates/exav-viewer/e2e/csp.ts @@ -6,12 +6,25 @@ import { expect, test, type Page } from "@playwright/test"; const seen = new WeakMap(); +const screenshots = new WeakMap(); /** The violations reported so far on `page`. */ export function violations(page: Page): string[] { return seen.get(page) ?? []; } +/** + * Says a screenshot of `page` is taken. In WebKit, Playwright adds a + * `", + "
      ", + "
      ", + "bold", + "", + "", + "", + "", + "

      ", + ]; + + it("shows each of these as written and creates none of their elements", async () => { + for (const html of hostile) { + const r = await render(html); + expect(r.querySelector("script,img,svg,iframe,a,style,form,input,details,math,meta,link,base,b"), html).toBeNull(); + expectInert(r, html); + expect(r.textContent, html).toBe(html); + } + }); + + it("does so inside every Markdown construct", async () => { + const html = ""; + const wrapped = [`# ${html}`, `> ${html}`, `- ${html}`, `1. ${html}`, `*${html}*`, `**${html}**`, `[${html}](https://example.com)`, `![${html}](https://example.com/i.png)`, `| ${html} |\n|---|\n| ${html} |`, `\`${html}\``, `\`\`\`\n${html}\n\`\`\``, ` ${html}`]; + for (const md of wrapped) { + const r = await render(md); + expect(r.querySelector("img"), md).toBeNull(); + expectInert(r, md); + expect(r.textContent, md).toContain(html.slice(0, 4)); + } + }); +}); + +describe("limits", () => { + const sizes = (n: number, s: string) => s.repeat(n); + const inputs: [string, string][] = [ + ["100k stars", sizes(100_000, "*")], + ["closers only", sizes(40, `${sizes(3000, "a* ")}\n`)], + ["alternating emphasis", sizes(50_000, "*a_b")], + ["100k open brackets", sizes(100_000, "[")], + ["100k link openers", sizes(100_000, "[](")], + ["100k image openers", sizes(100_000, "![")], + ["nested links", sizes(20_000, "[a](") + sizes(20_000, ")")], + ["ascending backtick runs", Array.from({ length: 1500 }, (_, i) => "`".repeat(i + 1)).join(" ")], + ["autolink openers", sizes(30_000, " ") + "deep"], + ["quote staircase", Array.from({ length: 2000 }, (_, i) => "> ".repeat(i % 40) + "x").join("\n")], + ["nested list on one line", sizes(10_000, "- ") + "deep"], + ["list staircase", Array.from({ length: 3000 }, (_, i) => " ".repeat(2 * (i % 60)) + "- item").join("\n")], + ["20k blank-separated items", sizes(20_000, "- a\n\n")], + ["50k headings", sizes(50_000, "# h\n")], + ["wide table", "|" + sizes(5000, "a|") + "\n|" + sizes(5000, "-|") + "\n" + sizes(2000, "|" + sizes(100, "x|") + "\n")], + ["long table", "|a|b|\n|-|-|\n" + sizes(50_000, "|1|2|\n")], + ["html storm", sizes(30_000, "
      Hi & bye')); + expect(shownText(o.host)).toBe( + ['', "", " ", ' ', " Hi & bye", " ", ""].join("\n"), + ); + expect(o.host.querySelector("script,feed,entry,title")).toBeNull(); + expect(o.warnings).toEqual([]); + }); + + it("notes markup that is not well formed", async () => { + const o = await open("bad.xml", enc("")); + expect(o.warnings).toEqual([{ key: "text_not_xml", count: 1 }]); + const plain = await open("no.xml", enc("just words")); + expect(shownText(plain.host)).toBe("just words"); + expect(plain.warnings).toEqual([{ key: "text_not_xml", count: 1 }]); + }); + + it("does not show a SVG that way unless it is named as XML, and never draws it", async () => { + const svg = ''; + const o = await open("x.xml", enc(svg)); + expect(o.host.querySelector("svg,script")).toBeNull(); + }); +}); + +describe("HTML", () => { + it("is source text, with a note, and nothing in it is live", async () => { + const html = 'x'; + for (const name of ["page.html", "page.htm", "page.xhtml"]) { + const o = await open(name, enc(html)); + expect(shownText(o.host), name).toBe(html); + expect(o.warnings, name).toEqual([{ key: "text_html_source", count: 1 }]); + expect(o.host.querySelector("script,img,a,html,body,iframe,link,style"), name).toBeNull(); + } + }); +}); + +describe("Markdown", () => { + it("draws the document", async () => { + const o = await open("README.md", enc("# Title\n\nSome *text* with [a link](https://example.com).\n\n- one\n- two\n")); + expect(o.host.querySelector("h1")!.textContent).toBe("Title"); + expect(o.host.querySelector("em")!.textContent).toBe("text"); + expect(o.host.querySelector(".exav-viewer-text-link")!.textContent).toBe("a link"); + expect(o.host.querySelectorAll("li").length).toBe(2); + }); + + it("follows an http or https link on a click or Enter, as a PDF's links are, and no other", async () => { + const opened: unknown[][] = []; + const real = window.open; + window.open = ((...args: unknown[]) => { + opened.push(args); + return null; + }) as typeof window.open; + try { + const o = await open("links.md", enc("[a](https://example.com/a) [b](http://example.com/b) [m](mailto:me@example.com) [x](javascript:alert(1))\n")); + const links = [...o.host.querySelectorAll(".exav-viewer-text-link")]; + expect(links.map((l) => l.textContent)).toEqual(["a", "b", "https://example.com/c", "m"]); + expect(links.map((l) => l.getAttribute("role"))).toEqual(["link", "link", "link", null]); + expect(o.host.querySelector("a")).toBeNull(); + links[0]!.click(); + links[1]!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + links[1]!.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true })); + links[2]!.click(); + // The mail address is only text, and nothing else is a link. + links[3]!.click(); + expect(opened).toEqual([ + ["https://example.com/a", "_blank", "noopener,noreferrer"], + ["http://example.com/b", "_blank", "noopener,noreferrer"], + ["https://example.com/c", "_blank", "noopener,noreferrer"], + ]); + } finally { + window.open = real; + } + }); + + it("makes nothing live of a hostile document", async () => { + const md = [ + "# ", + "", + "[x](javascript:alert(1)) [y](data:text/html,x) [z](/relative) [w](https://a.example@b.example/)", + "![tracker](https://evil.example/pixel.png) ![d](data:image/svg+xml;base64,PHN2Zz4=)", + "", + ].join("\n\n"); + const o = await open("evil.md", enc(md)); + expect(o.host.querySelector("a,img,script,iframe,svg,object,embed,link,style,form")).toBeNull(); + expect(o.host.querySelector(".exav-viewer-text-link")).toBeNull(); + for (const e of o.host.querySelectorAll("*")) for (const a of e.getAttributeNames()) expect(["class", "tabindex", "role"], a).toContain(a); + expect(shownText(o.host)).toContain(""); + expect(shownText(o.host)).toContain("[tracker]"); + }); + + it("shows the text as it is, and says so, when the reader fails", async () => { + engine.fail = true; + try { + const o = await open("broken.md", enc("# Title\n\n*text*\n")); + expect(o.warnings).toEqual([{ key: "text_markdown_failed", count: 1 }]); + expect(o.host.querySelector("h1,em")).toBeNull(); + expect(shownText(o.host)).toBe("# Title\n\n*text*"); + expect(o.statuses.at(-1)).toEqual({ phase: "ready" }); + } finally { + engine.fail = false; + } + }); + + it("cuts a long line and says so", async () => { + const o = await open("long.md", enc(`${"word ".repeat(MAX_LINE)}\n\nend`)); + expect(o.warnings).toContainEqual({ key: "text_long_lines", count: 1 }); + expect(o.host.querySelector("p:last-child")!.textContent).toBe("end"); + }); +}); + +describe("diffs and subtitles", () => { + it("colours added, removed and hunk lines by class and leaves the text as it is", async () => { + const diff = ["diff --git a/f b/f", "index 1..2 100644", "--- a/f", "+++ b/f", "@@ -1,2 +1,2 @@", " same", "-old", "+new"].join("\n"); + const o = await open("fix.patch", enc(diff)); + expect(shownText(o.host)).toBe(diff); + const cls = (c: string) => [...o.host.querySelectorAll(`.${c}`)].map((e) => e.textContent); + expect(cls("exav-viewer-text-add")).toEqual(["+new"]); + expect(cls("exav-viewer-text-del")).toEqual(["-old"]); + expect(cls("exav-viewer-text-hunk")).toEqual(["@@ -1,2 +1,2 @@"]); + expect(cls("exav-viewer-text-meta")).toEqual(["diff --git a/f b/f", "index 1..2 100644", "--- a/f", "+++ b/f"]); + }); + + it("shows subtitles, YAML and source code as plain text", async () => { + const srt = "1\n00:00:01,000 --> 00:00:02,500\nHello there\n"; + const o = await open("film.srt", enc(srt)); + expect(shownText(o.host)).toBe(srt.trimEnd()); + expect(o.host.querySelector("i")).toBeNull(); + }); +}); diff --git a/crates/exav-viewer/ts/text/renderer.ts b/crates/exav-viewer/ts/text/renderer.ts new file mode 100644 index 00000000..55df10fa --- /dev/null +++ b/crates/exav-viewer/ts/text/renderer.ts @@ -0,0 +1,204 @@ +import { TEXT_KINDS } from "../core/formats.js"; +import { writable } from "../core/store.js"; +import type { Renderer } from "../core/types.js"; +import { clip, decodeText, MAX_BYTES, MAX_LINE, MAX_LINES, sanitize, splitLines } from "./decode.js"; +import { formatJson } from "./json.js"; +import { lineLength, type Line } from "./lines.js"; +import type { Parsed } from "./markdown.js"; +import { readMarkdown } from "./markdown-engine.js"; +import { drawBlocks } from "./markdown-view.js"; +import { formatXml } from "./xml.js"; + +export type TextKind = keyof typeof TEXT_KINDS; + +/** How `path` (or, failing that, `type`) says the text is to be shown. */ +export function kindOf(path: string, type = ""): TextKind { + const p = path.toLowerCase(); + const t = type.toLowerCase().split(";")[0]!.trim(); + const kinds = Object.entries(TEXT_KINDS) as [TextKind, (typeof TEXT_KINDS)[TextKind]][]; + return ( + kinds.find(([, m]) => m.extensions.some((e) => p.endsWith(e)))?.[0] ?? kinds.find(([, m]) => m.types.includes(t))?.[0] ?? "plain" + ); +} + +/** Lines drawn together, in one element that the browser may skip while it is off screen. */ +const CHUNK = 200; +/** Chunks drawn before the first paint; the others follow without blocking. */ +const FIRST_CHUNKS = 5; +/** Time the following chunks may take before the page gets a turn. */ +const SLICE_MS = 12; + +/** A diff line's class: what it adds, removes or introduces. */ +function diffClass(line: string): string | undefined { + if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("diff ") || line.startsWith("index ")) return "exav-viewer-text-meta"; + if (line.startsWith("@@")) return "exav-viewer-text-hunk"; + if (line[0] === "+") return "exav-viewer-text-add"; + if (line[0] === "-") return "exav-viewer-text-del"; + return undefined; +} + +/** Draws `lines[from, to)` as one block of text. */ +function chunk(lines: Line[], from: number, to: number): HTMLElement { + const pre = document.createElement("pre"); + pre.className = "exav-viewer-text-chunk"; + let buffer = ""; + const flush = () => { + if (buffer) pre.appendChild(document.createTextNode(buffer)); + buffer = ""; + }; + for (let i = from; i < to; i++) { + if (i > from) buffer += "\n"; + const line = lines[i]!; + if (typeof line === "string") { + buffer += line; + continue; + } + for (const [text, cls] of line) { + if (!cls) buffer += text; + else { + flush(); + const span = document.createElement("span"); + span.className = cls; + span.textContent = text; + pre.appendChild(span); + } + } + } + flush(); + return pre; +} + +/** `line` cut to `MAX_LINE` characters; null when it already fits. */ +function clipped(line: Line): Line | null { + if (lineLength(line) <= MAX_LINE) return null; + if (typeof line === "string") return clip(line); + const out: [string, string?][] = []; + let room = MAX_LINE; + for (const [text, cls] of line) { + if (room <= 0) break; + out.push([clip(text, room), cls]); + room -= text.length; + } + return out; +} + +export const renderer: Renderer> = { + async mount(host, ctx) { + ctx.status({ phase: "loading" }); + const blob = await ctx.source.blob(); + const partial = blob.size > MAX_BYTES; + const bytes = new Uint8Array(await blob.slice(0, MAX_BYTES).arrayBuffer()); + if (ctx.signal.aborted) throw new DOMException("aborted", "AbortError"); + const decoded = decodeText(bytes, partial); + if (!decoded) { + ctx.status({ phase: "error", error: { code: "text_binary" } }); + return { controllers: {}, destroy() {} }; + } + + const kind = kindOf(ctx.file.path ?? ctx.file.name, ctx.file.type); + const text = sanitize(decoded.text); + const warnings: { key: string; count: number }[] = []; + let truncated = partial; + let lines: Line[]; + let markdown: Parsed | null = null; + + const plain = () => { + const split = splitLines(text); + truncated ||= split.more; + return split.lines; + }; + if (kind === "markdown") { + const split = splitLines(text); + truncated ||= split.more; + lines = split.lines.map((l) => clip(l)); + try { + markdown = await readMarkdown((lines as string[]).join("\n"), ctx.signal); + truncated ||= markdown.truncated; + } catch { + if (ctx.signal.aborted) throw new DOMException("aborted", "AbortError"); + // The reader failed (its worker was stopped, or the module did not load): the text is shown as it is. + warnings.push({ key: "text_markdown_failed", count: 1 }); + } + } else if (kind === "json") { + // A cut file is not valid JSON: it is not judged. + const r = partial ? null : formatJson(text); + if (r) { + lines = r.lines; + truncated ||= r.more; + } else { + lines = plain(); + if (!partial) warnings.push({ key: "text_not_json", count: 1 }); + } + } else if (kind === "xml") { + const r = formatXml(text); + if (r) { + lines = r.lines; + truncated ||= r.more; + if (!r.wellFormed && !partial) warnings.push({ key: "text_not_xml", count: 1 }); + } else { + lines = plain(); + warnings.push({ key: "text_not_xml", count: 1 }); + } + } else if (kind === "diff") { + lines = plain().map((l): Line => { + const cls = diffClass(l); + return cls ? [[l, cls]] : l; + }); + } else { + lines = plain(); + if (kind === "html") warnings.push({ key: "text_html_source", count: 1 }); + } + // The newline that ends the file is not a line. + if (lines.length > 1 && lineLength(lines[lines.length - 1]!) === 0) lines.pop(); + + let cut = 0; + if (!markdown) { + lines = lines.map((l) => { + const c = clipped(l); + if (c) cut++; + return c ?? l; + }); + } else cut = (lines as string[]).filter((l) => l.length > MAX_LINE).length; + if (truncated) warnings.push({ key: "text_truncated", count: Math.min(lines.length, MAX_LINES) }); + if (cut) warnings.push({ key: "text_long_lines", count: cut }); + + const root = document.createElement("div"); + root.className = `exav-viewer-text exav-viewer-text-${kind}`; + root.tabIndex = 0; + host.append(root); + let destroyed = false; + let timer: ReturnType | undefined; + + if (markdown) drawBlocks(root, markdown.blocks); + else { + let at = 0; + const next = (count: number) => { + for (let k = 0; k < count && at < lines.length; k++) { + root.appendChild(chunk(lines, at, Math.min(at + CHUNK, lines.length))); + at += CHUNK; + } + }; + next(FIRST_CHUNKS); + const more = () => { + timer = undefined; + if (destroyed || at >= lines.length) return; + // As many blocks as fit in a slice of time, so that the page stays responsive. + const start = performance.now(); + do next(1); + while (at < lines.length && performance.now() - start < SLICE_MS); + timer = setTimeout(more, 0); + }; + if (at < lines.length) timer = setTimeout(more, 0); + } + + ctx.status({ phase: "ready" }); + return { + controllers: { warnings: writable(warnings) }, + destroy() { + destroyed = true; + if (timer !== undefined) clearTimeout(timer); + root.remove(); + }, + }; + }, +}; diff --git a/crates/exav-viewer/ts/text/xml.test.ts b/crates/exav-viewer/ts/text/xml.test.ts new file mode 100644 index 00000000..7c0fb48e --- /dev/null +++ b/crates/exav-viewer/ts/text/xml.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { lineText } from "./lines.js"; +import { formatXml } from "./xml.js"; + +const show = (text: string, max?: number) => { + const r = formatXml(text, max); + return r && r.lines.map(lineText).join("\n"); +}; + +describe("formatXml", () => { + it("indents elements and keeps text on its element's line", () => { + const out = show('Hello'); + expect(out).toBe( + ['', "", ' ', " Hello", " ", " ", " ", " ", ""].join("\n"), + ); + }); + + it("puts long or multi-line text on lines of its own", () => { + expect(show("one\n two")).toBe(["", " one", " two", ""].join("\n")); + const long = "x".repeat(300); + expect(show(`${long}`)).toBe(["", ` ${long}`, ""].join("\n")); + }); + + it("shows CDATA, comments, processing instructions and a DOCTYPE as written", () => { + const out = show('y">]>&a;]]>')!; + expect(out).toBe(['y">]>', "", " &a;]]>", " ", ""].join("\n")); + }); + + it("never expands an entity or follows an external one", () => { + const xxe = ']>&xxe; &lol2; <script>'; + const out = show(xxe)!; + expect(out).toContain(''); + expect(out).toContain("&xxe; &lol2; <script>"); + expect(out).not.toContain("

      Click here to https://www.paypal.com/login

      \"a
      ab
      ") +put("fragment.html", 1, "Hi run
      ♥¬it;A&zzz;") +put("document.xml", 2, 'HelloHYPERLINK "http://x"gone world & A') +put("sheet.xml", 3, '0HYPERLINK("http://x","y")z') +put("shared.xml", 4, "onetwo") +put("slide.xml", 5, "TitleBody") +put("content.xml", 6, "Heada bcdx") +content = "BT /F1 12 Tf 72 700 Td [(Pay to ev) 10 (il.exa) 5 (mple)] TJ 0 -20 Td (Second line) Tj ET" +pdf = (f"%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n" + f"3 0 obj<>>>/Contents 4 0 R>>endobj\n" + f"4 0 obj<>\nstream\n{content}\nendstream\nendobj\n" + f"5 0 obj<>endobj\ntrailer<>\n%%EOF\n") +put("page.pdf", 7, pdf) +z = zlib.compress(content.encode()) +put("flate.pdf", 7, (f"%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n" + f"3 0 obj<>>>/Contents 4 0 R>>endobj\n" + f"4 0 obj<>\nstream\n").encode() + z + + b"\nendstream\nendobj\n5 0 obj<>endobj\ntrailer<>\n%%EOF\n") +put("mtext.dxf", 8, " 0\nSECTION\n 2\nENTITIES\n 0\nMTEXT\n 8\n0\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 40\n2.5\n 1\nPay\\Pnow\n 0\nENDSEC\n 0\nEOF\n") +for pick, seeds in ((8, "cad_dxf"), (9, "cad_dwg")): + for path in sorted(glob.glob(f"{out}/{seeds}/*"))[:6]: + put("cad-" + os.path.basename(path), pick, open(path, "rb").read()) +PY + +# The sheets targets: the `sheets` fixtures, and for sheets_numbers the +# fixture's IWA streams joined, which the target stores in a ZIP itself (a +# joined stream is one stream to the reader), and a Numbers '09 index.xml, +# plain and gzipped, which it stores the same way. +SHEETS=crates/exav-render/tests/fixtures/office/sheets +for f in "$SHEETS/sample.xls"; do add "$f" "$OUT/sheets_xls"; done +for f in "$SHEETS/sample.ods" "$SHEETS/sample.fods"; do add "$f" "$OUT/sheets_ods"; done +for f in "$SHEETS/sample.numbers" fuzz/seeds/sheets_numbers/*; do add "$f" "$OUT/sheets_numbers"; done +python3 - "$SHEETS/sample.numbers" "$OUT/sheets_numbers/sample.iwa" fuzz/seeds/sheets_numbers/index09.xml "$OUT/sheets_numbers/index09.xml.gz" <<'PY' +import gzip, sys, zipfile +z = zipfile.ZipFile(sys.argv[1]) +open(sys.argv[2], "wb").write(b"".join(z.read(n) for n in z.namelist() if n.endswith(".iwa"))) +open(sys.argv[4], "wb").write(gzip.compress(open(sys.argv[3], "rb").read(), mtime=0)) +PY +# sheets_text: the text fixtures behind the byte that picks their reader. +python3 - "$OUT/sheets_text" <<'PY' +import sys +d = "crates/exav-render/tests/fixtures/office/sheets/" +for pick, name in ((0, "sample-2003.xml"), (1, "sample.html"), (2, "sample.slk")): + open(f"{sys.argv[1]}/{name}", "wb").write(bytes([pick]) + open(d + name, "rb").read()) +PY + +# The slides targets: the `slides` fixtures. slides_ppt gets the PPT's +# `PowerPoint Document` stream, which the target stores in a compound file +# itself (the fixture is over MAX_BYTES with its thumbnail); slides_keynote the +# Keynote document's IWA streams joined, but its package metadata. +SLIDES=crates/exav-render/tests/fixtures/office/slides +for f in "$SLIDES/sample.odp" "$SLIDES/sample.fodp" "$SLIDES/chart.fodp"; do add "$f" "$OUT/slides_odp"; done +DRAW=crates/exav-render/tests/fixtures/office/draw +for f in "$DRAW/sample.odg" "$DRAW/sample.fodg"; do add "$f" "$OUT/slides_odg"; done +for f in "$DRAW/sample.vsdx" "$DRAW/themed.vsdx"; do add "$f" "$OUT/slides_vsdx"; done +# Keynote 1 to 5's XML and a PowerPoint 95 stream, written by hand. +for f in fuzz/seeds/slides_keynote/*; do add "$f" "$OUT/slides_keynote"; done +add fuzz/seeds/slides_ppt/powerpoint95.bin "$OUT/slides_ppt" +python3 - "$SLIDES" "$OUT" <<'PY' +import struct, sys, zipfile +d, out = sys.argv[1], sys.argv[2] +z = zipfile.ZipFile(f"{d}/sample.key") +iwa = [n for n in z.namelist() if n.endswith(".iwa") and not n.endswith("/Metadata.iwa")] +open(f"{out}/slides_keynote/sample.iwa", "wb").write(b"".join(z.read(n) for n in iwa)) +# The one stream, from the FAT: version 3, its first 109 FAT sectors. +b = open(f"{d}/sample.ppt", "rb").read() +ss = 1 << struct.unpack_from(" OUT/list.txt; node pdfjs.mjs OUT/list.txt OUT/png-pdfjs 3 > OUT/pdfjs.jsonl` (needs the viewer's `node_modules`: `EXAV_VIEWER_DIR`) | +| poppler | `python3 run_poppler.py CORPUS OUT 3` (needs `pdftoppm`) | +| hayro, in wasm, in Chromium | `cd hayro-wasm && cargo rustc --release --target wasm32-unknown-unknown --crate-type cdylib` (add `RUSTFLAGS="-C target-feature=+simd128"` for the SIMD build), `wasm-bindgen --target web`, `wasm-opt -Oz --enable-simd`, then `node hayro-wasm.mjs OUT/list.txt 3 > OUT/hayrowasm.jsonl` (`HAYRO_WASM_PKG`) | +| compare | `python3 compare3.py OUT` (numpy, Pillow) | + +`run_hayro.py` rewrites its output whole every 25 PDFs, as the scripts here never append. + +The corpus of the evaluation is the 990 PDFs of `test/pdfs` in mozilla/pdf.js, the 364 of +`hayro-tests/pdfs` in LaurenzV/hayro, and 16 of our own, deduplicated by hash. Both projects' corpora are +regression tests chosen for hard cases, not a sample of PDFs in the wild. diff --git a/scripts/pdf-compare/compare3.py b/scripts/pdf-compare/compare3.py new file mode 100644 index 00000000..ee4ffaf7 --- /dev/null +++ b/scripts/pdf-compare/compare3.py @@ -0,0 +1,128 @@ +"""python3 -I compare3.py OUT: hayro, pdf.js and poppler on the same pages (first three of each PDF, 72 dpi). + +Poppler is the reference: a page's distance to it is the mean absolute luminance difference after a 3x3 box +blur (0 to 255) and the share of pixels differing by more than 64 ("bad"). Pages over 25 million pixels are +left out of the pixel comparison. Prints the figures the report quotes and writes OUT/compare3.json. +""" +import collections, json, os, re, sys +import numpy as np +from PIL import Image + +Image.MAX_IMAGE_PIXELS = None +out = sys.argv[1] +names = ["hayro", "pdfjs", "poppler"] + +def read(n): + rows = {} + for l in open(f"{out}/{n}.jsonl"): + l = l.strip() + if l.startswith("{"): + try: + r = json.loads(l) + except Exception: + continue + rows.setdefault(r["pdf"], []).append(r) + return rows + +logs = {n: read(n) for n in names} +pdfs = sorted(set().union(*[set(v) for v in logs.values()])) + +def doc_state(n, pdf): + rs = logs[n].get(pdf, []) + if any(r["page"] >= 1 and r["status"] == "ok" for r in rs): + return "ok" + for r in rs: + if r["page"] == 0: + return r["status"].split(":")[0].split("(")[0] + return "none" + +print(f"PDFs: {len(pdfs)}") +for n in names: + c = collections.Counter(doc_state(n, p) for p in pdfs) + print(f" {n}: " + ", ".join(f"{k} {v}" for k, v in c.most_common())) +both_ok_ref = [p for p in pdfs if doc_state("poppler", p) == "ok"] +print(f" where poppler renders ({len(both_ok_ref)}): hayro fails {sum(1 for p in both_ok_ref if doc_state('hayro', p) != 'ok')}, pdf.js fails {sum(1 for p in both_ok_ref if doc_state('pdfjs', p) != 'ok')}") +print(f" where poppler fails ({len(pdfs) - len(both_ok_ref)}): hayro renders {sum(1 for p in pdfs if doc_state('poppler', p) != 'ok' and doc_state('hayro', p) == 'ok')}, pdf.js renders {sum(1 for p in pdfs if doc_state('poppler', p) != 'ok' and doc_state('pdfjs', p) == 'ok')}") + +pages = collections.defaultdict(dict) +for n in names: + for pdf, rs in logs[n].items(): + for r in rs: + if r["page"] >= 1 and r["status"] == "ok": + pages[(pdf, r["page"])][n] = r + +def lum(n, pdf, page): + return np.asarray(Image.open(f"{out}/png-{n}/{pdf}-{page}.png").convert("L"), dtype=np.float32) + +def blur(x): + p = np.pad(x, 1, mode="edge") + return sum(p[i:i + x.shape[0], j:j + x.shape[1]] for i in range(3) for j in range(3)) / 9.0 + +res = {n: [] for n in ("hayro", "pdfjs")} +size = {n: collections.Counter() for n in ("hayro", "pdfjs")} +for key, r in sorted(pages.items()): + if "poppler" not in r: + continue + pw, ph = r["poppler"]["w"], r["poppler"]["h"] + for n in ("hayro", "pdfjs"): + if n not in r: + continue + w, h = r[n]["w"], r[n]["h"] + if (w, h) == (pw, ph): + size[n]["same"] += 1 + elif (w, h) == (ph, pw): + size[n]["rotated"] += 1 + elif abs(w - pw) <= 1 and abs(h - ph) <= 1: + size[n]["off by one"] += 1 + else: + size[n]["different"] += 1 +for n in size: + print(f" page size vs poppler, {n}: " + ", ".join(f"{k} {v}" for k, v in size[n].items())) + +per = {} +for key, r in sorted(pages.items()): + if not all(n in r for n in names): + continue + sizes = {(r[n]["w"], r[n]["h"]) for n in names} + if len(sizes) != 1: + continue + w, h = next(iter(sizes)) + if w * h > 25_000_000: + continue + ref = blur(lum("poppler", *key)) + for n in ("hayro", "pdfjs"): + d = np.abs(blur(lum(n, *key)) - ref) + res[n].append((key, float(d.mean()), float((d > 64).mean()))) + per[key] = {n: res[n][-1][1:] for n in res} + +k = len(res["hayro"]) +print(f" pages with the same size in all three: {k}") +for n in res: + mad = np.array([x[1] for x in res[n]]); bad = np.array([x[2] for x in res[n]]) + print(f" {n} vs poppler: MAD median {np.median(mad):.2f}, p90 {np.percentile(mad, 90):.2f}; pages <0.5% pixels far {int((bad < .005).sum())} ({100 * (bad < .005).mean():.1f}%), 0.5-5% {int(((bad >= .005) & (bad < .05)).sum())}, over 5% {int((bad >= .05).sum())} ({100 * (bad >= .05).mean():.1f}%)") +hb = np.array([x[2] for x in res["hayro"]]); pb = np.array([x[2] for x in res["pdfjs"]]) +print(f" closer to poppler by 'bad' (more than 1 point apart): hayro {int((hb + .01 < pb).sum())}, pdf.js {int((pb + .01 < hb).sum())}, level {int((abs(hb - pb) <= .01).sum())}") + +# By what the file name says it tests. +cats = {"shading": "shad", "tiling/pattern": "patt|tiling", "jpx": "jpx|jpeg2000|jp2", "jbig2": "jbig2", "ccitt": "ccitt|fax", "mask/blend/transparency": "mask|blend|transp|smask|alpha", "font": "font|glyph|type1|type3|cff|truetype|cid|cmap", "annotation/form": "annot|form|widget|acro"} +print(" by name (pages <0.5% far from poppler / pages):") +for c, rx in cats.items(): + sel = [i for i, x in enumerate(res["hayro"]) if re.search(rx, x[0][0].lower())] + if sel: + print(f" {c}: hayro {sum(1 for i in sel if res['hayro'][i][2] < .005)}/{len(sel)}, pdf.js {sum(1 for i in sel if res['pdfjs'][i][2] < .005)}/{len(sel)}") + +# Time. +for n in ("hayro", "pdfjs", "poppler"): + ms = np.array([r[n]["ms"] for r in pages.values() if n in r]) + print(f" render ms per page, {n}: median {np.median(ms):.0f}, p90 {np.percentile(ms, 90):.0f}, p99 {np.percentile(ms, 99):.0f}, max {ms.max():.0f}, total {ms.sum() / 1000:.0f} s over {len(ms)} pages") +rss = [r["rss_kb"] for rs in logs["hayro"].values() for r in rs if r["page"] == -1 and r.get("rss_kb")] +print(f" hayro process peak RSS: median {np.median(rss) / 1024:.0f} MB, p99 {np.percentile(rss, 99) / 1024:.0f} MB, max {max(rss) / 1024:.0f} MB") +worst = sorted(per.items(), key=lambda kv: -kv[1]["hayro"][1])[:8] +print(" hayro's worst against poppler (hayro bad / pdf.js bad):") +for (pdf, page), v in worst: + print(f" {pdf} p{page}: {v['hayro'][1] * 100:.1f}% / {v['pdfjs'][1] * 100:.1f}%") +worst = sorted(per.items(), key=lambda kv: -kv[1]["pdfjs"][1])[:8] +print(" pdf.js's worst against poppler (pdf.js bad / hayro bad):") +for (pdf, page), v in worst: + print(f" {pdf} p{page}: {v['pdfjs'][1] * 100:.1f}% / {v['hayro'][1] * 100:.1f}%") +json.dump({f"{k[0]}:{k[1]}": v for k, v in per.items()}, open(f"{out}/compare3.json", "w")) diff --git a/scripts/pdf-compare/hayro-eval/Cargo.toml b/scripts/pdf-compare/hayro-eval/Cargo.toml new file mode 100644 index 00000000..a782ff6d --- /dev/null +++ b/scripts/pdf-compare/hayro-eval/Cargo.toml @@ -0,0 +1,13 @@ +# Not part of the workspace: a harness (see ../README.md), with its own lock and target directory. +[package] +name = "hayro-eval" +version = "0.0.0" +edition = "2024" +publish = false + +[workspace] + +[dependencies] +hayro = "=0.8.0" +hayro-interpret = "=0.8.0" +hayro-syntax = "=0.8.0" diff --git a/scripts/pdf-compare/hayro-eval/src/main.rs b/scripts/pdf-compare/hayro-eval/src/main.rs new file mode 100644 index 00000000..6af9931e --- /dev/null +++ b/scripts/pdf-compare/hayro-eval/src/main.rs @@ -0,0 +1,76 @@ +// hayro-eval PDF OUTDIR MAXPAGES: renders the first MAXPAGES pages of PDF at scale 1 (one point to +// one pixel, as pdf.js's viewport at scale 1) on white, writes OUTDIR/-.png and prints +// one JSON line per page: ms to render, size, or the failure. +use hayro::{PixmapSettings, RenderCache, RenderSettings, render}; +use hayro_interpret::InterpreterSettings; +use hayro_syntax::Pdf; +use std::time::Instant; + +fn json(s: &str) -> String { + s.chars() + .flat_map(|c| match c { + '"' => vec!['\\', '"'], + '\\' => vec!['\\', '\\'], + c if (c as u32) < 0x20 => vec![' '], + c => vec![c], + }) + .collect() +} + +fn main() { + let a: Vec = std::env::args().collect(); + let (path, out, max) = (&a[1], &a[2], a[3].parse::().unwrap()); + let stem = std::path::Path::new(path) + .file_stem() + .unwrap() + .to_string_lossy() + .to_string(); + let data = std::fs::read(path).unwrap(); + let t0 = Instant::now(); + let pdf = match Pdf::new(data) { + Ok(p) => p, + Err(e) => { + println!( + "{{\"pdf\":\"{}\",\"page\":0,\"status\":\"load: {}\"}}", + json(&stem), + json(&format!("{e:?}")) + ); + return; + } + }; + let load_ms = t0.elapsed().as_secs_f64() * 1000.0; + let pages = pdf.pages(); + let cache = RenderCache::new(); + let settings = InterpreterSettings::default(); + let render_settings = RenderSettings::default(); + for (i, page) in pages.iter().enumerate().take(max) { + let t = Instant::now(); + let pix = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let ps = PixmapSettings { + bg_color: hayro::vello_cpu::color::palette::css::WHITE, + ..PixmapSettings::default() + }; + render(page, &cache, &settings, &render_settings, &ps) + })); + let ms = t.elapsed().as_secs_f64() * 1000.0; + match pix { + Ok(p) => { + let (w, h) = (p.width(), p.height()); + let png = p.into_png().unwrap_or_default(); + let file = format!("{out}/{stem}-{}.png", i + 1); + std::fs::write(&file, &png).unwrap(); + println!( + "{{\"pdf\":\"{}\",\"page\":{},\"pages\":{},\"w\":{w},\"h\":{h},\"ms\":{ms:.1},\"load_ms\":{load_ms:.1},\"status\":\"ok\"}}", + json(&stem), + i + 1, + pages.len() + ); + } + Err(_) => println!( + "{{\"pdf\":\"{}\",\"page\":{},\"status\":\"panic\"}}", + json(&stem), + i + 1 + ), + } + } +} diff --git a/scripts/pdf-compare/hayro-wasm.mjs b/scripts/pdf-compare/hayro-wasm.mjs new file mode 100644 index 00000000..af1ba442 --- /dev/null +++ b/scripts/pdf-compare/hayro-wasm.mjs @@ -0,0 +1,77 @@ +// node hayro-wasm.mjs LIST MAXPAGES: hayro compiled to wasm (renderPage), run in Chromium over the first +// MAXPAGES pages of each PDF named in LIST; one JSON line per page: ms, size, wasm memory after, or failure. +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const root = process.env.EXAV_VIEWER_DIR ?? path.resolve(import.meta.dirname, "../../crates/exav-viewer"); +const require = createRequire(root + "/package.json"); +const { chromium } = require("playwright"); +// wasm-bindgen's --target web output of hayro-wasm, with hayro_wasm_bg.opt.wasm made by wasm-opt. +const pkg = process.env.HAYRO_WASM_PKG ?? path.resolve(import.meta.dirname, "hayro-wasm/pkg"); +const [list, maxArg] = process.argv.slice(2); +const max = Number(maxArg); +const files = fs.readFileSync(list, "utf8").split("\n").filter(Boolean); + +const server = http.createServer((req, res) => { + const url = decodeURIComponent(req.url.split("?")[0]); + let file; + if (url === "/") { + res.setHeader("content-type", "text/html"); + return res.end(""); + } + if (url.startsWith("/pkg/")) file = path.join(pkg, url.slice(5)); + else if (url.startsWith("/file/")) file = files[Number(url.slice(6))]; + else return res.writeHead(404).end(); + fs.readFile(file, (e, d) => { + if (e) return res.writeHead(404).end(); + res.setHeader("content-type", file.endsWith(".js") ? "text/javascript" : file.endsWith(".wasm") ? "application/wasm" : "application/octet-stream"); + res.end(d); + }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +const base = `http://127.0.0.1:${server.address().port}`; +const browser = await chromium.launch(); +for (let n = 0; n < files.length; n++) { + const stem = path.basename(files[n], path.extname(files[n])); + const page = await (await browser.newContext()).newPage(); + const timeout = setTimeout(() => page.close().catch(() => {}), 150000); + try { + await page.goto(base + "/"); + const results = await page.evaluate( + async ({ base, n, max }) => { + const m = await import(base + "/pkg/hayro_wasm.js"); + const wasm = await m.default({ module_or_path: base + "/pkg/hayro_wasm_bg.opt.wasm" }); + const buf = new Uint8Array(await (await fetch(base + "/file/" + n)).arrayBuffer()); + let count; + try { + count = m.pageCount(buf); + } catch (e) { + return [{ page: 0, status: "load: " + String(e && e.message ? e.message : e) }]; + } + const out = []; + for (let i = 0; i < Math.min(max, count); i++) { + const t = performance.now(); + try { + const r = m.renderPage(buf, i); + const v = new DataView(r.buffer, r.byteOffset); + out.push({ page: i + 1, pages: count, w: v.getUint32(0, true), h: v.getUint32(4, true), ms: +(performance.now() - t).toFixed(1), mem: wasm.memory.buffer.byteLength, status: "ok" }); + } catch (e) { + out.push({ page: i + 1, status: "render: " + String(e && e.message ? e.message : e).slice(0, 100) }); + break; + } + } + return out; + }, + { base, n, max }, + ); + for (const r of results) console.log(JSON.stringify({ pdf: stem, ...r })); + } catch (e) { + console.log(JSON.stringify({ pdf: stem, page: 0, status: "harness: " + String(e.message).slice(0, 120) })); + } + clearTimeout(timeout); + await page.context().close().catch(() => {}); +} +await browser.close(); +server.close(); diff --git a/scripts/pdf-compare/hayro-wasm/Cargo.toml b/scripts/pdf-compare/hayro-wasm/Cargo.toml new file mode 100644 index 00000000..8670d78c --- /dev/null +++ b/scripts/pdf-compare/hayro-wasm/Cargo.toml @@ -0,0 +1,24 @@ +# Not part of the workspace: a harness (see ../README.md), with its own lock and target directory. +[package] +name = "hayro-wasm" +version = "0.0.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +hayro = "=0.8.0" +hayro-interpret = "=0.8.0" +hayro-syntax = "=0.8.0" +wasm-bindgen = "=0.2.129" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/scripts/pdf-compare/hayro-wasm/src/lib.rs b/scripts/pdf-compare/hayro-wasm/src/lib.rs new file mode 100644 index 00000000..6c37f27c --- /dev/null +++ b/scripts/pdf-compare/hayro-wasm/src/lib.rs @@ -0,0 +1,38 @@ +use hayro::{PixmapSettings, RenderCache, RenderSettings, render}; +use hayro_interpret::InterpreterSettings; +use hayro_syntax::Pdf; +use wasm_bindgen::prelude::*; + +/// Page `index` (0-based) of `data` at scale 1 on white: width and height as two u32 (LE), then RGBA. +#[wasm_bindgen(js_name = renderPage)] +pub fn render_page(data: &[u8], index: u32) -> Result, JsError> { + let pdf = Pdf::new(data.to_vec()).map_err(|e| JsError::new(&format!("{e:?}")))?; + let pages = pdf.pages(); + let page = pages + .get(index as usize) + .ok_or_else(|| JsError::new("no such page"))?; + let cache = RenderCache::new(); + let ps = PixmapSettings { + bg_color: hayro::vello_cpu::color::palette::css::WHITE, + ..PixmapSettings::default() + }; + let pix = render( + page, + &cache, + &InterpreterSettings::default(), + &RenderSettings::default(), + &ps, + ); + let (w, h) = (pix.width() as u32, pix.height() as u32); + let mut out = Vec::with_capacity(8 + (w * h * 4) as usize); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + out.extend_from_slice(pix.data_as_u8_slice()); + Ok(out) +} + +#[wasm_bindgen(js_name = pageCount)] +pub fn page_count(data: &[u8]) -> Result { + let pdf = Pdf::new(data.to_vec()).map_err(|e| JsError::new(&format!("{e:?}")))?; + Ok(pdf.pages().len() as u32) +} diff --git a/scripts/pdf-compare/pdfjs.mjs b/scripts/pdf-compare/pdfjs.mjs new file mode 100644 index 00000000..28bc2004 --- /dev/null +++ b/scripts/pdf-compare/pdfjs.mjs @@ -0,0 +1,100 @@ +// node pdfjs.mjs LIST OUTDIR MAXPAGES: renders the first MAXPAGES pages of each PDF named in LIST +// (one path a line) at scale 1 on white in headless Chromium with the pdfjs-dist of the viewer, writes +// OUTDIR/-.png, prints one JSON line per page: ms to render, size, or the failure. +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { createRequire } from "node:module"; + +// The viewer's node_modules (playwright, pdfjs-dist), a Linux install of it. +const root = process.env.EXAV_VIEWER_DIR ?? path.resolve(import.meta.dirname, "../../crates/exav-viewer"); +const require = createRequire(root + "/package.json"); +const { chromium } = require("playwright"); +const pdfjs = path.join(root, "node_modules", "pdfjs-dist"); + +const [list, out, maxArg] = process.argv.slice(2); +const max = Number(maxArg); +const files = fs.readFileSync(list, "utf8").split("\n").filter(Boolean); + +const server = http.createServer((req, res) => { + const url = decodeURIComponent(req.url.split("?")[0]); + let file; + if (url === "/") { + res.setHeader("content-type", "text/html"); + return res.end(""); + } + if (url.startsWith("/pdfjs/")) file = path.join(pdfjs, url.slice(7)); + else if (url.startsWith("/file/")) file = files[Number(url.slice(6))]; + else return res.writeHead(404).end(); + fs.readFile(file, (e, d) => { + if (e) return res.writeHead(404).end(); + res.setHeader("content-type", file.endsWith(".mjs") ? "text/javascript" : "application/octet-stream"); + res.end(d); + }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +const base = `http://127.0.0.1:${server.address().port}`; + +const browser = await chromium.launch(); +for (let n = 0; n < files.length; n++) { + const stem = path.basename(files[n], path.extname(files[n])); + const page = await (await browser.newContext({ deviceScaleFactor: 1 })).newPage(); + const timeout = setTimeout(() => page.close().catch(() => {}), 120000); + try { + await page.goto(base + "/"); + const results = await page.evaluate( + async ({ base, n, max }) => { + const pdfjs = await import(base + "/pdfjs/build/pdf.mjs"); + pdfjs.GlobalWorkerOptions.workerSrc = base + "/pdfjs/build/pdf.worker.mjs"; + const out = []; + const t0 = performance.now(); + let doc; + try { + const buf = await (await fetch(base + "/file/" + n)).arrayBuffer(); + doc = await pdfjs.getDocument({ + data: new Uint8Array(buf), + cMapUrl: base + "/pdfjs/cmaps/", + cMapPacked: true, + standardFontDataUrl: base + "/pdfjs/standard_fonts/", + iccUrl: base + "/pdfjs/iccs/", + wasmUrl: base + "/pdfjs/wasm/", + isEvalSupported: false, + }).promise; + } catch (e) { + return [{ page: 0, status: "load: " + String(e && e.message ? e.message : e) }]; + } + const loadMs = performance.now() - t0; + for (let i = 1; i <= Math.min(max, doc.numPages); i++) { + try { + const p = await doc.getPage(i); + const vp = p.getViewport({ scale: 1 }); + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(vp.width); + canvas.height = Math.ceil(vp.height); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const t = performance.now(); + await p.render({ canvasContext: ctx, viewport: vp }).promise; + out.push({ page: i, pages: doc.numPages, w: canvas.width, h: canvas.height, ms: +(performance.now() - t).toFixed(1), load_ms: +loadMs.toFixed(1), status: "ok", png: canvas.toDataURL("image/png").split(",")[1] }); + } catch (e) { + out.push({ page: i, status: "render: " + String(e && e.message ? e.message : e) }); + } + } + return out; + }, + { base, n, max }, + ); + for (const r of results) { + const { png, ...line } = r; + if (png) fs.writeFileSync(`${out}/${stem}-${r.page}.png`, Buffer.from(png, "base64")); + console.log(JSON.stringify({ pdf: stem, ...line })); + } + } catch (e) { + console.log(JSON.stringify({ pdf: stem, page: 0, status: "harness: " + String(e.message).slice(0, 200) })); + } + clearTimeout(timeout); + await page.context().close().catch(() => {}); +} +await browser.close(); +server.close(); diff --git a/scripts/pdf-compare/run_hayro.py b/scripts/pdf-compare/run_hayro.py new file mode 100644 index 00000000..7e1f89ea --- /dev/null +++ b/scripts/pdf-compare/run_hayro.py @@ -0,0 +1,48 @@ +"""python3 -I run_hayro.py CORPUS OUT MAXPAGES: hayro-eval on every PDF of CORPUS, 60 s and one process each. + +Writes OUT/hayro.jsonl (the file is rewritten whole every 25 PDFs): a line per page, and per PDF a line with +the peak RSS and the wall time of the process, or "timeout" / "crash". +""" +import json, os, subprocess, sys, time + +corpus, out, maxpages = sys.argv[1], sys.argv[2], sys.argv[3] +os.makedirs(out + "/png-hayro", exist_ok=True) +exe = os.environ.get("HAYRO_EVAL") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "hayro-eval/target/release/hayro-eval") +files = sorted(f for f in os.listdir(corpus) if f.endswith(".pdf")) +lines = [] + +def flush(): + tmp = out + "/hayro.jsonl.tmp" + with open(tmp, "w") as f: + f.write("\n".join(json.dumps(l) for l in lines) + "\n") + os.replace(tmp, out + "/hayro.jsonl") + +for n, name in enumerate(files): + stem = os.path.splitext(name)[0] + t = time.time() + try: + p = subprocess.run(["/usr/bin/time", "-f", "RSS %M", exe, f"{corpus}/{name}", out + "/png-hayro", maxpages], + capture_output=True, text=True, timeout=60) + wall = time.time() - t + rss = 0 + for l in p.stderr.splitlines(): + if l.startswith("RSS "): + rss = int(l.split()[1]) + got = 0 + for l in p.stdout.splitlines(): + if l.startswith("{"): + try: + lines.append(json.loads(l)); got += 1 + except Exception: + pass + if p.returncode != 0 or got == 0: + tail = p.stderr.strip().splitlines()[-2:] if p.stderr else [] + lines.append({"pdf": stem, "page": 0, "status": f"crash({p.returncode}): " + " | ".join(tail)[:160]}) + lines.append({"pdf": stem, "page": -1, "status": "proc", "rss_kb": rss, "wall_s": round(wall, 2)}) + except subprocess.TimeoutExpired: + lines.append({"pdf": stem, "page": 0, "status": "timeout"}) + lines.append({"pdf": stem, "page": -1, "status": "proc", "rss_kb": 0, "wall_s": 60}) + if n % 25 == 24: + flush() +flush() +print("done", len(files)) diff --git a/scripts/pdf-compare/run_poppler.py b/scripts/pdf-compare/run_poppler.py new file mode 100644 index 00000000..82006c05 --- /dev/null +++ b/scripts/pdf-compare/run_poppler.py @@ -0,0 +1,38 @@ +"""python3 -I run_poppler.py CORPUS OUT MAXPAGES: pdftoppm (poppler) on every PDF, 72 dpi, white page. + +Writes OUT/poppler.jsonl and OUT/png-poppler/-.png; one line per page, and per PDF a line with +the wall time of the process (page -1). +""" +import glob, json, os, subprocess, sys, time +from PIL import Image + +Image.MAX_IMAGE_PIXELS = None +corpus, out, maxpages = sys.argv[1], sys.argv[2], sys.argv[3] +os.makedirs(out + "/png-poppler", exist_ok=True) +lines = [] +files = sorted(f for f in os.listdir(corpus) if f.endswith(".pdf")) +for n, name in enumerate(files): + stem = os.path.splitext(name)[0] + prefix = f"{out}/png-poppler/{stem}" + t = time.time() + try: + p = subprocess.run(["pdftoppm", "-r", "72", "-f", "1", "-l", maxpages, "-png", f"{corpus}/{name}", prefix], + capture_output=True, text=True, timeout=60) + wall = time.time() - t + pngs = sorted(glob.glob(prefix + "-*.png")) + for f in pngs: + page = int(f.rsplit("-", 1)[1].split(".")[0]) + # pdftoppm pads the page number to the width of the page count. + os.replace(f, f"{prefix}-{page}.png") if f != f"{prefix}-{page}.png" else None + with Image.open(f"{prefix}-{page}.png") as im: + w, h = im.size + lines.append({"pdf": stem, "page": page, "w": w, "h": h, "ms": round(1000 * wall / max(1, len(pngs)), 1), "status": "ok"}) + if not pngs: + lines.append({"pdf": stem, "page": 0, "status": f"fail({p.returncode}): " + (p.stderr.strip().splitlines() or [""])[0][:120]}) + lines.append({"pdf": stem, "page": -1, "status": "proc", "wall_s": round(wall, 2)}) + except subprocess.TimeoutExpired: + lines.append({"pdf": stem, "page": 0, "status": "timeout"}) + if n % 50 == 49: + open(out + "/poppler.jsonl", "w").write("\n".join(json.dumps(l) for l in lines) + "\n") +open(out + "/poppler.jsonl", "w").write("\n".join(json.dumps(l) for l in lines) + "\n") +print("done", len(files)) diff --git a/scripts/release.sh b/scripts/release.sh index 508db27e..c72cb7b8 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -85,7 +85,8 @@ $CARGO clippy --all-targets --features exav-core/http -- -D warnings || fail "cl $CARGO clippy -p exav-core --all-targets --features unstable-internals -- -D warnings || fail "clippy (unstable-internals)" # exav-render's drawing and model readers sit behind features no other crate turns on. $CARGO clippy -p exav-render --all-targets --features dwg -- -D warnings || fail "clippy (exav-render dwg)" -$CARGO clippy -p exav-render --all-targets --no-default-features --features ifc,stl -- -D warnings || fail "clippy (exav-render ifc,stl)" +$CARGO clippy -p exav-render --all-targets --no-default-features --features ifc,stl,obj,ply,gltf -- -D warnings || fail "clippy (exav-render ifc,stl,obj,ply,gltf)" +$CARGO clippy -p exav-render --all-targets --no-default-features --features office,office-test-util -- -D warnings || fail "clippy (exav-render office)" step "Tests" # The native matrix; `make test` additionally needs wasmtime and node. diff --git a/scripts/stack-frames.py b/scripts/stack-frames.py new file mode 100755 index 00000000..38f6b025 --- /dev/null +++ b/scripts/stack-frames.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Audits the stack frames of the functions that recurse into nested input. + +A container inside a container, a group inside a group: each level of nesting +keeps the frames of the walk that reads it on the stack while the next level is +read. A walk that holds a decoder or a large model value by value has a frame of +tens of kilobytes, and sixteen levels (more with `--max-unpack-depth`) are past +the stack of a worker thread. The size of a frame is a property of the compiled +code, not of the source, and a release build and a test build differ, so it is +read from the release binary. + + scripts/stack-frames.py target/release/exav [more binaries...] + scripts/stack-frames.py --all --min-kib 8 target/release/exav + +Exits 1 when a function that is on a recursion path has a frame over +`--max-kib` (16 by default). The functions are the WATCHED patterns below; add +the walk of a format here when it is added. + +Needs `objdump` (binutils or LLVM's, which the toolchain's `llvm-tools` has), +on aarch64 or x86_64. +""" + +import argparse +import re +import shutil +import subprocess +import sys + +# Demangled names, matched anywhere in the name. +WATCHED = [ + # The walk of each container format and the shared streaming of members. + r"exav_unpack::formats::\w+::walk\b", + r"exav_unpack::formats::\w+::walk_\w+", + r"exav_unpack::stream::", + # The scan's own recursion into members. + r"exav_core::walk_members", + r"exav_core::scan_found", + r"exav_core::scan_object", + # The converters' walks of shapes, blocks, groups and nested values. + r"exav_render::formats::office::\w+::(?:\w+::)*(?:shape|group|drawable|drawables|block|list|body|inlines|walk|find_shape)\b", + r"exav_render::formats::(?:gltf|ifc|cad)::.*::(?:value|item|curve|profile|unit|entity|document)\b", +] + +FRAME_AARCH64 = re.compile(r"\bsub\s+sp, sp, #(0x[0-9a-f]+|\d+)(?:, lsl #(\d+))?") +PROBE_AARCH64 = re.compile(r"\bsub\s+x\d+, sp, #(0x[0-9a-f]+|\d+)(?:, lsl #(\d+))?") +FRAME_X86 = re.compile(r"\bsub\s+\$(0x[0-9a-f]+),%rsp") +PROBE_X86 = re.compile(r"\blea\s+-(0x[0-9a-f]+)\(%rsp\),%r11") +PROBESTACK_X86 = re.compile(r"\bmov\s+\$(0x[0-9a-f]+),%eax") +LABEL = re.compile(r"^[0-9a-f]+ <(.+)>:$") + + +def value(match): + v = int(match.group(1), 0) + if match.lastindex and match.lastindex >= 2 and match.group(2): + v <<= int(match.group(2)) + return v + + +def frames(binary, objdump): + """Yields (frame bytes, demangled name) for every function.""" + out = subprocess.run( + [objdump, "-dC", "--no-show-raw-insn", binary], + capture_output=True, + text=True, + errors="replace", + check=True, + ).stdout + name, seen, total, probed, pending_eax = None, 0, 0, False, 0 + for line in out.splitlines(): + label = LABEL.match(line.strip()) + if label: + if name and total: + yield total, name + name, seen, total, probed, pending_eax = label.group(1), 0, 0, False, 0 + continue + if name is None: + continue + seen += 1 + if seen > 60: + if total: + yield total, name + name = None + continue + # aarch64: a frame over a page is probed in a loop whose limit is the + # whole frame, then the rest is taken off. + m = PROBE_AARCH64.search(line) + if m and not probed: + probed, total = True, total + value(m) + continue + m = FRAME_AARCH64.search(line) + if m: + v = value(m) + if not (probed and v == 4096): + total += v + continue + # x86_64: the same, `lea` for the limit or `__rust_probestack`. + m = PROBE_X86.search(line) + if m and not probed: + probed, total = True, total + int(m.group(1), 16) + continue + m = PROBESTACK_X86.search(line) + if m: + pending_eax = int(m.group(1), 16) + continue + if pending_eax and "probestack" in line: + total, pending_eax = total + pending_eax, 0 + continue + m = FRAME_X86.search(line) + if m: + v = int(m.group(1), 16) + if not (probed and v == 4096): + total += v + if name and total: + yield total, name + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("binaries", nargs="+") + ap.add_argument("--max-kib", type=int, default=16, help="the largest frame a watched function may have") + ap.add_argument("--min-kib", type=int, default=0, help="with --all, the smallest frame to list") + ap.add_argument("--all", action="store_true", help="list every function over --min-kib, watched or not") + args = ap.parse_args() + objdump = shutil.which("objdump") or shutil.which("llvm-objdump") + if not objdump: + sys.exit("stack-frames: no objdump on the PATH") + watched = [re.compile(p) for p in WATCHED] + bad = [] + for binary in args.binaries: + listed = {} + for size, name in frames(binary, objdump): + hit = any(p.search(name) for p in watched) + if args.all and size >= args.min_kib * 1024: + listed[name] = (size, hit) + if hit and size > args.max_kib * 1024: + bad.append((size, name, binary)) + for name, (size, hit) in sorted(listed.items(), key=lambda kv: -kv[1][0]): + print(f"{size // 1024:>6} KiB {'*' if hit else ' '} {name[:160]}") + for size, name, binary in sorted(bad, reverse=True): + print(f"{binary}: {size // 1024} KiB frame in {name[:200]}") + if bad: + sys.exit( + f"stack-frames: {len(bad)} watched function(s) over {args.max_kib} KiB: " + "a value held by this function stays on the stack while the next level of " + "nesting is read; move it into a function of its own, or box it" + ) + print(f"stack-frames: every watched function is within {args.max_kib} KiB") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-js.sh b/scripts/test-js.sh index 0be8fb54..574d4e8f 100755 --- a/scripts/test-js.sh +++ b/scripts/test-js.sh @@ -42,7 +42,7 @@ if [ ! -d node_modules ]; then fi echo "== vitest (pure-JS helpers) ==" -npx vitest run +npx vitest run tests/helpers.test.ts if [ "${1:-}" = "--unit" ]; then exit 0 @@ -59,6 +59,9 @@ fi # those tests do not skip — they fail, on a build that is working correctly. wasm-pack build --release --target web -- --features testing-faults +echo "== vitest (on the built pkg/) ==" +npx vitest run tests/volumes.test.ts + echo "== playwright (headless chromium, real wasm) ==" # `--with-deps` needs root and is only wanted on a fresh CI image; locally the # plain download is enough and the shared libraries are already present. diff --git a/scripts/test-www.sh b/scripts/test-www.sh index a08a6e17..b42c7c37 100755 --- a/scripts/test-www.sh +++ b/scripts/test-www.sh @@ -23,6 +23,9 @@ if ! command -v node >/dev/null 2>&1; then exit 1 fi +# The format registry against the code, and the pages generated from it. +node "$REPO/scripts/formats.mjs" check + if [ ! -d node_modules ]; then npm ci || npm install fi diff --git a/scripts/wasm-format-sizes.sh b/scripts/wasm-format-sizes.sh index 95671a14..437d6cd8 100755 --- a/scripts/wasm-format-sizes.sh +++ b/scripts/wasm-format-sizes.sh @@ -40,7 +40,7 @@ fi [package] name = "uwsz" version = "0.0.0" -edition = "2021" +edition = "2024" [[bin]] name = "uwsz" path = "src/main.rs" diff --git a/www/astro.config.mjs b/www/astro.config.mjs index a6f1ea00..6f70dd60 100644 --- a/www/astro.config.mjs +++ b/www/astro.config.mjs @@ -3,10 +3,12 @@ import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; import starlightSidebarTopics from 'starlight-sidebar-topics'; +const SITE = 'https://exav.org'; const GITHUB_REPO = 'https://github.com/sylvinus/exav'; +const OG_ALT = 'exav: Memory-safe tools for untrusted files.'; export default defineConfig({ - site: 'https://exav.org', + site: SITE, redirects: { // Folded into Design principles; keep old links working. '/concepts/never-silent/': '/scanner/concepts/design-principles/#never-a-silent-clean', @@ -88,7 +90,7 @@ export default defineConfig({ integrations: [ starlight({ title: 'exav', - tagline: 'Memory-safe tools for untrusted files.', + description: 'Memory-safe tools for untrusted files.', // Mark only: the "exav" wordmark next to it is rendered as real text by // Starlight, so it uses the site font rather than whatever the viewer's // OS happens to substitute inside an -loaded SVG. @@ -97,6 +99,22 @@ export default defineConfig({ alt: '', }, favicon: '/favicon.svg', + // Starlight already writes the description, canonical link, og:title, + // og:description, og:type, og:url, og:site_name, og:locale and + // twitter:card (summary_large_image) of every page, and the `lang` + // attribute. What it does not: the preview image (public/og.png, drawn + // by scripts/make-og-image.py) and the browser's theme colour, which is + // the dark theme's background (the site is dark only). + head: [ + { tag: 'meta', attrs: { property: 'og:image', content: `${SITE}/og.png` } }, + { tag: 'meta', attrs: { property: 'og:image:type', content: 'image/png' } }, + { tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } }, + { tag: 'meta', attrs: { property: 'og:image:height', content: '630' } }, + { tag: 'meta', attrs: { property: 'og:image:alt', content: OG_ALT } }, + { tag: 'meta', attrs: { name: 'twitter:image', content: `${SITE}/og.png` } }, + { tag: 'meta', attrs: { name: 'twitter:image:alt', content: OG_ALT } }, + { tag: 'meta', attrs: { name: 'theme-color', content: '#17181c' } }, + ], social: [ { icon: 'github', label: 'GitHub', href: GITHUB_REPO }, ], @@ -199,6 +217,7 @@ export default defineConfig({ { label: 'React UI', slug: 'viewer/react' }, { label: 'Core API', slug: 'viewer/api' }, { label: 'Security', slug: 'viewer/security' }, + { label: 'Example files', slug: 'viewer/examples' }, { label: 'Live demo', link: '/viewer/demo/', attrs: { target: '_blank' } }, ], }, @@ -233,6 +252,7 @@ export default defineConfig({ icon: 'open-book', items: [ { label: 'Technical architecture', slug: 'project/architecture' }, + { label: 'Format matrix', slug: 'project/formats' }, { label: 'Roadmap', slug: 'project/roadmap' }, { label: 'Contributing', slug: 'project/contributing' }, { label: 'Security', slug: 'project/security' }, diff --git a/www/package-lock.json b/www/package-lock.json index 9e34adb7..86140b73 100644 --- a/www/package-lock.json +++ b/www/package-lock.json @@ -8,96 +8,90 @@ "name": "exav-www", "version": "0.1.0", "dependencies": { - "@astrojs/starlight": "^0.41.4", - "astro": "^7.1.3", - "sharp": "^0.35.3", - "starlight-sidebar-topics": "0.8.0" + "@astrojs/starlight": "^0.42.7", + "astro": "^7.4.2", + "sharp": "^0.35.5", + "starlight-sidebar-topics": "0.9.0" }, "devDependencies": { - "@astrojs/check": "^0.9.9", + "@astrojs/check": "^0.9.12", "typescript": "^6.0.3" } }, - "node_modules/@astrojs/check": { - "version": "0.9.9", - "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.9.tgz", - "integrity": "sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==", + "node_modules/@astrojs/astro2tsx": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@astrojs/astro2tsx/-/astro2tsx-0.1.3.tgz", + "integrity": "sha512-GMDC7Eo0myBUA/QLBl0u+OcegJ8LiBhccmNZR34KmprQNP8gQUJlWlOOZWUsxNA2rNS6GuPYcGDyLoPnjmn4/Q==", "dev": true, "license": "MIT", "dependencies": { - "@astrojs/language-server": "^2.16.7", - "chokidar": "^4.0.3", - "kleur": "^4.1.5", - "yargs": "^17.7.2" - }, - "bin": { - "astro-check": "bin/astro-check.js" + "@emnapi/core": "1.11.3", + "@napi-rs/wasm-runtime": "1.2.4" }, - "peerDependencies": { - "typescript": "^5.0.0 || ^6.0.0" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@astrojs/check/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "node_modules/@astrojs/check": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.12.tgz", + "integrity": "sha512-hazpqxIfZWj2VhyPyR0pOEQ/IhFDM/2nIApL96zMXABdD8NrUKYDmhxTy7h+drTDl9MQG/Sd1ET/4amadVJUUA==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" + "@astrojs/language-server": "^2.17.3", + "chokidar": "^5.0.0", + "kleur": "^4.1.5", + "yargs": "^18.2.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@astrojs/check/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" + "bin": { + "astro-check": "bin/astro-check.js" }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "typescript": "^5.0.0 || ^6.0.0" } }, - "node_modules/@astrojs/compiler": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", - "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", - "dev": true, - "license": "MIT" - }, "node_modules/@astrojs/compiler-binding": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.3.1.tgz", - "integrity": "sha512-DaAUj29AIBU2XdJ8uwcab8lW5O2pk9pY8AXkcMw0sw77nVa3oeTYRcO+Dvbbpoexf6ThMc0FMWYCQ/wN1/T7oQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.5.2.tgz", + "integrity": "sha512-lEn76+0JcMQ5RDXU0wSMWJg2ZppiZvSULpghC29cfv4o9bhvWFDbQjsCtvjjXN9iVLFQ7fL3wTR2L2UU13mP8g==", "license": "MIT", "engines": { "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@astrojs/compiler-binding-darwin-arm64": "0.3.1", - "@astrojs/compiler-binding-darwin-x64": "0.3.1", - "@astrojs/compiler-binding-linux-arm64-gnu": "0.3.1", - "@astrojs/compiler-binding-linux-arm64-musl": "0.3.1", - "@astrojs/compiler-binding-linux-x64-gnu": "0.3.1", - "@astrojs/compiler-binding-linux-x64-musl": "0.3.1", - "@astrojs/compiler-binding-wasm32-wasi": "0.3.1", - "@astrojs/compiler-binding-win32-arm64-msvc": "0.3.1", - "@astrojs/compiler-binding-win32-x64-msvc": "0.3.1" + "@astrojs/compiler-binding-android-arm64": "0.5.2", + "@astrojs/compiler-binding-darwin-arm64": "0.5.2", + "@astrojs/compiler-binding-darwin-x64": "0.5.2", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.5.2", + "@astrojs/compiler-binding-linux-arm64-musl": "0.5.2", + "@astrojs/compiler-binding-linux-x64-gnu": "0.5.2", + "@astrojs/compiler-binding-linux-x64-musl": "0.5.2", + "@astrojs/compiler-binding-wasm32-wasi": "0.5.2", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.5.2", + "@astrojs/compiler-binding-win32-x64-msvc": "0.5.2" + } + }, + "node_modules/@astrojs/compiler-binding-android-arm64": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-android-arm64/-/compiler-binding-android-arm64-0.5.2.tgz", + "integrity": "sha512-kckIxon6Rm/Bxzh9mGPYpRRqUbToUQoHptCmpMmE+Yt0Xumq8CqrKUidyYhrBsrHVO7bxuRzzWUUqvpf+iYceQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@astrojs/compiler-binding-darwin-arm64": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.3.1.tgz", - "integrity": "sha512-IEmEF2fUIlTHtpeE/isyEGVOB14cEyh/LZOFYt6wn3jNyVpdC8aR5OZ+RzFUR/f+8ZDM1LaMwZKvoA7eMyJeFw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.5.2.tgz", + "integrity": "sha512-1ZG5Ar3dD8HtodX9XH1mRmE0qBXZlvjYXSFfpgFwocqfWnvhFuqtGUNseL8wgfImiRMztIjpQqlogQFQQtgiag==", "cpu": [ "arm64" ], @@ -111,9 +105,9 @@ } }, "node_modules/@astrojs/compiler-binding-darwin-x64": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.3.1.tgz", - "integrity": "sha512-GF2kIxjpPDLsn94zbZNMsxEmkU828QqnmM7kiQJnaooS3jmI+I7kk6+oI6EpwOsK3femCMdcm+wmOsEqtGrmjQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.5.2.tgz", + "integrity": "sha512-+kl35S7hiHJ2cb7eujwEsPFYYuSgE11uEE2Z20lAJ+undpgulMcW+weQqFPOSSZccEKHOiLIY+kQHkb4e4092w==", "cpu": [ "x64" ], @@ -127,9 +121,9 @@ } }, "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.3.1.tgz", - "integrity": "sha512-XJL3SDmOtVrqFhCirNcHwE91+IesJqlgNo23I4qW9QUYfwzm/TBZuH61fgqsb1ttgR1mMYz6ooPWs0JDhwMqpQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.5.2.tgz", + "integrity": "sha512-BqlR0EHNQLPLwgL9+xogjsAc7zGf0cHho2CPdhv0P1yqbVMwGVaGAqrANP6VYAXCqSC43AF4Czcxc2n+oqdesg==", "cpu": [ "arm64" ], @@ -146,9 +140,9 @@ } }, "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.3.1.tgz", - "integrity": "sha512-xqE8BVbDoBueK/B47w30PtkVofUWJKGkwoMVE+EOMLf11rnoANxIAdA9FPqY+rng4oNI5ndHGsri1yPj2k8vZQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.5.2.tgz", + "integrity": "sha512-GRGyby3AZWw5d6JQzidyxPyvGgHWsSusrpgKyFVpDTKctABF7L3Pd5V1cRJbV36/rcu+Eh292/wL7pF7MkmGkQ==", "cpu": [ "arm64" ], @@ -165,9 +159,9 @@ } }, "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.3.1.tgz", - "integrity": "sha512-1y0StU1qiCuDFH3rmbRJXcxdfHxFPrES1Rd+RLffosvUR7I2cH5SF5SFnBN9vXpzpkmyElZm3Yr47iJBPN7vVA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.5.2.tgz", + "integrity": "sha512-9XAqGTMQAtIslMsjK3xdyY6OI9d59OZqcLBB6pvD/DcoR0daYswfrKx2azdDERDY4AqAnWPC7odVM1BmxYv3Vw==", "cpu": [ "x64" ], @@ -184,9 +178,9 @@ } }, "node_modules/@astrojs/compiler-binding-linux-x64-musl": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.3.1.tgz", - "integrity": "sha512-16q0fYf7kpbmdObZEeZJEup8hQv/whgNwVjrSvT8umrKwLDSnNIWiQpm09lQQu6bweZB0XyIvHwlPitvJhC+hg==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.5.2.tgz", + "integrity": "sha512-jOViewGeTh9bSfS8vK72ECxoy2yvmA+Pd/ZcEvBI5c7+7IcfHwZ5qA4L9fRYuOhSrAifvQ2dV4vm1XAtloaWBw==", "cpu": [ "x64" ], @@ -203,25 +197,48 @@ } }, "node_modules/@astrojs/compiler-binding-wasm32-wasi": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.3.1.tgz", - "integrity": "sha512-cB456shIwDv/PrVT+2QG7LFndpHkVge5HjqADKZgGaAc9JHVktCtjSrcdkRQ+3tbkPazNKaTLRjXLIiz2NIx9g==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.5.2.tgz", + "integrity": "sha512-Z7p64doar1uvR5C9Xs6j1CV9lGlPVQX7OQ2aAtZ/xKC5aGS6GB13nyc2UfQXTQLvZrvVwsyaflTQy6ziY5Bekw==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.6" + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", + "@napi-rs/wasm-runtime": "^1.2.5" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.3.1.tgz", - "integrity": "sha512-ur/9+If/yTE69mmeX5MqSZndL0HOyx67GeNZUy3N7wVdWpLz9UTJXwyWS4UR2PUQHitghjsM5xoX0Ge56WRVQQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.5.2.tgz", + "integrity": "sha512-Tr13bh+ahTWsjXjzctK7pFpWV+dUU4FtBfETlR7gO+ueex0F+RDch7eRr0YfKysaRhk9tWm14lsCJ5uWyFPNHQ==", "cpu": [ "arm64" ], @@ -235,9 +252,9 @@ } }, "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.3.1.tgz", - "integrity": "sha512-k0W+kDBzDkNZOqu4kElDvCOIbKw5Ut9S1WZ1Krj3KTgNuBERNKXsMMsRLLcbgfdMdbe7bTekQLshZrrvmYpmwA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.5.2.tgz", + "integrity": "sha512-SfKkI+jOQYLh6wojPvVPV0lFfQnxisweN9B+qCzRvZp7DHAuGif8Y0WcC5PUgN4jb8VKy57LggQDtggR46Du1g==", "cpu": [ "x64" ], @@ -251,49 +268,48 @@ } }, "node_modules/@astrojs/compiler-rs": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.3.1.tgz", - "integrity": "sha512-aT7xkgsbNoS6nriY5qKpbihK43slFHO41iqgHCTdOvn1ifaQxLCc5yXy+6GzAtiafoaC1zA7OwVXCXMsvUZOkg==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.5.2.tgz", + "integrity": "sha512-IvwYLB8t1M86rKO4oPdBi9GP4gzXcgrVlNgocjwos1jeeUuEIV8d9Crn1PvYD/5IKteWOxpIHomf7e8iYlkE+Q==", "license": "MIT", "dependencies": { - "@astrojs/compiler-binding": "0.3.1" + "@astrojs/compiler-binding": "0.5.2" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@astrojs/internal-helpers": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.1.tgz", - "integrity": "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.13.0.tgz", + "integrity": "sha512-20jxQqnSmpskcGyad7N5l6Xf1gz0UCa6lNde44bEBdaXGpncEYfAZJ142r2ZgzBaG4dUyzGmhqZWv1INU2eiHQ==", "license": "MIT", "dependencies": { - "@types/hast": "^3.0.4", + "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", - "js-yaml": "^4.1.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.7", "retext-smartypants": "^6.2.0", - "shiki": "^4.0.2", - "smol-toml": "^1.6.0", - "unified": "^11.0.5" + "shiki": "^4.5.0", + "smol-toml": "^1.9.1", + "unified": "^11.0.5", + "yaml": "^2.9.1" } }, "node_modules/@astrojs/language-server": { - "version": "2.16.13", - "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.13.tgz", - "integrity": "sha512-ekOa+CYprEq5n4EJC1qTIAhLk49HZIUQuFwrEuF+3JK/pdMaYnWoREFUI2A0KEPOJiFA2kamBzKzbYljDvUxLg==", + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.17.3.tgz", + "integrity": "sha512-QIAFu1e/4quJMTPdmBOm+ZPXc5NDGZE4/YQjgR3EgjhGLl/tdidaWJDr5WmO2lesVc+BW1qN/xNBURmuePDG2A==", "dev": true, "license": "MIT", "dependencies": { - "@astrojs/compiler": "^2.13.1", + "@astrojs/astro2tsx": "^0.1.2", "@astrojs/yaml2ts": "^0.2.4", - "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", - "tinyglobby": "^0.2.16", + "tinyglobby": "^0.2.17", "volar-service-css": "0.0.71", "volar-service-emmet": "0.0.71", "volar-service-html": "0.0.71", @@ -302,7 +318,7 @@ "volar-service-typescript-twoslash-queries": "0.0.71", "volar-service-yaml": "0.0.71", "vscode-html-languageservice": "^5.6.2", - "vscode-uri": "^3.1.0" + "vscode-uri": "^3.2.0" }, "bin": { "astro-ls": "bin/nodeServer.js" @@ -320,88 +336,52 @@ } } }, - "node_modules/@astrojs/markdown-remark": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.1.tgz", - "integrity": "sha512-jPVNIqTvk+yKviikszv/Y1U4jGUSKpp/Nw48QZV4qjWgp70j4Lkq3lhSDRbWwCfgKvEyO9GHuVbV1dM2WYXy1w==", - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.10.1", - "@astrojs/prism": "4.0.2", - "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "hast-util-to-text": "^4.0.2", - "mdast-util-definitions": "^6.0.0", - "rehype-raw": "^7.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "remark-smartypants": "^3.0.2", - "unified": "^11.0.5", - "unist-util-remove-position": "^5.0.0", - "unist-util-visit": "^5.1.0", - "unist-util-visit-parents": "^6.0.2", - "vfile": "^6.0.3" - } - }, "node_modules/@astrojs/markdown-satteri": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.4.tgz", - "integrity": "sha512-6Lvt/bQZEBW+zzdhPblvfZEy5PGEYJaUsUqaCgwHeRPxZJL1gc9I+DRLKWJjjYTWDzVUTzXlMq4WwSK+X34CVw==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.5.tgz", + "integrity": "sha512-0IeGdoFbv0f+442KJhYS44Wg8F3MuHmRsxIEYuFyqkjRubc6dPBFZ7azor5t4AeXLQoT5A5h9HyViKpd70Dl9w==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.10.1", - "@astrojs/prism": "4.0.2", + "@astrojs/internal-helpers": "0.13.0", + "@astrojs/prism": "4.1.1", "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "satteri": "^0.9.1" + "satteri": "^0.10.5" } }, "node_modules/@astrojs/mdx": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-7.0.3.tgz", - "integrity": "sha512-RxyIwU0uFam5ftwqKOjpIdhnFxZ/kEikeimLyQy3eGXbHT8WgRGzzesOIHVU8+m9TY8ag5WVOyvV24/GyqPdPQ==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-8.1.1.tgz", + "integrity": "sha512-jEJUJsO2KqmKfBXaKeDHDCCj+Kerj1Fj+XndHznF9wxahvs+1onYCBeKmieM28N2+uA6mW57DOQQw5YaDLdBnA==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.10.1", - "@astrojs/markdown-remark": "7.2.1", - "@mdx-js/mdx": "^3.1.1", - "acorn": "^8.16.0", - "es-module-lexer": "^2.0.0", - "estree-util-visit": "^2.0.0", - "hast-util-to-html": "^9.0.5", - "piccolore": "^0.1.3", - "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1", - "remark-smartypants": "^3.0.2", - "source-map": "^0.7.6", - "unist-util-visit": "^5.1.0", - "vfile": "^6.0.3" + "@astrojs/internal-helpers": "0.13.0", + "@astrojs/markdown-satteri": "0.4.5", + "es-module-lexer": "^3.0.3" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.23.4" }, "peerDependencies": { - "@astrojs/markdown-satteri": "^0.3.1", - "astro": "^7.0.0" + "@astrojs/markdown-remark": "^7.3.0", + "@astrojs/markdown-satteri": "^0.4.0", + "astro": "^7.2.10" }, "peerDependenciesMeta": { - "@astrojs/markdown-satteri": { + "@astrojs/markdown-remark": { "optional": true } } }, "node_modules/@astrojs/prism": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", - "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.1.1.tgz", + "integrity": "sha512-sXlAXJX6t4QfuLg+CuCDzpg7NoxWcyb4paFprgtFppLeisCBmh935h+tJ5CWXfsXEoW2IDy/ihbRMtiu28Chjw==", "license": "MIT", "dependencies": { "prismjs": "^1.30.0" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.23.4" } }, "node_modules/@astrojs/sitemap": { @@ -416,44 +396,43 @@ } }, "node_modules/@astrojs/starlight": { - "version": "0.41.4", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.41.4.tgz", - "integrity": "sha512-cRCKZhM2BKYViCakBiN68aVwPn5qj/XtMMq//G54xOWdXXcvic1gMMEI+veNlIKOqqC4QmIjcjk4jiFtlZ3mMg==", + "version": "0.42.7", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.7.tgz", + "integrity": "sha512-mlbit5iRjLx5eIcLf84Q2CwkrrsEgZrQwKKBK+vRf+ZGiSrwy1UGdT1hUfAIaHrMfcvkDpL+gw3aua7HobiKFg==", "license": "MIT", "dependencies": { - "@astrojs/markdown-satteri": "^0.3.2", - "@astrojs/mdx": "^7.0.0", - "@astrojs/sitemap": "^3.7.2", + "@astrojs/markdown-satteri": "^0.4.0", + "@astrojs/mdx": "^8.0.0", + "@astrojs/sitemap": "^3.7.3", "@pagefind/default-ui": "^1.3.0", - "@types/hast": "^3.0.4", + "@types/hast": "^3.0.5", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "astro-expressive-code": "^0.44.0", "bcp-47": "^2.1.0", - "hast-util-from-html": "^2.0.3", + "hast-util-format": "^1.1.0", "hast-util-select": "^6.0.4", + "hast-util-to-html": "^9.0.5", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", "i18next": "^26.0.7", "js-yaml": "^4.1.1", "klona": "^2.0.6", - "magic-string": "^0.30.21", + "magic-string": "^1.2.3", "mdast-util-directive": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", "mdast-util-to-string": "^4.0.0", "pagefind": "^1.5.2", - "rehype": "^13.0.2", - "rehype-format": "^5.0.1", "remark-directive": "^4.0.0", - "satteri": "^0.9.1", + "satteri": "^0.10.3", "ultrahtml": "^1.6.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { - "@astrojs/markdown-remark": "^7.2.0", - "astro": "^7.0.2" + "@astrojs/markdown-remark": "^7.3.0", + "astro": "^7.2.10" }, "peerDependenciesMeta": { "@astrojs/markdown-remark": { @@ -462,18 +441,18 @@ } }, "node_modules/@astrojs/telemetry": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", - "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.4.1.tgz", + "integrity": "sha512-SMkvMsGOpM0FMAoT5pzhgI3LZC0JSPWf7Evwop+fZ3N6UkusPM7lI+sOJjMNI5OJrVoiei1/AGo1cY7sikTc5A==", "license": "MIT", "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", - "package-manager-detector": "^1.6.0" + "package-manager-detector": "^1.9.0" }, "engines": { - "node": "18.20.8 || ^20.3.0 || >=22.0.0" + "node": ">=22.23.4" } }, "node_modules/@astrojs/yaml2ts": { @@ -505,12 +484,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -520,9 +499,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -533,9 +512,9 @@ } }, "node_modules/@bruits/satteri-darwin-arm64": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.9.5.tgz", - "integrity": "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", "cpu": [ "arm64" ], @@ -546,9 +525,9 @@ ] }, "node_modules/@bruits/satteri-darwin-x64": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.9.5.tgz", - "integrity": "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", "cpu": [ "x64" ], @@ -559,9 +538,9 @@ ] }, "node_modules/@bruits/satteri-linux-arm64-gnu": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.9.5.tgz", - "integrity": "sha512-u51id17uJwNEMK9nBlICsq6U31c+XVqQueVBkwRIzZG+gMpS8TOJctt5h5Wz33Z8xnMdTd+adtACVz0yHgGuOA==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", "cpu": [ "arm64" ], @@ -575,9 +554,9 @@ ] }, "node_modules/@bruits/satteri-linux-arm64-musl": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.9.5.tgz", - "integrity": "sha512-v39HxiwGC5Rqm01HksP6+5Y+xKLPlsuVFgIgpEAo+SiQ22c+mJVhS3u7Z6ePAKdhL5NJoK1xq70kLz3L13AhpQ==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", "cpu": [ "arm64" ], @@ -591,9 +570,9 @@ ] }, "node_modules/@bruits/satteri-linux-x64-gnu": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.9.5.tgz", - "integrity": "sha512-F3uO8uFp3pAP5ZGXttwvh57GS7s0lL953tnNdyI2gRyP4kOOkp6pyGojNJzCjkDvWI2Cvb9iNrKok3aqQPauAw==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", "cpu": [ "x64" ], @@ -607,9 +586,9 @@ ] }, "node_modules/@bruits/satteri-linux-x64-musl": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.9.5.tgz", - "integrity": "sha512-bicEqglLlz++mWyADaZoP0JY20s4vDfLjaPYgQqC+NI4zZLTOOg1T4GB8aqtc822Pqji8SQBmSrTb7CrP8i08Q==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", "cpu": [ "x64" ], @@ -623,9 +602,9 @@ ] }, "node_modules/@bruits/satteri-wasm32-wasi": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.9.5.tgz", - "integrity": "sha512-zauAuMwfPnKPUkd4AFixRFpXdgKwP2mKgxrIIo2gJzW0/ZneF9dbHnLkojSpaBnCCp7VUL1hIi5WWZvB1CqmAQ==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", "cpu": [ "wasm32" ], @@ -634,7 +613,7 @@ "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "@napi-rs/wasm-runtime": "^1.2.3" }, "engines": { "node": ">=14.0.0" @@ -672,9 +651,9 @@ } }, "node_modules/@bruits/satteri-win32-arm64-msvc": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.9.5.tgz", - "integrity": "sha512-SrfE7NEsgZjBvU3c+RR6oQRu0ToXY5uVJEbieXEF0YTctIV2zAVlbaMjWLts074QCgh3a+XHWkR/lWh2VH2LUg==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", "cpu": [ "arm64" ], @@ -685,9 +664,9 @@ ] }, "node_modules/@bruits/satteri-win32-x64-msvc": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.9.5.tgz", - "integrity": "sha512-5Kw9ZAtTGS8WHizyn+CJhjjfIQrw+7jcZodpmpXJjefnO15M8UexIi6JR2E5thyvsmHyhL6ZDDMUNR4bKJPd4g==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", "cpu": [ "x64" ], @@ -710,9 +689,9 @@ } }, "node_modules/@clack/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", - "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", "license": "MIT", "dependencies": { "fast-wrap-ansi": "^0.2.0", @@ -723,12 +702,12 @@ } }, "node_modules/@clack/prompts": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", - "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", "license": "MIT", "dependencies": { - "@clack/core": "1.4.3", + "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" @@ -812,9 +791,8 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "devOptional": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" @@ -824,8 +802,8 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "tslib": "^2.4.0" } @@ -834,646 +812,229 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "devOptional": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@expressive-code/core": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.1.tgz", + "integrity": "sha512-3dDo9N8D7hYrLNNMMWFovg3+aDUtnQm7c7z0GZc1c0LEFVBc0Q6lKG+tVT28gDadOvsgOANfCn35fgpe97Pmgg==", "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@ctrl/tinycolor": "^4.0.4", + "hast-util-select": "^6.0.2", + "hast-util-to-html": "^9.0.1", + "hast-util-to-text": "^4.0.1", + "hastscript": "^9.0.0", + "postcss": "^8.4.38", + "postcss-nested": "^6.0.1", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], + "node_modules/@expressive-code/plugin-frames": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.1.tgz", + "integrity": "sha512-HC/bdRao9225ApcgO/e3jn8ZOhldKO7ob1O/Tcipvtv7Vb5nMphZhMtD9uuywpvxkPYBHJi3504WhrKg05Dwqg==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@expressive-code/core": "^0.44.1" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], + "node_modules/@expressive-code/plugin-shiki": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.1.tgz", + "integrity": "sha512-YApiZt3buUzBwL5tqj8G+sYC5NjMjRCHgQwr9bmGl69rtcHy6fE9dooWUeKYB978fJT2BuxT5FeHcF47rA3SEg==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@expressive-code/core": "^0.44.1", + "shiki": "^4.0.2" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], + "node_modules/@expressive-code/plugin-text-markers": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.1.tgz", + "integrity": "sha512-B3BsJoJ8CFMlcIX9f+X9tcI3C4zPDO601+YuLi9GheSTNro7ZfqSjLptMQKBHOWZvxnAtY5zvIX7iO/qtBhNBg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.1" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" + "darwin" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" + "darwin" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ - "ia32" + "ppc64" ], - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ - "loong64" + "riscv64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@expressive-code/core": { - "version": "0.44.1", - "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.1.tgz", - "integrity": "sha512-3dDo9N8D7hYrLNNMMWFovg3+aDUtnQm7c7z0GZc1c0LEFVBc0Q6lKG+tVT28gDadOvsgOANfCn35fgpe97Pmgg==", - "license": "MIT", - "dependencies": { - "@ctrl/tinycolor": "^4.0.4", - "hast-util-select": "^6.0.2", - "hast-util-to-html": "^9.0.1", - "hast-util-to-text": "^4.0.1", - "hastscript": "^9.0.0", - "postcss": "^8.4.38", - "postcss-nested": "^6.0.1", - "unist-util-visit": "^5.0.0", - "unist-util-visit-parents": "^6.0.1" - } - }, - "node_modules/@expressive-code/plugin-frames": { - "version": "0.44.1", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.1.tgz", - "integrity": "sha512-HC/bdRao9225ApcgO/e3jn8ZOhldKO7ob1O/Tcipvtv7Vb5nMphZhMtD9uuywpvxkPYBHJi3504WhrKg05Dwqg==", - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.1" - } - }, - "node_modules/@expressive-code/plugin-shiki": { - "version": "0.44.1", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.1.tgz", - "integrity": "sha512-YApiZt3buUzBwL5tqj8G+sYC5NjMjRCHgQwr9bmGl69rtcHy6fE9dooWUeKYB978fJT2BuxT5FeHcF47rA3SEg==", - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.1", - "shiki": "^4.0.2" - } - }, - "node_modules/@expressive-code/plugin-text-markers": { - "version": "0.44.1", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.1.tgz", - "integrity": "sha512-B3BsJoJ8CFMlcIX9f+X9tcI3C4zPDO601+YuLi9GheSTNro7ZfqSjLptMQKBHOWZvxnAtY5zvIX7iO/qtBhNBg==", - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.1" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1483,9 +1044,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -1502,9 +1063,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -1521,9 +1082,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -1540,9 +1101,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -1559,9 +1120,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -1580,13 +1141,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -1605,13 +1166,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -1630,13 +1191,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -1655,13 +1216,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -1680,13 +1241,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -1705,13 +1266,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -1730,13 +1291,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -1755,17 +1316,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1775,16 +1336,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1794,9 +1355,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1813,9 +1374,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1832,9 +1393,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1851,64 +1412,30 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, - "node_modules/@mdx-js/mdx": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", - "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdx": "^2.0.0", - "acorn": "^8.0.0", - "collapse-white-space": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "estree-util-scope": "^1.0.0", - "estree-walker": "^3.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "markdown-extensions": "^2.0.0", - "recma-build-jsx": "^1.0.0", - "recma-jsx": "^1.0.0", - "recma-stringify": "^1.0.0", - "rehype-recma": "^1.0.0", - "remark-mdx": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "source-map": "^0.7.0", - "unified": "^11.0.0", - "unist-util-position-from-estree": "^2.0.0", - "unist-util-stringify-position": "^4.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@oslojs/encoding": { @@ -1918,12 +1445,12 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@pagefind/darwin-arm64": { @@ -2023,10 +1550,26 @@ "win32" ] }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -2040,9 +1583,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -2056,9 +1599,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -2072,9 +1615,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -2088,9 +1631,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -2104,9 +1647,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -2123,9 +1666,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -2142,9 +1685,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -2161,9 +1704,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -2180,9 +1723,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -2199,9 +1742,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -2218,9 +1761,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -2233,59 +1776,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -2299,9 +1793,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -2320,44 +1814,16 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, - "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, "node_modules/@shikijs/core": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.1.tgz", - "integrity": "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.5.0.tgz", + "integrity": "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.3.1", - "@shikijs/types": "4.3.1", + "@shikijs/primitive": "4.5.0", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", + "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" }, "engines": { @@ -2365,12 +1831,12 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.1.tgz", - "integrity": "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.5.0.tgz", + "integrity": "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" }, @@ -2379,12 +1845,12 @@ } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.1.tgz", - "integrity": "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.5.0.tgz", + "integrity": "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -2392,68 +1858,68 @@ } }, "node_modules/@shikijs/langs": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.1.tgz", - "integrity": "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.5.0.tgz", + "integrity": "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1" + "@shikijs/types": "4.5.0" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/primitive": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.1.tgz", - "integrity": "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz", + "integrity": "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/themes": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.1.tgz", - "integrity": "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.5.0.tgz", + "integrity": "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1" + "@shikijs/types": "4.5.0" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.1.tgz", - "integrity": "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.5.0.tgz", + "integrity": "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.1.0.tgz", + "integrity": "sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==", "license": "MIT" }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "tslib": "^2.4.0" } @@ -2506,12 +1972,6 @@ "@types/unist": "*" } }, - "node_modules/@types/mdx": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", - "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2558,80 +2018,105 @@ "license": "ISC" }, "node_modules/@volar/kit": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz", - "integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==", + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.30.tgz", + "integrity": "sha512-VocLtumfXnqnPQEWc9Yx7uC1uOh9gyjVNlrn2+6Zx/z9aM6yTLhPK8lSqM9Ilu81XRa48I00lcYJWLi2BLxx8Q==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-service": "2.4.28", - "@volar/typescript": "2.4.28", + "@volar/language-service": "2.4.30", + "@volar/typescript": "2.4.30", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { - "typescript": "*" + "typescript": "<7" } }, "node_modules/@volar/language-core": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", - "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.30.tgz", + "integrity": "sha512-73fjwEV8wat6aOZ6U/y/hqtE30ocrIWECg67b0txGczOMNw+MffLmvJ5fnFfN9drVUEYZzLdaNo6VAy6G6avMw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.28" + "@volar/source-map": "2.4.30" } }, "node_modules/@volar/language-server": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz", - "integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==", + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.30.tgz", + "integrity": "sha512-tcP9fzzUBbsLIFHhgnRU4ncQjMn9G9Vax0ffkQmdCrXVEYqRroUepmyC5AVgiju5bCiLD6a55qen5iOTQHkb3g==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.28", - "@volar/language-service": "2.4.28", - "@volar/typescript": "2.4.28", + "@volar/language-core": "2.4.30", + "@volar/language-service": "2.4.30", + "@volar/typescript": "2.4.30", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "<7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@volar/language-service": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz", - "integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==", + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.30.tgz", + "integrity": "sha512-w/xDsASiqC71ggCOm7sTDXDNk/l72XEptodeRC68tgZ0e5QM4hM3zAqBGC7mBctvhrpYwKfMG+t9xcUo2hEFtA==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.28", + "@volar/language-core": "2.4.30", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "<7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@volar/source-map": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", - "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.30.tgz", + "integrity": "sha512-zym1ob/KwDDyN+0jGVtxwbxJSRRnUvv5HspCtz3yEfVVdCG4/dzxJktMGPAgemqRuVs81O0o/y0q3qOUErR8kg==", "dev": true, "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", - "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.30.tgz", + "integrity": "sha512-/DgA5tgYFPfrLFuSjIMXMGHk1LQv2+P8eny6+BhGNzFJELWIW7UZlc56ZLIRFhLsPpMfIFxM8/HvOPBggrjTZg==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.28", + "@volar/language-core": "2.4.30", + "@volar/language-service": "2.4.30", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "<7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@vscode/emmet-helper": { @@ -2662,27 +2147,6 @@ "dev": true, "license": "MIT" }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -2726,9 +2190,9 @@ } }, "node_modules/am-i-vibing": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", - "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz", + "integrity": "sha512-KLcsDhqf1XRrJmJr+9I/C0EX2K+sGrAGZFgkmd84c8TJO9zJBu+/kFInjazu1KfJWFhWDKdRWkpBzEs7s35DVA==", "license": "MIT", "dependencies": { "process-ancestry": "^0.1.0" @@ -2738,26 +2202,26 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -2809,90 +2273,70 @@ "node": ">= 0.4" } }, - "node_modules/array-iterate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", - "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/astring": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", - "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", - "license": "MIT", - "bin": { - "astring": "bin/astring" - } - }, "node_modules/astro": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.1.3.tgz", - "integrity": "sha512-4dhPyAAXthf3xLEYnG8SeL7yr/nTPPABfY7e9YF0yuO+vK9Xp+8Q5j4xzsmL3GueukQv4oNwGNTBepLOiDGeJA==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.4.2.tgz", + "integrity": "sha512-FAH4cdDlFaVnQGjazLTEIC5vJqGlo5XANc1wIbzQHIqAjJTll/RWTUs+SPNW1JdezG4YGxMZllaE5A41T2kzhA==", "license": "MIT", "dependencies": { - "@astrojs/compiler-rs": "^0.3.1", - "@astrojs/internal-helpers": "0.10.1", - "@astrojs/markdown-satteri": "0.3.4", - "@astrojs/telemetry": "3.3.3", - "@capsizecss/unpack": "^4.0.0", - "@clack/prompts": "^1.1.0", + "@astrojs/compiler-rs": "^0.5.1", + "@astrojs/internal-helpers": "0.13.0", + "@astrojs/markdown-satteri": "0.4.5", + "@astrojs/telemetry": "3.4.1", + "@capsizecss/unpack": "^4.0.1", + "@clack/prompts": "^1.8.1", "@oslojs/encoding": "^1.1.0", - "@rollup/pluginutils": "^5.3.0", - "am-i-vibing": "^0.4.0", + "am-i-vibing": "^0.5.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", - "devalue": "^5.8.1", - "diff": "^8.0.3", + "devalue": "^6.0.2", + "diff": "^9.0.0", "dset": "^3.1.4", - "es-module-lexer": "^2.0.0", - "esbuild": "^0.28.0", + "es-module-lexer": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.1", - "get-tsconfig": "5.0.0-beta.4", + "get-tsconfig": "5.0.0-beta.6", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", - "http-cache-semantics": "^4.2.0", - "js-yaml": "^4.1.1", + "http-cache-semantics": "^4.3.0", "jsonc-parser": "^3.3.1", - "magic-string": "^0.30.21", - "magicast": "^0.5.2", + "magic-string": "^1.4.3", + "magicast": "^0.5.5", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", - "obug": "^2.1.1", - "p-limit": "^7.3.0", - "p-queue": "^9.1.0", - "package-manager-detector": "^1.6.0", + "obug": "^3.0.0", + "p-limit": "^7.3.3", + "p-queue": "^9.3.5", + "package-manager-detector": "^1.9.0", "piccolore": "^0.1.3", - "picomatch": "^4.0.4", - "semver": "^7.7.4", - "shiki": "^4.0.2", - "smol-toml": "^1.6.0", - "svgo": "^4.0.1", - "tinyclip": "^0.1.12", - "tinyexec": "^1.0.4", - "tinyglobby": "^0.2.15", - "ultrahtml": "^1.6.0", - "unifont": "~0.7.4", + "picomatch": "^4.0.7", + "rolldown": "^1.2.13", + "shiki": "^4.5.0", + "smol-toml": "^1.9.1", + "svgo": "^4.1.0", + "tinyclip": "^1.0.3", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "tinyps": "^0.5.0", + "ultrahtml": "^1.7.0", + "unifont": "^1.0.3", "unstorage": "^1.17.5", - "vite": "^8.0.13", - "vitefu": "^1.1.2", + "verkit": "^0.5.0", + "vite": "^8.3.4", + "vitefu": "^1.1.3", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", - "zod": "^4.3.6" + "zod": "^4.6.6" }, "bin": { "astro": "bin/astro.mjs" }, "engines": { - "node": ">=22.12.0", + "node": ">=22.23.4", "npm": ">=9.6.5", "pnpm": ">=7.1.0" }, @@ -2901,10 +2345,10 @@ "url": "https://opencollective.com/astrodotbuild" }, "optionalDependencies": { - "sharp": "^0.34.0 || ^0.35.0" + "sharp": "^0.35.4" }, "peerDependencies": { - "@astrojs/markdown-remark": "7.2.1" + "@astrojs/markdown-remark": "^7.3.0" }, "peerDependenciesMeta": { "@astrojs/markdown-remark": { @@ -3056,59 +2500,47 @@ } }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=6" - } - }, - "node_modules/collapse-white-space": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", - "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", - "license": "MIT", + "node": ">=18" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, "engines": { - "node": ">=7.0.0" + "node": ">=6" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -3166,16 +2598,16 @@ } }, "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" }, "funding": { "url": "https://github.com/sponsors/fb55" @@ -3211,9 +2643,9 @@ } }, "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -3328,10 +2760,13 @@ } }, "node_modules/devalue": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", - "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", - "license": "MIT" + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-6.0.3.tgz", + "integrity": "sha512-3woMEITftG1wKeO/4d/QXH1DVKctTSZg3jNGPpjp9hNr5Hos8MCFF5McTML5ZtgKDObm2R591B0spRp2IVernA==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/devlop": { "version": "1.1.0", @@ -3347,9 +2782,9 @@ } }, "node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3382,18 +2817,6 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/dom-serializer/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -3462,16 +2885,16 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -3481,84 +2904,11 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.4.tgz", + "integrity": "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==", "license": "MIT" }, - "node_modules/esast-util-from-estree": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", - "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "devlop": "^1.0.0", - "estree-util-visit": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/esast-util-from-js": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", - "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "acorn": "^8.0.0", - "esast-util-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3569,109 +2919,6 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/estree-util-attach-comments": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", - "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-build-jsx": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", - "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "estree-walker": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-scope": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", - "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-to-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", - "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "astring": "^1.8.0", - "source-map": "^0.7.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-visit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", - "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -3719,9 +2966,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -3770,6 +3017,12 @@ "node": ">=8" } }, + "node_modules/fnv1a-64": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/fnv1a-64/-/fnv1a-64-0.1.2.tgz", + "integrity": "sha512-mJbcILTPybAR1jdOwt/lVv8N2cffeJFFP+gVbSAwLEx1ujXH27RpPd8Rokec/KX9c76UYIB6Co+H4lQzxPnJfA==", + "license": "MIT" + }, "node_modules/fontace": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", @@ -3815,10 +3068,23 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { - "version": "5.0.0-beta.4", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", - "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "version": "5.0.0-beta.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.6.tgz", + "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -3886,44 +3152,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-from-html": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", - "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.1.0", - "hast-util-from-parse5": "^8.0.0", - "parse5": "^7.0.0", - "vfile": "^6.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", - "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^9.0.0", - "property-information": "^7.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-has-property": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", @@ -4010,31 +3238,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-raw": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", - "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-from-parse5": "^8.0.0", - "hast-util-to-parse5": "^8.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "parse5": "^7.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-select": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", @@ -4062,34 +3265,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-estree": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", - "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-attach-comments": "^3.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -4113,52 +3288,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", - "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-to-string": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", @@ -4245,9 +3374,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "license": "BSD-2-Clause" }, "node_modules/i18next": { @@ -4278,12 +3407,6 @@ } } }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -4342,16 +3465,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -4375,9 +3488,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -4669,335 +3782,104 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/markdown-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", - "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-definitions": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", - "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-directive": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", - "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/mdast-util-mdx": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", - "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "license": "MIT", "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "license": "MIT", "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" } }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "node_modules/mdast-util-directive": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", + "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", "license": "MIT", "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", - "devlop": "^1.1.0", + "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" + "unist-util-visit-parents": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { "type": "opencollective", @@ -5155,235 +4037,12 @@ "license": "MIT", "dependencies": { "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdx-expression": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", - "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-mdx-expression": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-jsx": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", - "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "micromark-factory-mdx-expression": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdx-md": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", - "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", - "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", - "license": "MIT", - "dependencies": { - "acorn": "^8.0.0", - "acorn-jsx": "^5.0.0", - "micromark-extension-mdx-expression": "^3.0.0", - "micromark-extension-mdx-jsx": "^3.0.0", - "micromark-extension-mdx-md": "^2.0.0", - "micromark-extension-mdxjs-esm": "^3.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs-esm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", - "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" + "parse-entities": "^4.0.0" }, "funding": { "type": "opencollective", @@ -5433,33 +4092,6 @@ "micromark-util-types": "^2.0.0" } }, - "node_modules/micromark-factory-mdx-expression": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", - "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" - } - }, "node_modules/micromark-factory-space": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", @@ -5661,31 +4293,6 @@ ], "license": "MIT" }, - "node_modules/micromark-util-events-to-acorn": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", - "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "estree-util-visit": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "vfile-message": "^4.0.0" - } - }, "node_modules/micromark-util-html-tag-name": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", @@ -5838,9 +4445,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -5911,9 +4518,9 @@ } }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -5934,12 +4541,6 @@ "ufo": "^1.6.1" } }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "license": "MIT" - }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -5958,9 +4559,9 @@ } }, "node_modules/p-limit": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz", - "integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz", + "integrity": "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==", "license": "MIT", "dependencies": { "yocto-queue": "^1.2.1" @@ -5973,9 +4574,9 @@ } }, "node_modules/p-queue": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", - "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.5.tgz", + "integrity": "sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ==", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.4", @@ -5989,9 +4590,9 @@ } }, "node_modules/p-timeout": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", - "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", + "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", "license": "MIT", "engines": { "node": ">=20" @@ -6001,9 +4602,9 @@ } }, "node_modules/package-manager-detector": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", - "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", "license": "MIT" }, "node_modules/pagefind": { @@ -6049,36 +4650,6 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, - "node_modules/parse-latin": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", - "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "@types/unist": "^3.0.0", - "nlcst-to-string": "^4.0.0", - "unist-util-modify-children": "^4.0.0", - "unist-util-visit-children": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -6099,9 +4670,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -6111,9 +4682,9 @@ } }, "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -6130,9 +4701,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6164,9 +4735,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -6177,9 +4748,9 @@ } }, "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", "bin": { @@ -6204,335 +4775,83 @@ "node_modules/process-ancestry": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", - "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/radix3": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", - "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/recma-build-jsx": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", - "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-util-build-jsx": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/recma-jsx": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", - "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", - "license": "MIT", - "dependencies": { - "acorn-jsx": "^5.0.0", - "estree-util-to-js": "^2.0.0", - "recma-parse": "^1.0.0", - "recma-stringify": "^1.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/recma-parse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", - "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "esast-util-from-js": "^2.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/recma-stringify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", - "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-util-to-js": "^2.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "license": "MIT" - }, - "node_modules/rehype": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", - "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "rehype-parse": "^9.0.0", - "rehype-stringify": "^10.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-expressive-code": { - "version": "0.44.1", - "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.1.tgz", - "integrity": "sha512-+VZgs7Evw4LXRN3owpoBNSTpYuW6GeOdjqcUT1TuY8o/4MGPtbd0EU7Bgrju7X8KrQ6SslOBAuGWJ5fV5TriJQ==", - "license": "MIT", - "dependencies": { - "expressive-code": "^0.44.1" - } - }, - "node_modules/rehype-format": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", - "integrity": "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-format": "^1.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-parse": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", - "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-from-html": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-raw": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", - "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-raw": "^9.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-recma": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", - "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "hast-util-to-estree": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-stringify": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", - "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-to-html": "^9.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-directive": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", - "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-directive": "^3.0.0", - "micromark-extension-directive": "^4.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" } }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/remark-mdx": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", - "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", - "dependencies": { - "mdast-util-mdx": "^3.0.0", - "micromark-extension-mdxjs": "^3.0.0" + "engines": { + "node": ">= 20.19.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "regex-utilities": "^2.3.0" } }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "regex-utilities": "^2.3.0" } }, - "node_modules/remark-smartypants": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.3.tgz", - "integrity": "sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA==", + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype-expressive-code": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.1.tgz", + "integrity": "sha512-+VZgs7Evw4LXRN3owpoBNSTpYuW6GeOdjqcUT1TuY8o/4MGPtbd0EU7Bgrju7X8KrQ6SslOBAuGWJ5fV5TriJQ==", "license": "MIT", "dependencies": { - "retext": "^9.0.0", - "retext-smartypants": "^6.0.0", - "unified": "^11.0.4", - "unist-util-visit": "^5.0.0" - }, - "engines": { - "node": ">=16.0.0" + "expressive-code": "^0.44.1" } }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "node_modules/remark-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", + "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^4.0.0", "unified": "^11.0.0" }, "funding": { @@ -6547,16 +4866,6 @@ "dev": true, "license": "MIT" }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6576,37 +4885,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/retext": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", - "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "retext-latin": "^4.0.0", - "retext-stringify": "^4.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/retext-latin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", - "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "parse-latin": "^7.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/retext-smartypants": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", @@ -6622,28 +4900,13 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/retext-stringify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", - "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "nlcst-to-string": "^4.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6653,44 +4916,44 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/satteri": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz", - "integrity": "sha512-ZuWVl+vnM64y+/TtX8Kosv2c00W+hLQiiwnEL6H0UKVVrxFqMw4D2CJHHQaouVd89OAhtBBfjWLqhKi3TVUV4w==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.5", - "@types/hast": "^3.0.4", + "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3" }, "optionalDependencies": { - "@bruits/satteri-darwin-arm64": "0.9.5", - "@bruits/satteri-darwin-x64": "0.9.5", - "@bruits/satteri-linux-arm64-gnu": "0.9.5", - "@bruits/satteri-linux-arm64-musl": "0.9.5", - "@bruits/satteri-linux-x64-gnu": "0.9.5", - "@bruits/satteri-linux-x64-musl": "0.9.5", - "@bruits/satteri-wasm32-wasi": "0.9.5", - "@bruits/satteri-win32-arm64-msvc": "0.9.5", - "@bruits/satteri-win32-x64-msvc": "0.9.5" + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" } }, "node_modules/sax": { @@ -6715,9 +4978,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", @@ -6731,31 +4994,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -6764,19 +5027,19 @@ } }, "node_modules/shiki": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.1.tgz", - "integrity": "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.5.0.tgz", + "integrity": "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.3.1", - "@shikijs/engine-javascript": "4.3.1", - "@shikijs/engine-oniguruma": "4.3.1", - "@shikijs/langs": "4.3.1", - "@shikijs/themes": "4.3.1", - "@shikijs/types": "4.3.1", + "@shikijs/core": "4.5.0", + "@shikijs/engine-javascript": "4.5.0", + "@shikijs/engine-oniguruma": "4.5.0", + "@shikijs/langs": "4.5.0", + "@shikijs/themes": "4.5.0", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" }, "engines": { "node": ">=20" @@ -6808,9 +5071,9 @@ } }, "node_modules/smol-toml": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", - "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -6819,19 +5082,10 @@ "url": "https://github.com/sponsors/cyyynthia" } }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6848,9 +5102,9 @@ } }, "node_modules/starlight-sidebar-topics": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/starlight-sidebar-topics/-/starlight-sidebar-topics-0.8.0.tgz", - "integrity": "sha512-hE8+w2vNL13h6cq3UJGl05milJQ3+1BSlhaV5V4a993YzYczU6uLAj6jfUDKQ2mmgkdVWp8/UNDF4Je/rQXNQw==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/starlight-sidebar-topics/-/starlight-sidebar-topics-0.9.0.tgz", + "integrity": "sha512-xFwMAAtTVyqsO5DZ8Ldk0vN+2QoPdtjtDi8fbrzvQATIJvIvfaBLL9JzfXAjNHI9vMkj35VMWMd8tTICeO6RIA==", "license": "MIT", "dependencies": { "picomatch": "^4.0.3" @@ -6859,9 +5113,16 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@astrojs/starlight": ">=0.38.0" + "@astrojs/starlight": ">=0.42.0", + "astro": "^7.2.10" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "license": "MIT" + }, "node_modules/stream-replace-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", @@ -6869,18 +5130,20 @@ "license": "MIT" }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/stringify-entities": { @@ -6898,49 +5161,34 @@ } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "license": "MIT", - "dependencies": { - "style-to-object": "1.0.14" - } - }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "license": "MIT", - "dependencies": { - "inline-style-parser": "0.2.7" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/svgo": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", - "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", + "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", "license": "MIT", "dependencies": { "commander": "^11.1.0", - "css-select": "^5.1.0", + "css-select": "^6.0.0", "css-tree": "^3.0.1", - "css-what": "^6.1.0", + "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", - "sax": "^1.5.0" + "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" @@ -6960,18 +5208,18 @@ "license": "MIT" }, "node_modules/tinyclip": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", - "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-1.0.3.tgz", + "integrity": "sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==", "license": "MIT", "engines": { "node": "^16.14.0 || >= 17.3.0" } }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "license": "MIT", "engines": { "node": ">=18" @@ -6993,6 +5241,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyps": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/tinyps/-/tinyps-0.5.1.tgz", + "integrity": "sha512-B6Ykif5HflC3AdDkS4Zst/2muYVfeXoVMy+KorqH80s/1x4vunIORpNySH681X7fjj+dFM9MDKD6/Hsccjb87Q==", + "license": "MIT" + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7017,8 +5271,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "devOptional": true, + "license": "0BSD" }, "node_modules/typesafe-path": { "version": "0.2.2", @@ -7069,6 +5323,15 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -7095,14 +5358,15 @@ } }, "node_modules/unifont": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", - "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-1.0.3.tgz", + "integrity": "sha512-XGnEwj5sWdCbiBX01mWLSBncS0yMRjnLygau3IfdOlfjMacNIQvTGW4VH7pWBq+gGWArfSef/CensMLjMmIr/A==", "license": "MIT", "dependencies": { "css-tree": "^3.1.0", - "ofetch": "^1.5.1", - "ohash": "^2.0.11" + "fnv1a-64": "^0.1.2", + "std-env": "^4.2.0", + "undici": "^8.0.0" } }, "node_modules/unist-util-find-after": { @@ -7132,20 +5396,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-modify-children": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", - "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "array-iterate": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-position": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", @@ -7159,33 +5409,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-position-from-estree": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", - "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-remove-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", - "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -7214,19 +5437,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-visit-children": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", - "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-visit-parents": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", @@ -7355,28 +5565,26 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" + "engines": { + "node": ">=18.12.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/vfile-location": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", - "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", - "vfile": "^6.0.0" + "vfile-message": "^4.0.0" }, "funding": { "type": "opencollective", @@ -7398,15 +5606,15 @@ } }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -7423,7 +5631,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -7687,13 +5895,13 @@ } }, "node_modules/vscode-jsonrpc": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", - "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-10.0.0.tgz", + "integrity": "sha512-USSfxg0vDIpTQqlQBGxF3mBARyoS8cU7LHLpbYNh6dOJt32dlskxPWB0Irrh5TllTmyf7oNLAvqDqzklbwwnpA==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=14.17.0" } }, "node_modules/vscode-languageserver": { @@ -7710,27 +5918,27 @@ } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.18.2", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", - "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.5.tgz", + "integrity": "sha512-FW4fBMzZ99N8xeob4py0l050wgCoRgTpEq5Xcsb9lM1bc29UKGR/wSV+4knaqFxssFHajy7OzNiKP/m3U1ZEuA==", "dev": true, "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.1", - "vscode-languageserver-types": "3.18.0" + "vscode-jsonrpc": "10.0.0", + "vscode-languageserver-types": "3.18.5" } }, "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.16.tgz", + "integrity": "sha512-qeNEuOi9a3qkqRf7Gfez2WL2VwlBTLCNekHBGO9kxPVUp9XuY15yfTJ9jwvOwKI0I2MhUUfZq95NSp+LK1CLQg==", "dev": true, "license": "MIT" }, "node_modules/vscode-languageserver-types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", - "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.5.tgz", + "integrity": "sha512-kPd6+JQNz3HMJvjKlqUxERSlZHAntl/aGpsk7ImwIh0ewDKDcjgAEyVSYciPiOJD3ftbK9IrfiqUeTZLSbCizw==", "dev": true, "license": "MIT" }, @@ -7770,38 +5978,46 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", "dev": true, "license": "MIT" }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/xxhash-wasm": { @@ -7821,10 +6037,9 @@ } }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "devOptional": true, + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -7884,22 +6099,21 @@ } }, "node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^8.2.1", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { @@ -7911,16 +6125,6 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", @@ -7934,9 +6138,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/www/package.json b/www/package.json index 066657d3..c3743228 100644 --- a/www/package.json +++ b/www/package.json @@ -12,13 +12,13 @@ "check": "astro check" }, "dependencies": { - "@astrojs/starlight": "^0.41.4", - "astro": "^7.1.3", - "sharp": "^0.35.3", - "starlight-sidebar-topics": "0.8.0" + "@astrojs/starlight": "^0.42.7", + "astro": "^7.4.2", + "sharp": "^0.35.5", + "starlight-sidebar-topics": "0.9.0" }, "devDependencies": { - "@astrojs/check": "^0.9.9", + "@astrojs/check": "^0.9.12", "typescript": "^6.0.3" } } diff --git a/www/public/og.png b/www/public/og.png new file mode 100644 index 00000000..579a11e4 Binary files /dev/null and b/www/public/og.png differ diff --git a/www/public/robots.txt b/www/public/robots.txt new file mode 100644 index 00000000..3fa07649 --- /dev/null +++ b/www/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://exav.org/sitemap-index.xml diff --git a/www/scripts/make-og-image.py b/www/scripts/make-og-image.py new file mode 100644 index 00000000..a359d162 --- /dev/null +++ b/www/scripts/make-og-image.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Draws www/public/og.png, the picture that link previews show for exav.org. + + python3 -I www/scripts/make-og-image.py + +The picture is 1200 x 630 pixels: the site's mark (the teal shield with the +2x2 "exav" monogram of src/assets/logo.svg, drawn again here), the name and the +tagline, on the site's dark background. Everything is drawn from nothing by +this script, with Pillow; no photograph, logo or other asset is read. + +Licence: the picture and this script are dedicated to the public domain under +CC0-1.0 (https://creativecommons.org/publicdomain/zero/1.0/). The text is set +in DejaVu Sans, a free font (Bitstream Vera licence with public domain +additions) whose licence lets its rendered text be used freely. + +The output does not depend on the date or the machine beyond Pillow's +rasteriser, so running the script again on the same version gives the same +file. +""" +import os +import sys + +from PIL import Image, ImageDraw, ImageFont +from PIL.PngImagePlugin import PngInfo + +WIDTH, HEIGHT = 1200, 630 +SCALE = 3 # drawn this many times larger, then reduced, for smooth edges + +BACKGROUND = (0x17, 0x18, 0x1C) # Starlight's dark --sl-color-black +TEAL = (0x0F, 0x9E, 0x8E) # --sl-color-accent in src/styles/custom.css +WHITE = (0xFF, 0xFF, 0xFF) +TEXT = (0xED, 0xEE, 0xF3) # --sl-color-white +MUTED = (0xC1, 0xC3, 0xC8) # --sl-color-gray-2 + +NAME = "exav" +TAGLINE = ["Memory-safe tools", "for untrusted files."] +ADDRESS = "exav.org" + +FONT_DIRS = ["/usr/share/fonts/truetype/dejavu", "/usr/share/fonts/dejavu", "/usr/share/fonts/TTF"] + + +def font(name, size): + for directory in FONT_DIRS: + path = os.path.join(directory, name) + if os.path.exists(path): + return ImageFont.truetype(path, size) + sys.exit(f"{name} not found in {', '.join(FONT_DIRS)}: install DejaVu Sans (fonts-dejavu-core)") + + +def cubic(p0, p1, p2, p3, steps=24): + """Points of a cubic Bezier curve, the start left out.""" + out = [] + for i in range(1, steps + 1): + t = i / steps + u = 1 - t + out.append( + ( + u**3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t**3 * p3[0], + u**3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t**3 * p3[1], + ) + ) + return out + + +def shield(): + """The shield of logo.svg, in its 32 x 32 units: + M16 3.4 26 6.6v12.1c0 4.9-4.1 8.1-10 10-5.9-1.9-10-5.1-10-10V6.6Z""" + points = [(16, 3.4), (26, 6.6), (26, 18.7)] + points += cubic((26, 18.7), (26, 23.6), (21.9, 26.8), (16, 28.7)) + points += cubic((16, 28.7), (10.1, 26.8), (6, 23.6), (6, 18.7)) + points += [(6, 6.6)] + return points + + +def draw_mark(draw, left, top, size): + """The mark with its top left corner at (left, top), `size` pixels wide.""" + k = size / 32 + + def at(x, y): + return (left + x * k, top + y * k) + + draw.rounded_rectangle([left, top, left + size, top + size], radius=8 * k, fill=TEAL) + draw.polygon([at(x, y) for x, y in shield()], fill=WHITE) + letters = font("DejaVuSans-Bold.ttf", round(10 * k)) + for letter, x, y in (("e", 12.6, 15.3), ("x", 19.4, 15.3), ("a", 12.6, 22.9), ("v", 19.4, 22.9)): + draw.text(at(x, y), letter, font=letters, fill=TEAL, anchor="ms") + + +def main(): + image = Image.new("RGB", (WIDTH * SCALE, HEIGHT * SCALE), BACKGROUND) + draw = ImageDraw.Draw(image) + + mark = 330 + draw_mark(draw, 120 * SCALE, (HEIGHT - mark) // 2 * SCALE, mark * SCALE) + + left = 520 * SCALE + draw.text((left, 292 * SCALE), NAME, font=font("DejaVuSans-Bold.ttf", 170 * SCALE), fill=TEXT, anchor="ls") + tagline = font("DejaVuSans.ttf", 46 * SCALE) + for i, line in enumerate(TAGLINE): + draw.text((left, (362 + i * 62) * SCALE), line, font=tagline, fill=MUTED, anchor="ls") + draw.text((left, 520 * SCALE), ADDRESS, font=font("DejaVuSansMono-Bold.ttf", 34 * SCALE), fill=TEAL, anchor="ls") + + image = image.resize((WIDTH, HEIGHT), Image.LANCZOS) + + info = PngInfo() + info.add_text("Title", "exav: Memory-safe tools for untrusted files.") + info.add_text("Copyright", "CC0-1.0: https://creativecommons.org/publicdomain/zero/1.0/") + out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "public", "og.png") + image.save(out, optimize=True, pnginfo=info) + print(f"wrote {os.path.normpath(out)}") + + +if __name__ == "__main__": + main() diff --git a/www/src/content/docs/index.mdx b/www/src/content/docs/index.mdx index 62edcfa3..0e315a99 100644 --- a/www/src/content/docs/index.mdx +++ b/www/src/content/docs/index.mdx @@ -2,6 +2,9 @@ title: exav description: Memory-safe tools for untrusted files, written in Rust. A malware scanner compatible with ClamAV's signatures and protocols, an archive extractor, and a file viewer for the browser. template: splash +head: + - tag: title + content: "exav: Memory-safe tools for untrusted files" hero: tagline: Memory-safe tools for untrusted files. Scan them for malware, unpack them, and view them in the browser. image: @@ -49,9 +52,9 @@ Each tool is usable on its own. [Start here](/unpack/) - `@exav/viewer` shows PDF, images, DWG and DXF, Office documents, IFC and - STL models, media and archives in the browser, and can open each file in - a sandboxed frame of its own. + `@exav/viewer` shows PDF, images, DWG and DXF, Office documents (the old + formats too), IFC, STL, OBJ, PLY and glTF models, media and archives in + the browser, and can open each file in a sandboxed frame of its own. ```sh npm install @exav/viewer diff --git a/www/src/content/docs/project/architecture.md b/www/src/content/docs/project/architecture.md index adbe776f..b91d8972 100644 --- a/www/src/content/docs/project/architecture.md +++ b/www/src/content/docs/project/architecture.md @@ -13,14 +13,30 @@ does in [How extraction works](/unpack/how-it-works/). The CLI (which is also the daemon) drives the scanning engine, `exav-core`, which also builds its own WASI binary, `exav-wasm` (feature `wasi-bin`). Two -front ends use only the extractor, `exav-unpack`: the archive grep and +front ends use the extractor, `exav-unpack`: the archive grep and `@exav/unpack-wasm`. `@exav/viewer`'s wasm modules use only `exav-render`. `exav-core` hashes images through `exav-imagehash` (the `image-hash` feature), which decodes them with `exav-render`. exav-render reads DWG and DXF -with exav-unpack's drawing parsers (its `dwg` feature); the scanner draws -nothing. `exav-update`, behind the `http-update` feature (part of `http`), -sits to the side, feeding fresh signatures out of band. +with exav-unpack's drawing parsers (its `dwg` feature), and its text layer +reads HTML and the XML parts of documents with exav-unpack's (its `text` +feature). That layer, the text a reader sees in a document, is also what the +scanner (`exav-core`'s `visible-text` feature) and the archive grep (its +`text` feature) read documents through; the scanner draws nothing. +`exav-update`, behind the `http-update` feature (part of `http`), sits to the +side, feeding fresh signatures out of band. + +`exav-render` is one crate, with the office converters in it as modules: +spreadsheets (`sheets`) to XLSX, presentations and drawings (`slides`) to +PPTX and text documents (`docs`) to DOCX, each behind a feature +(`office-sheets`, `office-slides`, `office-docs`, or `office` for the three), +and a `common` module they share (ZIP packages, ODF's XML, code pages, +compound files, iWork archives) that is not public. None is in the default +feature set. Under the office features the crate adds `zip`, `quick-xml`, +`encoding_rs`, `md-5` and `sha1` to its dependencies (`flate2`, which they use +too, is already there for the image decoders). The scanner links all of them +already, through exav-unpack, so `visible-text-office` adds code but no crate. +The viewer builds `sheets`, `slides` and `docs` into one wasm module each. Every arrow points down: nothing calls anything above it, which is what lets the extraction, decoding and hashing crates be taken on their own. Dashed @@ -34,11 +50,14 @@ labelled with. half of @exav/viewer), exav-grep and exav-unpack-wasm. exav depends on exav-core, and on exav-update behind the http-update feature. exav-core also builds exav-wasm, its wasm32-wasip1 binary. exav-core depends on - exav-imagehash behind the image-hash feature, on exav-unpack, and on exav-x86 - for the bytecode disassembly API. exav-imagehash and exav-viewer depend on - exav-render, which depends on exav-unpack behind its dwg feature. exav-grep - and exav-unpack-wasm depend on exav-unpack, which depends on exav-pe-emu - behind its pe-emu feature, which depends on exav-x86. + exav-imagehash behind the image-hash feature, on exav-render behind the + visible-text feature, on exav-unpack, and on exav-x86 for the bytecode + disassembly API. exav-imagehash and exav-viewer depend on exav-render, and + exav-grep does behind its text feature. exav-render, which holds the image + decoders, the DWG, DXF, IFC, STL, OBJ, PLY and glTF readers, the office converters and the + text layer, depends on exav-unpack behind its dwg and text features. + exav-grep and exav-unpack-wasm depend on exav-unpack, which depends on + exav-pe-emu behind its pe-emu feature, which depends on exav-x86. @@ -52,8 +71,8 @@ labelled with. - - + + @@ -65,7 +84,7 @@ labelled with. exav-unpack-wasm exav-update exav-core - exav-imagehash + exav-imagehash exav-render exav-unpack exav-pe-emu @@ -84,9 +103,10 @@ labelled with. typing · patterns · hashes YARA · .cbc VM + exav-wasm (WASI binary) - image hashes - image decoders - DWG · DXF · IFC · STL + image hashes + image decoders · DWG · DXF + IFC · STL · OBJ · PLY · glTF + office converters · text layer archives · disk images · documents packed executables packer stub emulator @@ -94,25 +114,29 @@ labelled with. http-update - image-hash - dwg + image-hash + visible-text + text + dwg · text pe-emu bytecode disasm - + - + - - + + + + diff --git a/www/src/content/docs/project/contributing.md b/www/src/content/docs/project/contributing.md index cd364a9c..31430824 100644 --- a/www/src/content/docs/project/contributing.md +++ b/www/src/content/docs/project/contributing.md @@ -10,7 +10,7 @@ targets. For a vulnerability, do not open a public issue: see [Security](/project/security/#reporting-a-vulnerability). Contributors follow the [code of conduct](https://github.com/sylvinus/exav/blob/main/CODE_OF_CONDUCT.md). -To start, install Rust 1.92 or newer and a C compiler (the `http` build links +To start, install Rust 1.95 or newer and a C compiler (the `http` build links `ring`), clone the repository and run `make test-native`. The rest of `make test` also needs `wasmtime`, Node.js and `wasm-pack` ([below](#before-submitting)). @@ -99,9 +99,10 @@ make lint # CI's clippy passes (-D warnings) and cargo fmt --check | target | what it covers | needs | | --- | --- | --- | -| `make test-native` | the workspace under every feature pass: default, `http`, `exav-unpack` with `checksums`, `--no-default-features`, `unstable-internals`, `testing-faults` | a C compiler | +| `make test-native` | the workspace under every feature pass: default, `http`, `exav-unpack` with `checksums`, `--no-default-features`, `unstable-internals`, `testing-faults`, and exav-render's `office-*`, `markdown`, `text-pdf` and `text-office` passes | a C compiler | | `make test-wasm` | the extractor and core unit tests on 32-bit `wasm32-wasip1` | `wasmtime` | | `make test-js` | the WASM bindings' vitest units and Playwright browser tests | Node.js, `wasm-pack`, python3; downloads Chromium | +| `make test-viewer` | `@exav/viewer`: its wasm modules rebuilt from the current source, vitest units, the package as `npm publish` would upload it, the sink and wasm-import gates, and the demo driven in headless browsers under its Content-Security-Policy | Node.js, `wasm-bindgen-cli` at the version the viewer pins; downloads browsers | | `make test-www` | `astro check`, a docs build (frontmatter, sidebar entries, rendering) and a check that every internal link and anchor resolves | Node.js | Not in `make test`: @@ -123,7 +124,7 @@ reports green having checked nothing. Run `make test` before you push. CI runs, on every pull request and push to `main`: the `make lint` checks, `make test-native`, the MSRV check (`make msrv`), `make test-yara-diff`, the WASM -builds with `make test-wasm` and `make test-js`, `make test-www`, `cargo audit`, +builds with `make test-wasm` and `make test-js`, `make test-viewer`, `make test-www`, `cargo audit`, `cargo deny check`, and a `cargo-fuzz` smoke run of every target. Add a line to the `[Unreleased]` section of `CHANGELOG.md` for any change a user diff --git a/www/src/content/docs/project/dependencies.md b/www/src/content/docs/project/dependencies.md index c01ade68..bdad557f 100644 --- a/www/src/content/docs/project/dependencies.md +++ b/www/src/content/docs/project/dependencies.md @@ -28,8 +28,8 @@ dependency policy behind these choices is in the repository's the parsers a hostile client reaches first are exav's own. - **`unsafe` in dependencies.** Many dependencies use `unsafe` internally: SIMD byte search and hashing, OS syscalls (the daemon), and buffer handling in some - decoders that read scanned bytes (`lzfse_rust`, `ruzstd`, `zip`, `tar`, - `delharc`, `goblin`). exav's own crates contain none outside the daemon's + decoders that read scanned bytes (`ruzstd`, `zip`, `tar`, `delharc`, + `goblin`). exav's own crates contain none outside the daemon's `libc` calls. The tables give a count per crate, and reducing it is on the [roadmap](/project/roadmap/). - **Feature-gated capability.** Optional features (`http-scan`, `http-update`, @@ -71,16 +71,21 @@ code, not a dependency. | [`regex`](https://crates.io/crates/regex) | Linear-time regex (PCRE subsignatures, phishing allowlists) | MIT OR Apache-2.0 | 1 | | [`regex-automata`](https://crates.io/crates/regex-automata) / [`regex-syntax`](https://crates.io/crates/regex-syntax) | Linear-time (DoS-safe) regex engine + parser (YARA regexes, the atom prefilter's required literals) | MIT OR Apache-2.0 | 55 / **none** (`forbid`) | | [`bit-set`](https://crates.io/crates/bit-set) | For the backtracking regex engine vendored in exav-core (below) | Apache-2.0 OR MIT | 2 | -| [`goblin`](https://crates.io/crates/goblin) | PE / ELF / Mach-O executable parsing | MIT | 34 | -| [`md-5`](https://crates.io/crates/md-5) / [`sha1`](https://crates.io/crates/sha1) / [`sha2`](https://crates.io/crates/sha2) | Hash signatures (whole-file / section digests), and key derivation in `decrypt` | MIT OR Apache-2.0 | 1 / 7 / 50 (CPU-feature detection, SIMD) | +| [`goblin`](https://crates.io/crates/goblin) | PE / ELF executable parsing, built without its Mach-O, `ar` and TE parsers | MIT | 34 | +| [`md-5`](https://crates.io/crates/md-5) / [`sha1`](https://crates.io/crates/sha1) / [`sha2`](https://crates.io/crates/sha2) | Hash signatures (whole-file / section digests), key derivation in `decrypt`, and, for `md-5` and `sha1`, the opening of an XLS encrypted with a password (Excel's default one for exav-render's `office-sheets`), in exav-unpack's `offcrypto` | MIT OR Apache-2.0 | 1 / 7 / 50 (CPU-feature detection, SIMD) | | [`crc32fast`](https://crates.io/crates/crc32fast) | CRC-32 (the `.exavdb` trailer, YARA's `hash` module, archive CRCs) | MIT OR Apache-2.0 | 15 (SIMD) | | [`base64`](https://crates.io/crates/base64) | Base64 decode (YARA `base64` modifiers; uuencode, email, XDP and embedded payloads in `exav-unpack`) | MIT OR Apache-2.0 | **none** (`forbid`) | | [`yara-x-parser`](https://crates.io/crates/yara-x-parser) | YARA grammar/AST parser (the native engine's front end) | BSD-3-Clause | 5 | | [`image`](https://crates.io/crates/image) | Image decoding for `fuzzy_img` hashing, in [exav-imagehash](/subprojects/exav-imagehash/): PNG, GIF, BMP, WebP, ICO, PNM, QOI, DDS, farbfeld and HDR itself, JPEG and TIFF through its decoders vendored there (below) | MIT OR Apache-2.0 | 6, plus its codecs below | -| [`zune-jpeg`](https://crates.io/crates/zune-jpeg) / [`zune-core`](https://crates.io/crates/zune-core) | JPEG decoding for fuzzy image hashing (0.5.8, and 0.4.21 for JPEG-compressed TIFF), built without its SIMD features, which hold all of its `unsafe` | MIT OR Apache-2.0 OR Zlib | **none** (`forbid`) / **none** (two versions of each resolve) | +| [`zune-jpeg`](https://crates.io/crates/zune-jpeg) / [`zune-core`](https://crates.io/crates/zune-core) | JPEG decoding for fuzzy image hashing (0.5.15, and 0.4.21 for JPEG-compressed TIFF), built without its SIMD features, which hold all of its `unsafe` | MIT OR Apache-2.0 OR Zlib | **none** (`forbid`) / **none** (two versions of each resolve) | | [`png`](https://crates.io/crates/png) / [`gif`](https://crates.io/crates/gif) | `image`'s PNG and GIF decoders, required at the versions libclamav links (as are the JPEG and TIFF ones), which `cargo install` would not otherwise hold | MIT OR Apache-2.0 | **none** (`forbid`) / **none** (`forbid`) | | [`hayro-jpeg2000`](https://crates.io/crates/hayro-jpeg2000) / [`hayro-jbig2`](https://crates.io/crates/hayro-jbig2) | JPEG 2000 and JBIG2 decoding for fuzzy image hashing, in [exav-render](/subprojects/exav-render/), built without their `simd` feature (fearless_simd); `hayro-ccitt`, below, is hayro-jbig2's | Apache-2.0 OR MIT | **none** (`forbid`) / **none** (`forbid`) | -| [`fax`](https://crates.io/crates/fax) / [`weezl`](https://crates.io/crates/weezl) / [`half`](https://crates.io/crates/half) / [`quick-error`](https://crates.io/crates/quick-error) / [`bytemuck`](https://crates.io/crates/bytemuck) | The vendored TIFF decoder's: CCITT fax, LZW, 16-bit floats, error types, byte casts | MIT / MIT OR Apache-2.0 / MIT OR Apache-2.0 / MIT OR Apache-2.0 / Zlib OR Apache-2.0 OR MIT | **none** / **none** (`forbid`) / 84 / **none** / 323 | +| [`zenjxl-decoder`](https://crates.io/crates/zenjxl-decoder) with `zenjxl-decoder-simd` and `zenjxl-decoder-macros` | JPEG XL decoding in `@exav/viewer`'s image module (exav-render's `jxl` feature, not a default): a fork of the libjxl team's jxl-rs with limits on pixels and memory, built without its SIMD and without `threads`; the viewer only | BSD-3-Clause | **none** (`forbid`; its `allow-unsafe` feature is off) / **none** (`forbid`) / **none** (`forbid`) | +| [`fax`](https://crates.io/crates/fax) / [`weezl`](https://crates.io/crates/weezl) / [`quick-error`](https://crates.io/crates/quick-error) / [`bytemuck`](https://crates.io/crates/bytemuck) | The vendored TIFF decoder's: CCITT fax, LZW, error types, byte casts | MIT / MIT OR Apache-2.0 / MIT OR Apache-2.0 / Zlib OR Apache-2.0 OR MIT | **none** / **none** (`forbid`) / **none** / 323 | +| [`bcdec_rs`](https://crates.io/crates/bcdec_rs) | BC1 to BC7 blocks of a DDS texture, in exav-render's viewer reader (`decode_any`), never in what is hashed; no dependencies | MIT | **none** | +| [`pulldown-cmark`](https://crates.io/crates/pulldown-cmark) | Markdown (CommonMark, tables, strikethrough) read as events, into a tree of data in `@exav/viewer`'s text module and into the text a reader sees for the scanner's `visible-text` feature (on by default) and `exav-grep --text` (exav-render's `markdown` feature); without its `html` and `simd` features, so no markup is made and the crate is `forbid(unsafe_code)`. It was measured linear on 35 hostile 1 MB inputs (worst 8 ms) and without advisories | MIT | **none** (`forbid`; the 4 uses are its SIMD feature, off) | +| [`html5gum`](https://crates.io/crates/html5gum) | HTML visible text (`visible-text`, on by default; `exav-grep --text`) and the HTML pages the office converters read (`exav_unpack::html`): a spec-compliant tokenizer alone (it passes the html5lib tokenizer tests), pulled token by token, keeping the words and where each link leads. No tree is built. Without its default `jetscii` feature it has no dependency | MIT | **none** (`forbid`) | +| [`hayro-interpret`](https://crates.io/crates/hayro-interpret) / [`hayro-syntax`](https://crates.io/crates/hayro-syntax) | PDF visible text (`visible-text-pdf` / `text-pdf`, **not a default**): hayro interprets each page and hands over its glyphs with their Unicode (`ToUnicode`, else glyph names). It is by the author of the JPEG 2000 and JBIG2 decoders exav-render already uses, and shares them. hayro puts no bound on what a stream decompresses to (a 1 MB PDF took 2.1 GB), so exav reads a PDF with it only after its own reader has taken every stream within the budget; a panic in it is contained. Built without the predefined CJK CMaps (`embed-cmaps`, which brings `brotli`). **Why it is off by default:** `hayro-interpret` turns on `hayro-syntax`'s `unsafe` feature itself, which switches every `flate2` in the build to `zlib-rs` (damaged streams then give back other bytes: two of exav-unpack's tests fail with it) and adds SIMD to the image decoders | Apache-2.0 OR MIT | **none** (`forbid`) / 11 (a lifetime `transmute`; with its `unsafe` feature, which is on, `flate2` and SIMD) | | [`tlsh2`](https://crates.io/crates/tlsh2) | TLSH fuzzy hashing | Apache-2.0 OR BSD-3-Clause | **none** | | [`rmp-serde`](https://crates.io/crates/rmp-serde) / [`rmp`](https://crates.io/crates/rmp) / [`serde`](https://crates.io/crates/serde) | MessagePack encoding of the prebuilt `.exavdb` | MIT / MIT / MIT OR Apache-2.0 | **none** (`forbid`) / 1 / 2 | | [`rustc-hash`](https://crates.io/crates/rustc-hash) | Fast hashing for internal maps (also in `exav-pe-emu`) | Apache-2.0 OR MIT | **none** | @@ -97,7 +102,8 @@ Some of exav is vendored source instead of dependencies, under its crates' - [exav-render](/subprojects/exav-render/)'s `src/image_codecs/`, which exav-imagehash decodes through: `image` 0.25.9's JPEG and TIFF decoders and the decoder of `tiff` 0.10.3 (MIT), so that zune-jpeg is built without its - SIMD code, which `image` and `tiff` cannot turn off. + SIMD code, which `image` and `tiff` cannot turn off. It holds 16-bit float + samples as their bits, which keeps `half` and `zerocopy` out. - exav-imagehash's `src/pillow.rs`: Pillow 12.3.0's resampler and grey conversions (MIT-CMU), ported for its imagehash preset. - exav-imagehash's `src/dct.rs`: rustdct 0.7.1's DCT-II for power-of-two @@ -109,37 +115,57 @@ Some of exav is vendored source instead of dependencies, under its crates' [`exav-x86`](/subprojects/exav-x86/) only, the oracle its differential tests, table generator and fuzz target check against. +### The office converters (`exav-render`) + +[exav-render](/subprojects/exav-render/#office-documents)'s `office-sheets`, +`office-slides` and `office-docs` features (`office` for the three; none is a +default) read ZIP packages and compound files and write XLSX, PPTX and DOCX. +They add `exav-unpack` (its `ole`, `html`, `offcrypto` and `rtf` readers: the +compound file and BIFF readers, the HTML tokens, the decryption of an XLS with +Excel's default password, which brings `md-5` and `sha1`, and the RTF +tokenizer, the same ones the scanner reads with) and three crates, all in the +tables on this page: `zip` (without default features, deflate through +`flate2`), `quick-xml` and `encoding_rs`; `html5gum` comes with the HTML +tokens. +`flate2` is already there for the image decoders, and the iWork (protobuf and +Snappy) readers and the OfficeArt reader are exav-render's own code. + +Counted as above, `cargo tree -p exav-render -e no-dev --prefix none | sort -u +| wc -l` gives 47 packages by default and 76 with `--features office` on +Linux (73 for `wasm32-unknown-unknown`), the crate itself included. The scanner +links every one of them already, through exav-unpack, so its +`visible-text-office` feature adds code and no crate. + ## Extraction & decompression (`exav-unpack`) Also `#![forbid(unsafe_code)]`. The decoders are pure-Rust; there is no C compression library in the tree. Most of these read scanned bytes. The bzip2 decoder is not a dependency: it is vendored from `bzip2-rs` into `formats/bzip2_rs`, with a fix for incompressible blocks, and is as -`unsafe`-free as the rest of the crate. +`unsafe`-free as the rest of the crate. Nor is the InstallShield `.z` reader: +its table of contents is read as the `unshield` crate reads it, and its PKWARE +DCL members are decoded in `formats/dcl_explode.rs`, ported from the +`explode` crate and Mark Adler's `blast.c`. The LZFSE and LZVN decoder of +DMG runs is `formats/lzfse.rs`, ported from Apple's reference implementation +(BSD-3-Clause) and checked against it, damaged streams included. | Crate | Purpose | License | `unsafe` uses | |---|---|---|---| | [`flate2`](https://crates.io/crates/flate2) | DEFLATE / gzip / zlib (pure-Rust `miniz_oxide` backend) | MIT OR Apache-2.0 | 36 (no C zlib) | | [`deflate64`](https://crates.io/crates/deflate64) | Deflate64 (ZIP method 9) | MIT | **none** (`forbid`) | | [`xz4rust`](https://crates.io/crates/xz4rust) | XZ (`.xz`, ZIP method 95, DMG) | MIT | 3 | -| [`lzma-rust2`](https://crates.io/crates/lzma-rust2) | LZMA / LZMA2 (7z, lzip, ZIP method 14, SWF, NSIS, UPX, EGG) | Apache-2.0 | 14 | -| [`ruzstd`](https://crates.io/crates/ruzstd) | Zstandard (pure Rust) | MIT | 39 | +| [`lzma-rust2`](https://crates.io/crates/lzma-rust2) | LZMA / LZMA2 (7z, lzip, `.lzma`, ZIP method 14, SWF, NSIS, UPX, EGG; also the strips of an LZMA TIFF in exav-render's viewer reader (`decode_any`), never in what is hashed) | Apache-2.0 | 14 | +| [`ruzstd`](https://crates.io/crates/ruzstd) | Zstandard (pure Rust), without its `hash` feature: exav does not check the content checksum; also the strips of a Zstandard TIFF in exav-render's viewer reader (`decode_any`), never in what is hashed | MIT | 39 | | [`lzxd`](https://crates.io/crates/lzxd) | LZX (CAB) | MIT OR Apache-2.0 | **none** | -| [`lzfse_rust`](https://crates.io/crates/lzfse_rust) | LZFSE / LZVN (DMG) | MIT OR Apache-2.0 | 435 | | [`delharc`](https://crates.io/crates/delharc) | LHA / LZH | MIT OR Apache-2.0 | 19 | | [`bitstream-io`](https://crates.io/crates/bitstream-io) | Bit-level readers for decoders | MIT OR Apache-2.0 | **none** (`forbid`) | | [`tar`](https://crates.io/crates/tar) | tar archives (`xattr` disabled, which drops its syscall crates) | MIT OR Apache-2.0 | 26 | -| [`zip`](https://crates.io/crates/zip) | ZIP container parsing | MIT | 32 | -| [`cfb`](https://crates.io/crates/cfb) | OLE2 / Compound File Binary (Office) | MIT | **none** | -| [`encoding_rs`](https://crates.io/crates/encoding_rs) | the code pages of DXF and DWG drawings before 2007, Asian double-byte ones included (features `dxf`, `dwg`), built without `simd-accel` | (Apache-2.0 OR MIT) AND BSD-3-Clause | 193 | -| [`quick-xml`](https://crates.io/crates/quick-xml) | OOXML / XML parsing | MIT | **none** (`forbid`) | +| [`zip`](https://crates.io/crates/zip) | ZIP container parsing; also the packages the office converters of [exav-render](/subprojects/exav-render/#office-documents) read and write (built without default features, deflate through `flate2`) | MIT | 25 | +| [`encoding_rs`](https://crates.io/crates/encoding_rs) | the code pages of DXF and DWG drawings before 2007, Asian double-byte ones included (features `dxf`, `dwg`), and of the text of the legacy office formats (exav-render's `office-*` features), built without `simd-accel` | (Apache-2.0 OR MIT) AND BSD-3-Clause | 180 | +| [`quick-xml`](https://crates.io/crates/quick-xml) | OOXML / XML parsing; also the OpenDocument, Visio and Word 2003 XML the office converters read and the XML they write | MIT | **none** (`forbid`) | | [`mail-parser`](https://crates.io/crates/mail-parser) | MIME / email parsing | Apache-2.0 OR MIT | **none** (`forbid`) | -| [`apfs`](https://crates.io/crates/apfs) / [`hfsplus`](https://crates.io/crates/hfsplus) | DMG filesystem parsing | MIT | **none** | -| [`ext4-view`](https://crates.io/crates/ext4-view) | ext2/3/4 filesystem walking (virtual disks) | MIT OR Apache-2.0 | **none** (`forbid`) | -| [`fatfs`](https://crates.io/crates/fatfs) | FAT12/16/32 cluster-chain walking | MIT | 11 | | [`lznt1`](https://crates.io/crates/lznt1) | LZNT1 (NTFS compressed streams) | MIT | **none** (`forbid`) | | [`salzweg`](https://crates.io/crates/salzweg) | LZW (ZOO, Unix `compress`) | MIT | **none** | -| [`unshield`](https://crates.io/crates/unshield) | InstallShield `.z` archives | MIT | **none** | | [`tinyvec`](https://crates.io/crates/tinyvec) | Inline vectors for the vendored bzip2 decoder | Zlib OR Apache-2.0 OR MIT | **none** (`forbid`) | | [`byteorder`](https://crates.io/crates/byteorder) | Byte-order reads | Unlicense OR MIT | 40 | @@ -155,7 +181,6 @@ SIMD (AES-NI) and CPU-feature detection. | [`aes`](https://crates.io/crates/aes) / [`cbc`](https://crates.io/crates/cbc) | AES block cipher + CBC (ZIP, 7z, PDF, DMG, Office) | MIT OR Apache-2.0 | 60 / **none** | | [`des`](https://crates.io/crates/des) | Triple DES (encrypted DMG key unwrap) | MIT OR Apache-2.0 | **none** | | [`hmac`](https://crates.io/crates/hmac) / [`pbkdf2`](https://crates.io/crates/pbkdf2) / [`digest`](https://crates.io/crates/digest) | Key derivation + MAC | MIT OR Apache-2.0 | **none** / **none** / **none** (`forbid`) | -| [`constant_time_eq`](https://crates.io/crates/constant_time_eq) | Constant-time comparison | CC0-1.0 OR MIT-0 OR Apache-2.0 | 2 | | [`stringprep`](https://crates.io/crates/stringprep) | Password normalization | MIT OR Apache-2.0 | **none** | ## Opt-in dependencies (not in the default binary) @@ -196,8 +221,7 @@ The `unsafe` column counts occurrences of the `unsafe` keyword in each crate's shipped `src/` (comments stripped; tests, benches and examples excluded) at the version resolved when the table was written. Read it as surface to review, not as risk. Most of it is in `hashbrown` and `bytemuck`, neither of which parses -scanned bytes, and in `zerocopy` (with its derive macro), which `half` pulls -in, and `moxcms`, which `image` does. There are +scanned bytes, and in `moxcms`, which `image` pulls in. There are no raw-syscall binding crates: `rustix` and `linux-raw-sys` are kept out by [writing the spill file in-tree](#cli--runtime-exav) and by building `tar` without `xattr`. Reproduce the counts with `cargo geiger`, or per crate with: @@ -215,17 +239,19 @@ find ~/.cargo/registry/src/*/-/src -name '*.rs' -print0 \ | [`anstyle`](https://crates.io/crates/anstyle) | MIT OR Apache-2.0 | 1 | `clap` | | [`anstyle-parse`](https://crates.io/crates/anstyle-parse) | MIT OR Apache-2.0 | 3 | `clap` | | [`anstyle-query`](https://crates.io/crates/anstyle-query) | MIT OR Apache-2.0 | 1 | `clap` | -| [`arraydeque`](https://crates.io/crates/arraydeque) | MIT/Apache-2.0 | 77 | `unshield` | +| [`aligned-vec`](https://crates.io/crates/aligned-vec) | MIT | 56 (the aligned allocation of the decoder's buffers) | `zenjxl-decoder` | +| [`array-init`](https://crates.io/crates/array-init) | MIT OR Apache-2.0 | 4 | `zenjxl-decoder` | | [`ascii_tree`](https://crates.io/crates/ascii_tree) | MIT | **none** | `yara-x-parser` | +| [`atomic_refcell`](https://crates.io/crates/atomic_refcell) | Apache-2.0 OR MIT | 14 | `zenjxl-decoder` | | [`beef`](https://crates.io/crates/beef) | MIT OR Apache-2.0 | 26 | `yara-x-parser` | | [`bit-vec`](https://crates.io/crates/bit-vec) | Apache-2.0 OR MIT | 4 | `bit-set` | | [`autocfg`](https://crates.io/crates/autocfg) | Apache-2.0 OR MIT | **none** | build-time: `num-traits` and its users | -| [`bitflags`](https://crates.io/crates/bitflags) | MIT OR Apache-2.0 | 21 (two versions resolve: 19 + 2) | `delharc`, `ext4-view`, `fatfs`, `image`, `png`, `yara-x-parser` | +| [`bitflags`](https://crates.io/crates/bitflags) | MIT OR Apache-2.0 | 19 | `delharc`, `image`, `png`, `pulldown-cmark`, `yara-x-parser` | | [`block-buffer`](https://crates.io/crates/block-buffer) | MIT OR Apache-2.0 | 21 | `digest`, `hmac`, `md-5`, `pbkdf2`, `sha1`, `sha2` | | [`block-padding`](https://crates.io/crates/block-padding) | MIT OR Apache-2.0 | **none** | `aes`, `cbc`, `des` | | [`bstr`](https://crates.io/crates/bstr) | MIT OR Apache-2.0 | 39 | `yara-x-parser` | | [`byteorder-lite`](https://crates.io/crates/byteorder-lite) | Unlicense OR MIT | 1 | `image` | -| [`cfg-if`](https://crates.io/crates/cfg-if) | MIT OR Apache-2.0 | **none** | `crc32fast`, `flate2`, `half`, `image`, `md-5`, `png`, `sha1`, `sha2`, `tar`, `zip` | +| [`cfg-if`](https://crates.io/crates/cfg-if) | MIT OR Apache-2.0 | **none** | `crc32fast`, `flate2`, `image`, `md-5`, `png`, `sha1`, `sha2`, `tar`, `zip` | | [`chrono`](https://crates.io/crates/chrono) | MIT OR Apache-2.0 | 11 | `delharc` | | [`cipher`](https://crates.io/crates/cipher) | MIT OR Apache-2.0 | **none** (`forbid`) | `aes`, `cbc`, `des` | | [`clap_builder`](https://crates.io/crates/clap_builder) | MIT OR Apache-2.0 | **none** (`forbid`) | `clap` | @@ -235,20 +261,19 @@ find ~/.cargo/registry/src/*/-/src -name '*.rs' -print0 \ | [`color_quant`](https://crates.io/crates/color_quant) | MIT | **none** | `gif`, `image` | | [`colorchoice`](https://crates.io/crates/colorchoice) | MIT OR Apache-2.0 | **none** | `clap` | | [`const-oid`](https://crates.io/crates/const-oid) | Apache-2.0 OR MIT | 1 | `digest`, `hmac`, `md-5`, `pbkdf2`, `sha1`, `sha2` | +| [`core_detect`](https://crates.io/crates/core_detect) | MIT/Apache-2.0 | 7 | `encoding_rs`, on x86 and x86-64 | | [`countme`](https://crates.io/crates/countme) | MIT OR Apache-2.0 | 1 | `yara-x-parser` | | [`cpubits`](https://crates.io/crates/cpubits) | MIT OR Apache-2.0 | **none** | `aes` | | [`cpufeatures`](https://crates.io/crates/cpufeatures) | MIT OR Apache-2.0 | 11 | `aes`, `sha1`, `sha2` | -| [`crc`](https://crates.io/crates/crc) | MIT OR Apache-2.0 | **none** (`forbid`) | `ext4-view` | -| [`crc-catalog`](https://crates.io/crates/crc-catalog) | MIT OR Apache-2.0 | **none** (`forbid`) | `ext4-view` | | [`crypto-common`](https://crates.io/crates/crypto-common) | MIT OR Apache-2.0 | **none** (`forbid`) | `aes`, `cbc`, `des`, `digest`, `hmac`, `md-5`, `pbkdf2`, `sha1`, `sha2` | | [`ctutils`](https://crates.io/crates/ctutils) | Apache-2.0 OR MIT | **none** (`forbid`) | `digest`, `hmac`, `md-5`, `pbkdf2`, `sha1`, `sha2` | | [`either`](https://crates.io/crates/either) | MIT OR Apache-2.0 | 2 | `yara-x-parser` | +| [`enough`](https://crates.io/crates/enough) | MIT OR Apache-2.0 | **none** (`forbid`) | `zenjxl-decoder` | | [`equivalent`](https://crates.io/crates/equivalent) | Apache-2.0 OR MIT | **none** | `mail-parser`, `yara-x-parser`, `zip` | -| [`explode`](https://crates.io/crates/explode) | MIT | 5 | `unshield` | | [`fax_derive`](https://crates.io/crates/fax_derive) | MIT | **none** | `fax` | | [`fdeflate`](https://crates.io/crates/fdeflate) | MIT OR Apache-2.0 | **none** (`forbid`) | `image`, `png` | | [`filetime`](https://crates.io/crates/filetime) | MIT/Apache-2.0 | 9 | `tar` | -| [`fnv`](https://crates.io/crates/fnv) | Apache-2.0 / MIT | **none** | `cfb`, `yara-x-parser` | +| [`fnv`](https://crates.io/crates/fnv) | Apache-2.0 / MIT | **none** | `yara-x-parser` | | [`hashbrown`](https://crates.io/crates/hashbrown) | MIT OR Apache-2.0 | 751 (two versions resolve: 450 + 301) | `mail-parser`, `yara-x-parser`, `zip` | | [`hashify`](https://crates.io/crates/hashify) | Apache-2.0 OR MIT | **none** | `mail-parser` | | [`hayro-ccitt`](https://crates.io/crates/hayro-ccitt) | Apache-2.0 OR MIT | **none** (`forbid`) | `hayro-jbig2` | @@ -262,46 +287,55 @@ find ~/.cargo/registry/src/*/-/src -name '*.rs' -print0 \ | [`itertools`](https://crates.io/crates/itertools) | MIT OR Apache-2.0 | 10 | `yara-x-parser` | | [`itoa`](https://crates.io/crates/itoa) | MIT OR Apache-2.0 | 13 | `serde_json` | | [`lazy_static`](https://crates.io/crates/lazy_static) | MIT OR Apache-2.0 | 2 | `yara-x-parser` | -| [`log`](https://crates.io/crates/log) | MIT OR Apache-2.0 | 6 | `fatfs`, `goblin` | +| [`log`](https://crates.io/crates/log) | MIT OR Apache-2.0 | 6 | `goblin` | | [`logos`](https://crates.io/crates/logos) | MIT OR Apache-2.0 | 18 | `yara-x-parser` | | [`logos-codegen`](https://crates.io/crates/logos-codegen) | MIT OR Apache-2.0 | 4 | `yara-x-parser` | | [`logos-derive`](https://crates.io/crates/logos-derive) | MIT OR Apache-2.0 | **none** | `yara-x-parser` | | [`miniz_oxide`](https://crates.io/crates/miniz_oxide) | MIT OR Zlib OR Apache-2.0 | **none** (`forbid`; two versions resolve) | `flate2`, `image`, `png`, `zip` | | [`moxcms`](https://crates.io/crates/moxcms) | BSD-3-Clause OR Apache-2.0 | 344 | `image` | +| [`multiversion_no_op`](https://crates.io/crates/multiversion_no_op) | Apache-2.0 OR MIT | **none** | `encoding_rs` | | [`no_std_io2`](https://crates.io/crates/no_std_io2) | Apache-2.0 OR MIT | 11 | `bitstream-io`, `zip` | +| [`num-derive`](https://crates.io/crates/num-derive) / [`paste`](https://crates.io/crates/paste) | MIT OR Apache-2.0 | **none** / **none** | `zenjxl-decoder` | | [`num-traits`](https://crates.io/crates/num-traits) | MIT OR Apache-2.0 | 1 | `delharc`, `image`, `rmp`, `rmp-serde`, `yara-x-parser` | | [`plain`](https://crates.io/crates/plain) | MIT/Apache-2.0 | 23 | `goblin` | -| [`proc-macro2`](https://crates.io/crates/proc-macro2) | MIT OR Apache-2.0 | 6 | `apfs`, `clap`, `fax`, `goblin`, `half`, `hfsplus`, `lznt1`, `mail-parser`, `rmp-serde`, `serde`, `thiserror`, `yara-x-parser` | +| [`proc-macro2`](https://crates.io/crates/proc-macro2) | MIT OR Apache-2.0 | 6 | `clap`, `fax`, `goblin`, `lznt1`, `mail-parser`, `rmp-serde`, `serde`, `thiserror`, `yara-x-parser` | | [`pxfm`](https://crates.io/crates/pxfm) | BSD-3-Clause OR Apache-2.0 | 224 | `image` | | [`qoi`](https://crates.io/crates/qoi) | MIT/Apache-2.0 | **none** (`forbid`) | `image` | -| [`quote`](https://crates.io/crates/quote) | MIT OR Apache-2.0 | **none** | `apfs`, `clap`, `fax`, `goblin`, `half`, `hfsplus`, `lznt1`, `mail-parser`, `rmp-serde`, `serde`, `thiserror`, `yara-x-parser` | +| [`quote`](https://crates.io/crates/quote) | MIT OR Apache-2.0 | **none** | `clap`, `fax`, `goblin`, `lznt1`, `mail-parser`, `rmp-serde`, `serde`, `thiserror`, `yara-x-parser` | | [`rowan`](https://crates.io/crates/rowan) | MIT OR Apache-2.0 | 55 | `yara-x-parser` | | [`rustc-hash`](https://crates.io/crates/rustc-hash) | Apache-2.0 OR MIT | **none** | a second version (1.x) for `yara-x-parser` | | [`rustc_version`](https://crates.io/crates/rustc_version) | MIT OR Apache-2.0 | **none** | build-time: `yara-x-parser` | +| [`rustversion`](https://crates.io/crates/rustversion) | MIT OR Apache-2.0 | 1 | build-time: `encoding_rs` | | [`same-file`](https://crates.io/crates/same-file) | Unlicense/MIT | 3 | `walkdir` | +| [`scopeguard`](https://crates.io/crates/scopeguard) | MIT OR Apache-2.0 | 3 | `encoding_rs` | | [`scroll`](https://crates.io/crates/scroll) | MIT | 7 | `goblin` | | [`scroll_derive`](https://crates.io/crates/scroll_derive) | MIT | 1 | `goblin` | | [`semver`](https://crates.io/crates/semver) | MIT OR Apache-2.0 | 47 | build-time: `yara-x-parser` | | [`serde_core`](https://crates.io/crates/serde_core) | MIT OR Apache-2.0 | 2 | `rmp-serde`, `serde`, `serde_json` | | [`serde_derive`](https://crates.io/crates/serde_derive) | MIT OR Apache-2.0 | **none** | `rmp-serde`, `serde` | | [`simd-adler32`](https://crates.io/crates/simd-adler32) | MIT | 36 | `flate2`, `image`, `png`, `zip` | +| [`simdutf8`](https://crates.io/crates/simdutf8) | MIT OR Apache-2.0 | 135 (SIMD UTF-8 validation, on x86, x86-64, aarch64 and wasm32 with `simd128`) | `encoding_rs` | | [`strsim`](https://crates.io/crates/strsim) | MIT | **none** (`forbid`) | `clap` | -| [`syn`](https://crates.io/crates/syn) | MIT OR Apache-2.0 | 152 (two versions resolve: 71 + 81) | `apfs`, `clap`, `fax`, `goblin`, `half`, `hfsplus`, `lznt1`, `mail-parser`, `rmp-serde`, `serde`, `thiserror`, `yara-x-parser` | +| [`syn`](https://crates.io/crates/syn) | MIT OR Apache-2.0 | 152 (two versions resolve: 71 + 81) | `clap`, `fax`, `goblin`, `lznt1`, `mail-parser`, `rmp-serde`, `serde`, `thiserror`, `yara-x-parser` | | [`text-size`](https://crates.io/crates/text-size) | MIT OR Apache-2.0 | **none** (`forbid`) | `yara-x-parser` | -| [`thiserror-impl`](https://crates.io/crates/thiserror-impl) | MIT OR Apache-2.0 | 1 | `apfs`, `hfsplus`, `lznt1`, `thiserror` | -| [`twox-hash`](https://crates.io/crates/twox-hash) | MIT | 72 | `ruzstd` | +| [`thiserror-impl`](https://crates.io/crates/thiserror-impl) | MIT OR Apache-2.0 | 1 | `lznt1`, `thiserror` | | [`typed-path`](https://crates.io/crates/typed-path) | MIT OR Apache-2.0 | 32 | `zip` | | [`typenum`](https://crates.io/crates/typenum) | MIT OR Apache-2.0 | **none** (`forbid`) | `aes`, `cbc`, `des`, `digest`, `hmac`, `md-5`, `pbkdf2`, `sha1`, `sha2` | +| [`unicase`](https://crates.io/crates/unicase) | MIT OR Apache-2.0 | **none** | `pulldown-cmark` | | [`unicode-bidi`](https://crates.io/crates/unicode-bidi) | MIT OR Apache-2.0 | 1 | `stringprep` | -| [`unicode-ident`](https://crates.io/crates/unicode-ident) | (MIT OR Apache-2.0) AND Unicode-3.0 | 2 | `apfs`, `clap`, `fax`, `goblin`, `half`, `hfsplus`, `lznt1`, `mail-parser`, `rmp-serde`, `serde`, `thiserror`, `yara-x-parser` | +| [`unicode-ident`](https://crates.io/crates/unicode-ident) | (MIT OR Apache-2.0) AND Unicode-3.0 | 2 | `clap`, `fax`, `goblin`, `lznt1`, `mail-parser`, `rmp-serde`, `serde`, `thiserror`, `yara-x-parser` | | [`unicode-normalization`](https://crates.io/crates/unicode-normalization) | MIT OR Apache-2.0 | 5 | `stringprep` | | [`unicode-properties`](https://crates.io/crates/unicode-properties) | MIT/Apache-2.0 | **none** | `stringprep` | | [`utf8parse`](https://crates.io/crates/utf8parse) | Apache-2.0 OR MIT | 1 | `clap` | -| [`uuid`](https://crates.io/crates/uuid) | Apache-2.0 OR MIT | 15 | `cfb` | -| [`web-time`](https://crates.io/crates/web-time) | MIT OR Apache-2.0 | **none** | `cfb` | -| [`zerocopy`](https://crates.io/crates/zerocopy) | BSD-2-Clause OR Apache-2.0 OR MIT | 482 | `half` | -| [`zerocopy-derive`](https://crates.io/crates/zerocopy-derive) | BSD-2-Clause OR Apache-2.0 OR MIT | 807 | `half` | | [`zmij`](https://crates.io/crates/zmij) | MIT | 70 | `serde_json` | +| [`phf`](https://crates.io/crates/phf) / `phf_shared` (two versions of each resolve) | MIT | **none** / 1 | `hayro-interpret` | +| [`smallvec`](https://crates.io/crates/smallvec) | MIT OR Apache-2.0 | 80 | `hayro-syntax`, `hayro-interpret`, `kurbo` | +| [`hayro-cmap`](https://crates.io/crates/hayro-cmap) / [`hayro-postscript`](https://crates.io/crates/hayro-postscript) | Apache-2.0 OR MIT | **none** (`forbid`) / **none** (`forbid`) | `hayro-interpret` | +| [`kurbo`](https://crates.io/crates/kurbo) / `polycool` / [`arrayvec`](https://crates.io/crates/arrayvec) | Apache-2.0 OR MIT / MIT OR Apache-2.0 / MIT OR Apache-2.0 | **none** / **none** / 52 | `hayro-interpret` | +| [`skrifa`](https://crates.io/crates/skrifa) / `read-fonts` | MIT OR Apache-2.0 | **none** (`forbid`) / **none** (`forbid`) | `hayro-interpret` (reads the PDF's embedded fonts) | +| [`font-types`](https://crates.io/crates/font-types) / `bytemuck_derive` | MIT OR Apache-2.0 / Zlib OR Apache-2.0 OR MIT | 3 / 7 | `read-fonts` | +| [`yoke`](https://crates.io/crates/yoke) / `yoke-derive` / `zerofrom` / `zerofrom-derive` / `stable_deref_trait` | Unicode-3.0 (and MIT OR Apache-2.0) | 91 / 11 / **none** / **none** / 28 | `read-fonts`, `skrifa` | +| [`fastrand`](https://crates.io/crates/fastrand) | Apache-2.0 OR MIT | **none** (`forbid`) | `phf_generator` (build time) | ## Verifying this yourself diff --git a/www/src/content/docs/project/formats.md b/www/src/content/docs/project/formats.md new file mode 100644 index 00000000..d90e4b4d --- /dev/null +++ b/www/src/content/docs/project/formats.md @@ -0,0 +1,193 @@ +--- +title: Format matrix +description: Which of exav's tools reads each file format, the scanner, the extractor or the viewer, and whether the extractor streams it or reads it whole. +--- + + + +One table for "can exav handle my file": for each format, what the +[scanner](/scanner/reference/formats/) does with it, whether the +[extractor](/unpack/formats/) streams it or reads it whole (a format read +whole is held in memory up to the buffer limit), and which plugin of the +[viewer](/viewer/) opens it. The pages of each tool have the detail: +codecs, encryption, limits. + +- **Scanner.** opened, and every member scanned; recognised and reported UNSCANNABLE or PASSWORD-PROTECTED, not opened; scanned through a text view as well as its bytes; typed by its content, its signatures applied; no type of its own. +- **Extractor.** *Streamed*: the members are decoded as they are read, at any size. *Read whole*: the container is read into memory first; *members streamed* says the members then come out one at a time. *Recognised, reported*: the format is told apart and the file is reported as not examined, never clean. +- **Viewer.** The plugin, as `@exav/viewer` names it. *No*: the viewer does not open it. + +## Archives and compressed streams + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| ZIP | `.zip` | Opened, members scanned | Streamed | `archive` | +| Packages that are ZIP files (JAR, APK, IPA, wheel, NuGet, VSIX, MSIX...) | `.jar` `.war` `.ear` `.apk` `.apks` `.xapk` `.aab` `.ipa` `.xpi` `.whl` `.nupkg` `.vsix` `.kmz` `.appx` `.appxbundle` `.msix` `.msixbundle` | Opened, members scanned | Streamed | `archive` | +| First part of a split archive (.7z.001, .zip.001, .rar.001, .tar.001, .z01) | `.7z.001` `.zip.001` `.rar.001` `.tar.001` `.z01` | Opened, members scanned | Streamed | `archive` | +| Comic book archives (CBZ, CBR, CB7, CBT) | `.cbz` `.cbr` `.cb7` `.cbt` | Opened, members scanned | Streamed | `archive` | +| gzip | `.gz` `.tgz` | Opened, members scanned | Streamed | `archive` | +| bzip2 | `.bz2` `.tbz` `.tbz2` | Opened, members scanned | Streamed | `archive` | +| xz | `.xz` `.txz` | Opened, members scanned | Streamed | `archive` | +| Zstandard | `.zst` `.tzst` | Opened, members scanned | Streamed | `archive` | +| LZ4 | `.lz4` | Opened, members scanned | Streamed | `archive` | +| LZMA (.lzma) | `.lzma` | Opened, members scanned | Streamed | `archive` | +| RPM package | `.rpm` | Opened, members scanned | Streamed | `archive` | +| lzip | `.lz` `.tlz` | Opened, members scanned | Streamed | `archive` | +| Unix compress (.Z) | `.z` `.taz` | Opened, members scanned | Streamed | `archive` | +| tar | `.tar` | Opened, members scanned | Streamed | `archive` | +| 7z | `.7z` | Opened, members scanned | Streamed | `archive` | +| RAR (RAR3 and RAR5) | `.rar` | Opened, members scanned | Read whole | `archive` | +| Cabinet (CAB) | `.cab` | Opened, members scanned | Streamed | `archive` | +| Compiled HTML Help (CHM) | `.chm` | Opened, members scanned | Read whole | `archive` | +| LHA / LZH | `.lzh` `.lha` | Opened, members scanned | Streamed | `archive` | +| ARJ | `.arj` | Opened, members scanned | Read whole | `archive` | +| ar (static libraries, Debian packages) | `.ar` `.a` `.deb` `.udeb` | Opened, members scanned | Streamed | `archive` | +| cpio (RPM payloads, initramfs) | `.cpio` | Opened, members scanned | Streamed | `archive` | +| xar (macOS packages) | `.xar` `.pkg` `.xip` | Opened, members scanned | Streamed | `archive` | +| ZOO | `.zoo` | Opened, members scanned | Read whole | `archive` | +| ARC / PKARC / PAK | `.arc` | Opened, members scanned | Read whole | `archive` | +| ALZ | `.alz` | Opened, members scanned | Read whole | `archive` | +| EGG | `.egg` | Opened, members scanned | Read whole | `archive` | +| InstallShield .z | | Opened, members scanned | Read whole | No | +| MS-Compress (SZDD, KWAJ) | | Opened, members scanned | Streamed | No | +| ACE | `.ace` | Reported, not opened | Recognised, reported | No | +| StuffIt and StuffIt X | `.sit` `.sitx` | Reported, not opened | Recognised, reported | No | +| InstallShield InstallScript cabinet | | Reported, not opened | Recognised, reported | No | +| CryptFF | | Reported, not opened | Recognised, reported | No | +| lrzip | `.lrz` | Reported, not opened | Recognised, reported | No | +| AppleSingle and AppleDouble | | Reported, not opened | Recognised, reported | No | + +## Disk images and filesystems + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| ISO 9660 and UDF | `.iso` | Opened, members scanned | Streamed | `archive` | +| Apple disk image (DMG) | `.dmg` | Opened, members scanned | Streamed | `archive` | +| VHD | `.vhd` | Opened, members scanned | Streamed | `archive` | +| VHDX | `.vhdx` | Opened, members scanned | Streamed | `archive` | +| VMDK (and OVA) | `.vmdk` `.ova` | Opened, members scanned | Streamed | `archive` | +| QCOW2 | `.qcow2` | Opened, members scanned | Streamed | `archive` | +| Windows imaging (WIM, ESD) | `.wim` `.esd` | Opened, members scanned | Streamed | `archive` | +| Partition maps (GPT, APM, MBR) and raw disk images | `.img` | Opened, members scanned | Streamed | `archive` | +| FAT12, FAT16, FAT32 | | Opened, members scanned | Streamed | No | +| NTFS | | Opened, members scanned | Streamed | No | +| ext2, ext3, ext4 | | Opened, members scanned | Streamed | No | +| exFAT | | Opened, members scanned | Streamed | No | +| SquashFS | `.squashfs` `.sqfs` `.snap` | Opened, members scanned | Streamed | No | +| cramfs | `.cramfs` | Opened, members scanned | Streamed | No | +| romfs | `.romfs` | Opened, members scanned | Streamed | No | +| EROFS | `.erofs` | Opened, members scanned | Streamed | No | + +## Installers, self-extractors and packers + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| InstallShield MSI | | Reported, not opened | Recognised, reported | No | +| NSIS installer | | Opened, members scanned | Read whole | No | +| Inno Setup installer | | Reported, not opened | Recognised, reported | No | +| Self-extracting archives | | Opened, members scanned | Streamed | No | +| AutoIt compiled scripts | | Opened, members scanned | Read whole | No | +| UPX-packed executables | | Opened, members scanned | Read whole | No | +| Other packed PE executables | | Opened, members scanned | Read whole | No | +| Mach-O universal binaries | | Opened, members scanned | Streamed | No | + +## Documents + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| OLE2 compound files (legacy Office, MSI) | | Opened, members scanned | Read whole | No | +| PDF | `.pdf` | Opened, members scanned | Read whole | `pdf` | +| Word (DOCX) | `.docx` `.docm` `.dotx` | Opened, members scanned | Streamed | `docx` | +| Excel (XLSX) | `.xlsx` `.xlsm` | Opened, members scanned | Streamed | `xlsx` | +| PowerPoint (PPTX) | `.pptx` `.pptm` `.ppsx` `.potx` | Opened, members scanned | Streamed | `pptx` | +| Word 6 to 2003 (DOC) | `.doc` `.dot` | Scanned with its text | Read whole | `doc` | +| Excel 4 to 2003 (XLS) | `.xls` `.xlt` | Scanned with its text | Read whole | `xls` | +| PowerPoint 95 to 2003 (PPT) | `.ppt` `.pps` `.pot` | Scanned with its text | Read whole | `ppt` | +| RTF | `.rtf` | Scanned with its text | Read whole | `rtf` | +| OpenDocument text (ODT) | `.odt` `.ott` `.fodt` | Scanned with its text | Streamed | `odt` | +| OpenDocument spreadsheet (ODS) | `.ods` `.ots` `.fods` | Scanned with its text | Streamed | `ods` | +| OpenDocument presentation (ODP) | `.odp` `.otp` `.fodp` | Scanned with its text | Streamed | `odp` | +| Keynote | `.key` | Scanned with its text | Streamed | `keynote` | +| Numbers | `.numbers` | Scanned with its text | Streamed | `numbers` | +| Pages | `.pages` | Scanned with its text | Streamed | No | +| CSV and TSV | `.csv` `.tsv` | Scanned with its text | No | `csv` | +| Visio | `.vsd` `.vst` `.vdx` `.vtx` `.vsdx` `.vsdm` `.vstx` `.vstm` | Bytes only | No | `visio` | +| CorelDRAW | `.cdr` `.cmx` | Bytes only | No | `coreldraw` | +| Publisher | `.pub` | Opened, members scanned | Read whole | `publisher` | +| PageMaker 6 to 7 | `.pmd` `.pm6` `.p65` | Bytes only | No | `pagemaker` | +| FreeHand 3 to 11 | `.fh` `.fh3` `.fh4` `.fh5` `.fh7` `.fh8` `.fh9` `.fh10` `.fh11` | Bytes only | No | `freehand` | +| QuarkXPress 3.1 to 4.1 | `.qxd` `.qxt` | Bytes only | No | `quarkxpress` | +| Zoner Draw 4 and 5 | `.zmf` | Bytes only | No | `zonerdraw` | +| WordPerfect Graphics 1 and 2 | `.wpg` | Bytes only | No | `wpg` | +| Hangul Word Processor 3 (HWP3) | `.hwp` | Opened, members scanned | Read whole | No | +| OneNote | `.one` | Opened, members scanned | Streamed | No | +| Adobe XDP | `.xdp` | Opened, members scanned | Read whole | No | + +## Mail and encodings + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| TNEF (winmail.dat) | `winmail.dat` `.tnef` | Opened, members scanned | Streamed | `archive` | +| Email (MIME) and MHTML | `.eml` `.mht` `.mhtml` | Opened, members scanned | Read whole | No | +| BinHex | `.hqx` | Opened, members scanned | Read whole | No | +| uuencode | `.uu` | Opened, members scanned | Read whole | No | + +## Images + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| PNG, JPEG, GIF, WebP and SVG | `.png` `.jpg` `.jpeg` `.webp` `.gif` `.svg` | Typed, signatures applied | No | `image` | +| TIFF | `.tif` `.tiff` | Typed, signatures applied | No | `image` | +| BMP | `.bmp` | Typed, signatures applied | No | `image` | +| ICO, PNM, QOI, DDS, farbfeld, Radiance HDR | `.ico` `.cur` `.pbm` `.pgm` `.ppm` `.pnm` `.pam` `.qoi` `.dds` `.ff` `.hdr` | Typed, signatures applied | No | `image` | +| Targa (TGA) | `.tga` `.icb` | Bytes only | No | `image` | +| JPEG 2000 | `.jp2` `.jpf` `.jpx` `.j2k` `.j2c` | Typed, signatures applied | No | `image` | +| JBIG2 | `.jb2` `.jbig2` | Typed, signatures applied | No | `image` | +| JPEG XL | `.jxl` | Typed, signatures applied | No | `image` | +| Camera RAW (DNG, CR2, CR3, NEF, ARW, ORF, RW2, RAF, PEF...) | `.dng` `.gpr` `.cr2` `.cr3` `.crw` `.nef` `.nrw` `.arw` `.sr2` `.srf` `.orf` `.ori` `.rw2` `.rwl` `.raw` `.raf` `.pef` `.srw` `.3fr` `.fff` `.iiq` `.x3f` `.mrw` `.dcr` `.kdc` `.erf` `.mef` `.mos` | Typed, signatures applied | No | `image` | +| HEIC and HEIF | `.heic` `.heif` | Typed, signatures applied | No | No | + +## Drawings and 3D models + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| OpenDocument drawing (ODG) | `.odg` `.otg` `.fodg` | Scanned with its text | Streamed | `odg` | +| AutoCAD DWG | `.dwg` | Opened, members scanned | Read whole | `dwg` | +| AutoCAD DXF | `.dxf` | Opened, members scanned | Read whole | `dxf` | +| IFC | `.ifc` | Bytes only | No | `ifc` | +| STL | `.stl` | Bytes only | No | `stl` | +| Wavefront OBJ | `.obj` | Bytes only | No | `obj` | +| Stanford PLY | `.ply` | Bytes only | No | `ply` | +| glTF 2.0 (GLTF, GLB) | `.gltf` `.glb` | Bytes only | No | `gltf` | + +## Video and audio + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| Video (MP4, MOV, WebM, Ogg) | `.mp4` `.m4v` `.mov` `.webm` `.ogv` | Bytes only | No | `video` | +| Audio (MP3, M4A, AAC, WAV, Ogg, Opus, FLAC) | `.mp3` `.m4a` `.aac` `.wav` `.ogg` `.oga` `.opus` `.flac` | Bytes only | No | `audio` | + +## Text and code + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| Markdown | `.md` `.markdown` | Scanned with its text | No | `text` | +| JSON | `.json` `.geojson` | Typed, signatures applied | No | `text` | +| XML (and RSS, KML, GPX, plist...) | `.xml` `.xsd` `.xsl` `.xslt` `.rss` `.atom` `.kml` `.gpx` `.rdf` `.wsdl` `.plist` | Typed, signatures applied | No | `text` | +| HTML | `.html` `.htm` `.xhtml` | Scanned with its text | No | `text` | +| Diffs and patches | `.diff` `.patch` | Typed, signatures applied | No | `text` | +| Plain text, logs, configuration, subtitles | `.txt` `.text` `.log` `.jsonl` `.ndjson` `.srt` `.vtt` `.yaml` `.yml` `.toml` `.ini` `.cfg` `.conf` `.properties` | Typed, signatures applied | No | `text` | +| Source code | `.js` `.mjs` `.cjs` `.jsx` `.ts` `.tsx` `.mts` `.cts` `.css` `.scss` `.py` `.sh` `.bash` `.zsh` `.bat` `.ps1` `.c` `.h` `.cc` `.cpp` `.hpp` `.cs` `.rs` `.go` `.java` `.kt` `.swift` `.rb` `.php` `.pl` `.lua` `.sql` | Typed, signatures applied | No | `text` | + +## Executables and other carriers + +| Format | Extensions | Scanner | Extractor | Viewer | +|---|---|---|---|---| +| Windows executables (PE) | `.exe` `.dll` `.sys` | Typed, signatures applied | No | No | +| ELF | | Typed, signatures applied | No | No | +| Mach-O | | Typed, signatures applied | No | No | +| Windows shortcuts (LNK) | `.lnk` | Opened, members scanned | Read whole | No | +| Python bytecode (.pyc) | `.pyc` | Opened, members scanned | Streamed | No | +| Java class files | `.class` | Opened, members scanned | Read whole | No | +| SWF (Flash) | `.swf` | Opened, members scanned | Streamed | No | +| AI models (pickle, safetensors) | `.pkl` `.safetensors` | Opened, members scanned | Read whole | No | +| Microsoft Script Encoder | `.vbe` `.jse` | Opened, members scanned | Read whole | No | diff --git a/www/src/content/docs/project/license.md b/www/src/content/docs/project/license.md index b30d5a0a..5bc98435 100644 --- a/www/src/content/docs/project/license.md +++ b/www/src/content/docs/project/license.md @@ -25,6 +25,14 @@ primitives and the `webpki-roots` certificate store. Among the rest: - exav also contains code ported or derived from permissively licensed projects (RAR, AZO, CAB, DMG, PPMd and bzip2 decoders among them, the HWP3 layout, and `iced-x86`'s generated decoder tables), each attributed in `NOTICE`. +- exav-render's office converters (spreadsheets, presentations, text + documents) are written from published specifications, among them + Microsoft's Open Specifications and the OASIS OpenDocument standard, and + from what other converters do with sample files, used as a black box. No code + of another office application is in them. The specifications, the tables and + message layouts taken from MIT-licensed projects (numbers-parser and + keynote-parser), and the test fixtures are listed in + [`crates/exav-render/NOTICE`](https://github.com/sylvinus/exav/blob/main/crates/exav-render/NOTICE). - The WASM sandbox runs exav under a WASI runtime you provide, under its own license; exav does not bundle it. See the [WASM sandbox guide](/scanner/guides/wasm-sandbox/). diff --git a/www/src/content/docs/project/roadmap.md b/www/src/content/docs/project/roadmap.md index 5c136617..5e244dd0 100644 --- a/www/src/content/docs/project/roadmap.md +++ b/www/src/content/docs/project/roadmap.md @@ -1,6 +1,6 @@ --- title: Roadmap -description: What's next for exav, from signature-matching performance and one read pass for large files to broader bytecode coverage, streaming RAR, multi-volume archives, JPEG XL in the viewer and less dependency unsafe. +description: What's next for exav, from signature-matching performance and one read pass for large files to broader bytecode coverage, streaming RAR, multi-volume archives, the viewer's Office converters, document text search and less dependency unsafe. --- exav is beta and `0.0.x`: any release may change behaviour or APIs. What has @@ -29,7 +29,7 @@ The exceptions are the documented detection gaps: unimplemented `inflate`) come first, with differential validation against `clamscan`. - **Extended and continuous fuzzing**, and a large no-silent-skip CI suite. -## Large files: fewer passes, PE structure +## Large files: fewer passes, PE structure, HTTP A file larger than `--max-object-bytes` gets the full engine, read through a block cache (see [Streaming & memory](/scanner/concepts/streaming-memory/)). Two things @@ -44,13 +44,21 @@ remain: is `LIMITS-EXCEEDED` unless something is found. Reading only the headers and sections would cover the common case of a small executable with a large overlay. YARA's `pe`, `elf` and `dotnet` modules have the same limit. +- **A spill file for `exav-grep`.** A container inside a member longer than + `--max-object-bytes` is reported unreadable, because opening it takes + somewhere to write it. +- **HTTP sources.** A scan over HTTP has no timeout of its own, follows + redirects without filtering where they lead, and does not check that the + object stays the same between range requests (see + [HTTP sources](/scanner/concepts/streaming-memory/#http-sources)). ## Streaming the containers read whole -Most containers, DMG disk images included, are walked member by member off the -source, so peak memory is one member rather than the whole decoded object (see -[how extraction works](/unpack/how-it-works/)). RAR, 7z, OLE, the virtual -disks and filesystems, and a few others are still read whole (see +Most containers, disk images and filesystems included, are walked member by +member off the source, so peak memory is one member rather than the whole +decoded object (see [how extraction works](/unpack/how-it-works/)), the files +of a disk image or an archive past `--max-object-bytes` through a spill file. +RAR, OLE, CHM, ARJ, email and a few others are still read whole (see [Supported formats](/scanner/reference/formats/#size-what-is-read-as-it-goes-and-what-is-read-whole)): past `--max-object-bytes` their own bytes get the full scan but their members are not extracted, and the file is `LIMITS-EXCEEDED` unless something is found. @@ -71,20 +79,15 @@ bounded object, so streaming it would save little memory; raising counted, so the list of what to add is measured. - Verifying Authenticode signatures cryptographically. Parsing, digest checks and `.crb` matching exist today. -- **Joining format-aware volume sets**: RAR `.partN`/`.rNN`, ZIP `.zNN`, and - multi-cabinet CAB. Byte-split sets (`x.7z.001`, `x.7z.002`, ...) are already - joined when their parts arrive together (see - [split archives](/scanner/guides/daemon/#split-archives)). A format-aware volume - carries its own headers and a member's data resumes past the next volume's - header, so the join belongs in the format's decoder. ClamAV does not join - them either; exav reports the split member and scans the part in the volume - it was given. Sibling volumes will only be looked for next to the scanned - file, under names generated from its own, as regular files (no symlinks). A - cabinet names its successor inside the file, so that name will be matched - against the directory listing and never opened as a path. +- **Volume sets met inside a container.** RAR, ZIP and CAB volume sets are + joined when their volumes sit next to each other on disk, or arrive together + at the daemon (see [split archives](/scanner/guides/daemon/#split-archives)), + but not when the volumes are members of another archive: each is then scanned + on its own and the member continued in another volume is reported. - The unimplemented `Target:` values (internal, other, and ClamAV's reserved - slot 8), `EP` and section offsets on ELF and Mach-O files, and the unloaded - database extensions (`.cat`, `.ioc`, and the legacy `.sdb`/`.zmd`/`.rmd`). + slot 8), `EP` on Mach-O files (clamscan does not resolve it either), and the + unloaded database extensions (`.cat`, `.ioc`, and the legacy + `.sdb`/`.zmd`/`.rmd`). None appears in a stock official database; all matter for third-party feeds. See [Targets](/scanner/reference/comparison-with-clamav/#targets). @@ -93,28 +96,45 @@ bounded object, so streaming it would save little memory; raising - QEMU/KVM dynamic detonation: a separate, heavyweight feature, only if there is demand. -## File viewer: JPEG XL, after 0.0.2 - -The viewer does not open JPEG XL yet. The decoder to build on is -[`jxl`](https://crates.io/crates/jxl) (jxl-rs, the libjxl team's own, -BSD-3-Clause), forked as a crate of exav's so that it can be -`#![forbid(unsafe_code)]` like the other decoders the viewer runs: - -- **Without SIMD.** Its SIMD (347 `unsafe` sites) is behind features, and the - WebAssembly build does without it. -- **Without the rest of its `unsafe`**, about 80 sites that no feature turns - off. Most are in the image buffers: allocation by hand, buffers - reinterpreted as another number type, rows sliced from raw pointers. They - become typed vectors. The others are unchecked indexing in the modular - predictor and the entropy decoder, a small-vector type and a few casts. Its - `array-init` and `byteorder` dependencies give way to the standard library. -- **Checked** against libjxl's `djxl` on its conformance files, and fuzzed. -- **In the viewer only,** in its WebAssembly image module: the scanner's image - hashing gains nothing from it, since ClamAV's image signatures do not cover - the format. - -The cost is speed (no SIMD, bounds checks), and following upstream, which -releases about monthly, by hand. +## Documents, search and the viewer + +The viewer opens JPEG XL (a fork of the libjxl team's jxl-rs, `forbid(unsafe_code)` +and built without SIMD), camera RAW previews, and converts the old Office +formats in the browser. What is not done: + +- **The text-document converter is a prototype.** On the project's test files, + 32% of the pages of DOC files come out close to a reference rendering, + against 50% for DOCX drawn natively. Open decisions: falling back to the + external converter for what it reports unsupported (Pages '09, Word 1 and + 2), making it the default even where an external converter is set, keeping + tracked changes in DOC, and Pages tables, pictures and headers. +- **Presentations.** PowerPoint 2, 3 and 4 are recognised and reported + unsupported. PowerPoint 95 pictures, freeforms and arcs are left out. The + first slide could be drawn while the rest converts. Binary Visio, CorelDRAW, + Publisher and the other page layout formats are read only by an + [external converter](/viewer/integration/#the-external-converter). +- **Searching documents.** `exav-grep --text` and the scanner's text view read + Markdown, HTML, the XML parts of Office and OpenDocument files, DXF and DWG, + and, behind opt-in features, PDF and the legacy and flat office formats. + Still to do: IFC names and property values, an email's headers, the + hyperlinks of OOXML files (apart from their labels), PDF link annotations, + the speaker notes of presentations, and matches located by page and by + drawing entity. PDF text and the office readers are not defaults because of + hayro's `unsafe` and the size of the readers. OCR for images and scanned + pages, and QR codes for the scanner, are proposed. +- **Drawings and models.** DWG before R13 is reported unsupported. Large + drawings have no level of detail, annotative objects and linked images are + not resolved (external references are, from the files around the drawing), + and 3D is out of scope. IFC geometry has + [known gaps](/subprojects/exav-render/#ifc-and-stl-models). glTF with Draco + or meshopt, and PLY point clouds, are not drawn. +- **Camera RAW.** The sensor data is never decoded, so a file with no preview + (most phones' DNG, Minolta MRW, CR3 whose preview is HEIF) shows an error. +- **Other viewer gaps.** Replacing a document in a sandboxed frame starts the + zoom and position over. Archives left out on purpose: email, OLE + containers, OpenDocument and EPUB, packers and installers. Text previews + have no line numbers, tree view or search. The browser's own JPEG XL decoder + could spare the module's size where it exists. ## Hardening: less dependency `unsafe` @@ -128,10 +148,11 @@ ongoing goal: - **Audit** each dependency's `unsafe` by reachability from a hostile-input scan path (the per-crate counts are done). - **Drop** dependencies exav does not need (done for `tar`'s `xattr`, which was - the largest source of syscall `unsafe`). + the largest source of syscall `unsafe`, for `half` and `zerocopy`, and for + zstd's checksum, `twox-hash`). - **Make safe** what can be: portable builds without SIMD where the speed cost is - acceptable, and vendoring then removing `unsafe` (the RAR PPMd decoder has - none). + acceptable, and vendoring then removing `unsafe` (the RAR PPMd, LZFSE and + InstallShield Z decoders have none). - **Reuse exav's own safe code** where it duplicates an `unsafe` dependency. - **Verify** the rest with `cargo-fuzz` and Miri (`make miri`), and document the containment (panic isolation, the prefork process boundary, the WASM build). diff --git a/www/src/content/docs/scanner/concepts/design-principles.md b/www/src/content/docs/scanner/concepts/design-principles.md index 2dc7ed92..ec9c9e9d 100644 --- a/www/src/content/docs/scanner/concepts/design-principles.md +++ b/www/src/content/docs/scanner/concepts/design-principles.md @@ -62,7 +62,7 @@ Where a deployment chooses that, with [`--partial-as ok`](/scanner/reference/cli/#what-an-unscannable-object-becomes), the choice is named per verdict and logged per object on stderr; the ICAP service also announces it at startup and keeps `X-Exav-Status: PARTIAL` in its response. -The rule does not forbid accepting a risk; it forbids hiding one. +The rule allows accepting a risk and forbids hiding one. ### Refusing empty coverage diff --git a/www/src/content/docs/scanner/concepts/how-it-works.md b/www/src/content/docs/scanner/concepts/how-it-works.md index fdf8b79e..ea2982c2 100644 --- a/www/src/content/docs/scanner/concepts/how-it-works.md +++ b/www/src/content/docs/scanner/concepts/how-it-works.md @@ -86,6 +86,13 @@ File type comes from the content, not the extension: an executable renamed `.jpg` is still scanned as an executable. The budgets every extraction runs under are in [How extraction works](/unpack/how-it-works/). +A document is matched twice: as its bytes, and as the text a reader sees, a +second view of the same object with its markup, escapes and run boundaries +gone and each link followed by where it goes. That view is made by the +`visible-text` feature, on by default; which documents it reads, and what +happens when one cannot be read, is in +[Supported formats](/scanner/reference/formats/#the-text-a-reader-sees). + ## Signature types and runtime structures Different ClamAV signature types compile to different runtime structures rather @@ -257,13 +264,11 @@ least as much) rules most objects out first.
      -Neither the sweep nor a check has a window to fall out of. The sweep reads the -object once, in order, and looks up the few positions before a seam from the -bytes on both sides of it, so an anchor split across a seam is found without -re-reading the chunk. A check -asks for the bytes it needs at their offset, before or after the hit, from -memory or, past `--max-object-bytes`, from the -[block cache](/scanner/concepts/streaming-memory/#past-it-through-a-block-cache). +The sweep reads the object once, in order, and looks up the few positions before +a seam from the bytes on both sides of it, so an anchor split across a seam is +found without re-reading the chunk. A check asks for the bytes it needs at their +offset, before or after the hit, from memory or, past `--max-object-bytes`, from +the [block cache](/scanner/concepts/streaming-memory/#past-it-through-a-block-cache). Over an object not held in memory, regular expressions (PCRE subsignatures, YARA strings) run as lazy DFAs stepped through it, forward to find where a match diff --git a/www/src/content/docs/scanner/concepts/quirks.md b/www/src/content/docs/scanner/concepts/quirks.md index 028966e1..e55190cb 100644 --- a/www/src/content/docs/scanner/concepts/quirks.md +++ b/www/src/content/docs/scanner/concepts/quirks.md @@ -139,7 +139,12 @@ is a 1 KiB decoy and whose second holds the malware was a real false negative. **Appended archives** are carved too: ZIP, gzip, bzip2, xz, 7z, RAR and CAB magics after offset 0 (self-extractors, PE overlays, droppers that staple an archive onto a carrier). A carve that fails to decode is usually a chance byte -run and is dropped rather than making the carrier `UNSCANNABLE`. The exception is +run and is dropped rather than making the carrier `UNSCANNABLE`. A file that +the magic at its start types as an archive (a PDF, a tar, a gzip, a bzip2) is +not carved for magics, but a ZIP behind it is found by the record at the end of +the file, which is how `unzip` finds it, and is scanned as the ZIP it is. An +ISO 9660 or ext image whose first bytes are an archive's magic, in the space +those formats leave free, is walked as the image too. The exception is an encrypted ZIP appended to a picture or a document whose central directory checks out: archive tools open such a polyglot through its trailing directory, so it is reported `PASSWORD-PROTECTED`. @@ -292,9 +297,12 @@ detection. A macro sheet is a `BOUNDSHEET` record (`0x0085`) with sheet type 1, and the macros are `FORMULA` records holding `ptg` token streams in which built-in functions (`EXEC`, `CALL`, `ALERT`) are numeric ids. The bytes `EXEC` never appear in the file, so a byte-pattern signature for them cannot match. exav walks -the record stream and produces an artifact with the macro-sheet names, the string -constants and each formula's tokens with function ids decoded back to names, so -signatures match the real calls and `--detect macros` fires for XLM as for VBA. +the record stream with the reader the XLS converter uses and produces an artifact +with the macro-sheet names, the defined names, the text cells and each formula as +Excel shows it (`=EXEC("calc.exe")`), function ids decoded back to names, so +signatures match the real calls and `--detect macros` fires for XLM as for VBA. A +formula the reader cannot finish still gives the calls and strings it read before +the token it could not. Other content that must be decoded before any signature can see it: @@ -354,7 +362,10 @@ Which signatures meet which rendering follows clamscan, probed one signature at a time: `Target:3` and `Target:7` signatures match only the HTML and text renderings, never raw bytes; an HTML file has the HTML rendering and a text file the text one, not the other; `Target:4` (mail) signatures match a mail's raw -bytes. An HTML signature on plain JavaScript is a match clamscan cannot make, +bytes. The exception is the [visible text](/scanner/reference/feature-flags/) of +a document (the `visible-text` feature, off under `--clamav-compat`): the words +of an HTML page, a Markdown file or an Office part are also a text view, which +`Target:7` signatures see. An HTML signature on plain JavaScript is a match clamscan cannot make, and one did fire on obfuscated npm code before. Java signatures (`Target:12`) are gated on the `cafebabe` magic because one fired on APK members, and the targets exav cannot positively identify are skipped (see diff --git a/www/src/content/docs/scanner/concepts/streaming-memory.md b/www/src/content/docs/scanner/concepts/streaming-memory.md index b2d625bc..41852264 100644 --- a/www/src/content/docs/scanner/concepts/streaming-memory.md +++ b/www/src/content/docs/scanner/concepts/streaming-memory.md @@ -15,6 +15,39 @@ parse it whole (below): What changes with size is where the file is while that runs. +## Can a file be larger than the RAM? + +Most can. A file on disk is never loaded past `--max-object-bytes`, so what a +scan holds does not grow with the file. The time does: a file past the limit is +read several times over, and each pass goes through the whole signature +database, so a 20 GB file takes minutes with a handful of signatures and longer +with a full database. Served over HTTP and scanned with only the four built-in +signatures, 20 GB objects of text, RAR, OLE and PDF took from 2 to 21 minutes +and held 50 to 61 MB at their peak ([the figures](/scanner/guides/sizing/#what-this-host-scans-and-what-to-change-to-scan-more)). +A real database is the larger part of the memory: +[sizing a server](/scanner/guides/sizing/) works through a 4 GiB machine. + +What happens past `--max-object-bytes` (256 MiB), by kind of input: + +| Input | Held in memory | Needs disk | A 20 GB input | +|---|---|---|---| +| A file on disk: executable, script, document, raw disk or filesystem image | Block cache, 8 MiB | No | Every signature on its bytes is matched. A PE, ELF or .NET file is not [parsed](#past-it-through-a-block-cache), so the signatures and rules on its structure (sections, imports, YARA's `pe`, `elf` and `dotnet`) are not evaluated; if nothing is found, the verdict is `LIMITS-EXCEEDED` | +| ZIP, tar, CAB, ISO, LHA, `ar`, cpio, 7z, WIM, xar, the single-stream compressors, self-extracting executables, and the formats built on them | One member, up to 256 MiB | Only for a member over 256 MiB, up to `--max-spill-bytes` (2 GiB) | Scanned member by member. A member past the spill ceiling, or any member with `--spill-dir off`, is not scanned and the file is `LIMITS-EXCEEDED`. A 7z folder with an encrypted coder or several packed streams (BCJ2) is decoded whole, under 256 MiB | +| Disk images (VHD, VHDX, QCOW2, VMDK, DMG) and the filesystems inside them or on their own: FAT, exFAT, NTFS, ext, SquashFS, cramfs, romfs, EROFS, HFS+, APFS | A cache of up to 32 MiB (8 MiB for a raw image), and one file up to 256 MiB | Only for a file over 256 MiB, up to `--max-spill-bytes` (2 GiB) | Walked in place, each file read from where the volume has it, and one past 256 MiB goes to a spill file as a ZIP member does. A file stored compressed by the filesystem (HFS+, APFS, NTFS, EROFS) is decoded whole under the limit and reported past it. A raw image's own bytes are matched as part of the image | +| RAR, OLE, PDF, CHM, ARJ, email and the other formats [read whole](/scanner/reference/formats/#size-what-is-read-as-it-goes-and-what-is-read-whole) | Not held: the container is not opened | No | The file's own bytes are scanned, its members are not unpacked, and the result is `LIMITS-EXCEEDED` unless something is found. Raising `--max-object-bytes` to cover it takes that much RAM | +| Stdin, `INSTREAM`, an ICAP body | 16 MiB | Yes: the stream is written to a temporary file to make it seekable | The first 2 GiB is scanned and the result is `LIMITS-EXCEEDED PARTIAL`. With `--spill-dir off`, the first 16 MiB. Raise `--max-spill-bytes` and give it the disk, or hand the daemon a path instead | +| An `http(s)://` URL | See [HTTP sources](#http-sources) | No | Scanned in full, over the network about twice | + +Two other limits apply whatever the memory. `--max-scan-secs` stops a daemon +job after 120 seconds by default (a one-shot run has no time limit), and a +file past `--max-object-bytes` is read several times over, so a very large one +can reach it. `--max-matcher-bytes` (10 GiB) counts what members and carved +objects feed to the matcher, not the top-level file, so it did not stop the 20 +GiB file above. It does count the files of an archive or a disk image, and the +holes of a sparse file, which read as zeros: an archive or image holding more +than that is scanned as far as it, then reported `LIMITS-EXCEEDED`, and raising +the limit takes it further. + ## Up to `--max-object-bytes`: in memory A file up to `--max-object-bytes` (256 MiB by default) is read into memory, and @@ -27,7 +60,7 @@ scanned in full. ## Past it: through a block cache -A larger file is read through a cache of 64 KiB blocks, of which at most 8 MiB +A larger file is read through a cache of 1 MiB blocks, of which at most 8 MiB are held, least recently used dropped first. So are an HTTP source of that size and a spill file (below). Verifying a match can still look anywhere in the file: the cache bounds what is held at once, not how far a check may reach, and @@ -36,13 +69,14 @@ one, with the same results, only slower, since the file is read a few times over: once for format detection's search, carving and the digests (when a hash signature has its size), once for the signature sweep (every anchor at once), twice more for YARA's prefilter when rules are loaded, and again in the parts a -check needs. +check needs. [The block cache](#the-block-cache) below says how it works and +what its three settings trade.
      One source, read through a block cache A file or an HTTP source past --max-object-bytes, or a spill - file, is read through a block cache of 64 KiB blocks holding 8 MiB. Every + file, is read through a block cache of 1 MiB blocks holding 8 MiB. Every check asks it for bytes at an offset: the signature sweep a chunk at a time in order, verification and the container walk at any offset, the regexes stepped through, the hashes, typing's search and carving in one shared pass. @@ -85,7 +119,7 @@ check needs. past --max-object-bytes past the limit too a large member or view - 64 KiB blocks, 8 MiB held, + 1 MiB blocks, 8 MiB held, least recently used dropped; bytes at any offset, or in 1 MiB chunks in order @@ -116,8 +150,8 @@ A few things need the file whole and are not done past the limit: - YARA's `pe`, `elf` and `dotnet` modules; - a PCRE subsignature with a lookaround or a backreference, and the `fuzzy_img` image hash; -- containers whose format is read whole (7z, RAR, OLE, PDF, the virtual-disk and - filesystem images and others; see +- containers whose format is read whole (RAR, OLE, PDF, CHM, ARJ, email and + others; see [Supported formats](/scanner/reference/formats/#size-what-is-read-as-it-goes-and-what-is-read-whole)). A file for which one of these applied is reported `LIMITS-EXCEEDED`, naming @@ -127,18 +161,133 @@ run, at the cost of memory. The normalised views of a large text file are as large as the file, so they go to a [spill file](#spill-files). +## The block cache + +Picture a window of a few large blocks sliding over the object, with a short +memory of the blocks it has just left. + +- **What it is.** Each object read in place (a file, an HTTP source, a spill + file) has one cache. It cuts the object into aligned blocks of + `--object-cache-block-bytes` (1 MiB) and keeps up to `--object-cache-bytes` + (8 MiB) of them, so 8 blocks by default. Never fewer than two. +- **A read.** Every check asks for bytes at an offset. If the block holding them + is held, the bytes are copied out of memory. If not, the whole block is read + from the source, in one request over HTTP, and the least recently used block + is dropped to make room. A read that spans two blocks takes both. +- **Two kinds of read.** The sweeps (signature sweep, hashes, typing, carving) + go through the object in order, so each block is read once per pass and is + never asked for again. Verifying a match, reading a ZIP's directory and then + its members, or stepping a regex, ask at scattered offsets, and each miss + costs a whole block, however few bytes it needed. +- **Over HTTP, runs are merged.** A read that goes on from where the last + request stopped asks for twice as much as that one did, from one block up to + `--http-request-bytes` (8 MiB). A pass over a large object costs one request + per 8 MiB, not one per block, while scattered reads stay one block each. +- **Passes.** An object larger than the cache is read again from the source at + each pass, as the blocks of the previous pass have been dropped by the time it + ends. An object that fits in the cache is read from the source once and held, + so every pass after the first costs no request at all. +- **What it costs in memory.** The cache's size for each object being scanned in + place, plus up to one request held by the HTTP reader while it is read, plus + a copy of the bytes a check is looking at (a 1 MiB chunk for the sweep). + Objects nested in one another each have their own cache: an archive on HTTP + whose big member is spilled to disk holds two. A daemon with + `--max-process-bytes` lowers the cache and the request to an eighth of a job's + memory each, at most. +- **What it does not do.** It reads nothing ahead of the block asked for (apart + from the doubling of HTTP requests above), shares nothing between objects, + does not write, and does not change what the scan finds, only how many reads + of the source it takes. + +Which setting to change: + +| Setting | Raise it when | What it costs | +|---|---|---| +| `--object-cache-block-bytes` | requests are slow (HTTP, network storage) and there are many small misses: fewer requests for the same scattered reads | each miss moves a whole block: 1 MiB to verify a 100-byte match. Past about 1 MiB the misses stop falling | +| `--object-cache-bytes` | the object is not much bigger than the cache: every pass but the first is then free, and a few more blocks hold more of the places verification goes back to | that much memory per object being scanned in place, nested ones included | +| `--http-request-bytes` | a pass over a large object is bound by round trips, not bandwidth | that much memory held while the object is read | + +What each setting did against a local server that waits before answering each +request, so the delay stands for the round trip. The first object is a 300 MB +file under a database with a pattern, a logical signature with a gap and the +hash of that size. It is read about 4.4 times over, and 300 MB is past +`--max-object-bytes`, so it goes through the cache. Block, cache and request +sizes in the first three columns, defaults in bold: + +| Block | Cache | Request | Requests | Fetched | No delay | 20 ms | 100 ms | +|---|---|---|---|---|---|---|---| +| 64K | 8M | 8M | 506 | 1.23 GB | 4.4 s | 15.0 s | 55.9 s | +| 256K | 8M | 8M | 361 | 1.29 GB | 4.1 s | 12.0 s | 41.2 s | +| **1M** | **8M** | **8M** | 176 | 1.33 GB | 4.0 s | 8.0 s | 22.1 s | +| 4M | 8M | 8M | 170 | 1.35 GB | 4.1 s | 7.9 s | 21.4 s | +| 1M | 32M | 8M | 176 | 1.33 GB | 4.2 s | 8.2 s | 22.1 s | +| 1M | 8M | 1M | 1,275 | 1.33 GB | 4.4 s | 31.7 s | 136.0 s | +| 1M | 8M | 32M | 66 | 1.33 GB | 4.0 s | 5.4 s | 10.7 s | + +The second is a 20 MB ZIP of 2,000 small members, read in place (with +`--max-object-bytes 1M`, as a much larger archive would be). The cache is +smaller than the file, so the archive is read six times over; a cache that holds +it reads it once: + +| Block | Cache | Requests | Fetched | No delay | 20 ms | 100 ms | +|---|---|---|---|---|---|---| +| 64K | 8M | 56 | 121 MB | 0.7 s | 1.8 s | 6.3 s | +| **1M** | **8M** | 32 | 122 MB | 0.7 s | 1.3 s | 4.0 s | +| 4M | 8M | 20 | 125 MB | 0.7 s | 1.1 s | 2.7 s | +| 64K | 32M | 10 | 20 MB | 0.6 s | 0.8 s | 1.7 s | +| 1M | 32M | 6 | 20 MB | 0.6 s | 0.7 s | 1.3 s | + +On a local disk the block size changed nothing measurable (the operating +system's page cache absorbs it), so the defaults are chosen for the network. The +tables measure round trips, not bandwidth: a server on the same host answered +at several GB/s. + +## HTTP sources + +An `http(s)://` target (`--allow-http-scan`, and `SCANURL` on the daemon) is +read by range requests and never written to disk. It takes the same two paths +as a file, with the network in place of the disk: + +- **Up to `--max-object-bytes`:** fetched in full into memory, once. A 100 MB + object cost 16 requests, 1.0 times its size on the wire, and a 226 MB peak. +- **Past it:** read through the [block cache](#the-block-cache), so every pass + over the file fetches it again. A 300 MB object, under a database with a hash + of that size, a gap signature and a pattern, cost 4.4 times its size on the + wire (1.33 GB in 176 requests). How many passes there are depends on the + database: detection and the signature sweep make two at least, and rules that + need more (YARA's prefilter reads the file twice more) transfer more. So a + 20 GB object is tens of gigabytes of egress or more. To pay in disk instead, + [download it and scan the file](/scanner/guides/scanning/#scanning-over-http). + `--object-cache-block-bytes`, `--object-cache-bytes` and `--http-request-bytes` + set how many requests that takes. + +What the source must and must not do: + +- It must answer range requests with `206` and a `Content-Range` that carries + the total size. A server that does not is an error, not a fallback to a full + download. +- The transfer has no timeout of its own. Only `--max-scan-secs` bounds it + (set by default for a daemon job, off for a one-shot run). +- Up to 10 redirects are followed, and the destination is not filtered, so a + daemon with `--allow-http-scan` fetches any address it is given, including + ones only it can reach. +- The object is not pinned. exav does not check an `ETag` or `Last-Modified` + between ranges, so a server can answer differently on each pass. What was + scanned is then not what a later download receives. For an object from a + server you do not control, download it once and scan the file. + ## Archive members -Archives decoded as they are read (ZIP, tar, CAB, ISO/UDF, DMG, LHA, `ar`, cpio, -the single-stream compressors, self-extracting executables) are walked member by -member at any size and any depth. Such a member is held in memory up to the same -limit, and past it written to a temporary file and scanned from there. With -`--spill-dir off`, or past `--max-spill-bytes`, it is not scanned and the file is -`LIMITS-EXCEEDED`. 7z members stream the same way once the container is read. +Archives decoded as they are read (ZIP, tar, CAB, ISO/UDF, LHA, `ar`, cpio, 7z, +WIM, xar, the disk images and filesystems, the single-stream compressors, +self-extracting executables) are walked member by member at any size and any +depth. Such a member is held in memory up to the same limit, and past it written +to a temporary file and scanned from there. With `--spill-dir off`, or past +`--max-spill-bytes`, it is not scanned and the file is `LIMITS-EXCEEDED`. -A member that has to be decoded whole (an encrypted ZIP member, a file inside a -DMG, a member of a format read whole) is `LIMITS-EXCEEDED` past -`--max-object-bytes`. +A member that has to be decoded whole (an encrypted ZIP member, a 7z folder with +an encrypted coder, a file a filesystem stores compressed, a member of a format +read whole) is `LIMITS-EXCEEDED` past `--max-object-bytes`. ## Stdin, `INSTREAM` and ICAP bodies diff --git a/www/src/content/docs/scanner/getting-started/installation.mdx b/www/src/content/docs/scanner/getting-started/installation.mdx index 5a2c477d..9e1cf300 100644 --- a/www/src/content/docs/scanner/getting-started/installation.mdx +++ b/www/src/content/docs/scanner/getting-started/installation.mdx @@ -7,9 +7,8 @@ import { Steps } from '@astrojs/starlight/components'; exav runs on Linux, macOS and Windows and ships as a single binary. The Linux release builds are static (musl); the macOS and Windows builds link the system -libraries. On Windows there is no prefork worker pool, no per-job limits -(`--max-scan-secs`, `--max-process-bytes`) and no `--auto-update`, which are -Unix only. +libraries. The prefork worker pool, the per-job limits (`--max-scan-secs`, +`--max-process-bytes`) and `--auto-update` are Unix only. ## Prebuilt binaries @@ -21,25 +20,25 @@ module `exav-wasm--wasm32-wasip1.wasm` (see the checksum beside it. For example, on 64-bit Linux: ```sh -tag=v0.0.2 target=x86_64-unknown-linux-musl +tag=v0.0.3 target=x86_64-unknown-linux-musl curl -fsSLO https://github.com/sylvinus/exav/releases/download/$tag/exav-$tag-$target.tar.gz tar xzf exav-$tag-$target.tar.gz sudo install -m755 exav /usr/local/bin/exav exav --version ``` -They are the default build, without HTTP support: to fetch signatures with -`--auto-update` or scan URLs, build with `--features http` below. +The binaries are the default build, without HTTP support. To fetch signatures +with `--auto-update` or scan URLs, build with `--features http` (below). Every archive carries the `LICENSE` and the `NOTICE` of what the binary links. -The same releases carry the other tools in archives of their own, each -installed as its page says: [exav-unpack](/unpack/cli/), +The same releases carry the other tools in archives of their own, installed as +their pages say: [exav-unpack](/unpack/cli/), [exav-grep](/subprojects/exav-grep/), [exav-pe-emu](/subprojects/exav-pe-emu/#cli) and [exav-imagehash](/subprojects/exav-imagehash/). ## From crates.io -Building needs Rust 1.92 or newer (`rustc --version`; `rustup update` to +Building needs Rust 1.95 or newer (`rustc --version`; `rustup update` to upgrade). ```sh @@ -61,7 +60,7 @@ See [Feature flags](/scanner/reference/feature-flags/) for all build options. -1. Install a Rust toolchain (1.92 or newer) via [rustup](https://rustup.rs/). +1. Install a Rust toolchain (1.95 or newer) via [rustup](https://rustup.rs/). 2. Clone and build: @@ -87,7 +86,7 @@ scanning and signature auto-update): cargo build --release -p exav --features http ``` -For a smaller binary without YARA, DLP, the phishing heuristic or ICAP: +For a smaller binary with only the archive formats and decryption: ```sh cargo build --release -p exav --no-default-features --features all-formats,decrypt diff --git a/www/src/content/docs/scanner/getting-started/introduction.mdx b/www/src/content/docs/scanner/getting-started/introduction.mdx index ed92ff7c..ae0086c1 100644 --- a/www/src/content/docs/scanner/getting-started/introduction.mdx +++ b/www/src/content/docs/scanner/getting-started/introduction.mdx @@ -5,23 +5,20 @@ description: What exav is, a memory-safe malware scanner written in Rust that re import { Card, CardGrid } from '@astrojs/starlight/components'; -exav is a malware scanner written in memory-safe Rust. It ships as one -MIT-licensed binary with a scanning CLI, a resident daemon and an ICAP server, -and it drops into an existing ClamAV setup: it loads the signature databases you -already have and answers the protocols your tooling already speaks. - -It reads ClamAV databases (`.cvd`/`.cld`, `.ndb`/`.ldb`/`.hdb`/`.hsb`/`.mdb`/ -`.msb`/`.cdb`/`.imp`/`.cbc`) and YARA rules (`.yar`/`.yara`), and speaks two wire -protocols: `clamd`, so `clamdscan`, milters and existing tooling talk to it -unchanged, and [ICAP](/scanner/guides/icap/), so it can replace a `c-icap` container -behind a proxy or an upload service. +exav is a malware scanner written in Rust. It ships as one MIT-licensed binary +with a scanning CLI, a resident daemon and an ICAP server. It loads the +signature databases you already have (ClamAV's `.cvd`/`.cld`, `.ndb`/`.ldb`/ +`.hdb`/`.hsb`/`.mdb`/`.msb`/`.cdb`/`.imp`/`.cbc`, and YARA `.yar`/`.yara`) and +speaks two wire protocols: `clamd`, so `clamdscan`, milters and existing tooling +talk to it unchanged, and [ICAP](/scanner/guides/icap/), so it can replace a +`c-icap` container behind a proxy or an upload service. ## Why exav Established engines are large C codebases, and they bound their own work and then report a file clean without saying so. ClamAV, for instance, reads a file over -about 2 GB, scans none of it, and answers `OK`. exav is a memory-safe rewrite -built around one rule: +about 2 GB, scans none of it, and answers `OK`. exav is a rewrite in a +memory-safe language built around one rule: > Never report a file clean unless it was fully scanned. @@ -34,29 +31,21 @@ for how each verdict is reported. - The scanning and extraction code is safe Rust (`#![forbid(unsafe_code)]` in - the core crates). The default build links no C and generates no code at run - time, so the memory-corruption bugs behind most scanner CVEs cannot occur in - the code that parses hostile input. + The crates that parse hostile input are `#![forbid(unsafe_code)]`. The + default build links no C and generates no code at run time, so the + memory-corruption bugs behind most scanner CVEs cannot occur in exav's own + parsers. Dependencies are another matter: see + [Dependencies](/project/dependencies/). Most archives are walked member by member instead of being extracted whole, - and a stream waits in a temp file under + and a large member waits in a temp file under [budgets you set](/scanner/reference/cli/#buffering-a-stream-spill). A file over a bound is reported, not called clean. Near-full YARA support through a native tree-walking evaluator: no code - generation at scan time, a pure-Rust dependency tree, on by default. - - - Loads existing `.cvd`/`.ndb`/`.ldb`/`.hdb`/YARA signature databases and - answers the `clamd` and ICAP protocols, so `clamdscan`, milters, proxies and - existing tooling talk to exav unchanged. - - - Compile a large signature set once, then load it quickly and with far less - memory everywhere. + generation at scan time, and a pure-Rust dependency tree. The whole engine compiles to a WASI module, so **untrusted** signatures can diff --git a/www/src/content/docs/scanner/getting-started/quick-start.mdx b/www/src/content/docs/scanner/getting-started/quick-start.mdx index d999bd5d..96049384 100644 --- a/www/src/content/docs/scanner/getting-started/quick-start.mdx +++ b/www/src/content/docs/scanner/getting-started/quick-start.mdx @@ -49,11 +49,12 @@ signatures loaded it refuses to run. Fetching signatures is the first step below eicar.com: Eicar-Test-Signature FOUND ``` - A `SCAN SUMMARY` block follows the result lines. This first run parses the raw signatures, which takes a while and needs - memory to spare (see [what it costs](/scanner/guides/prebuilt-database/#what-it-costs)). - If the process dies with no message, it ran out of memory. exav always knows - EICAR, so the check that the ClamAV signatures loaded is `Known viruses` in - the summary: it should be in the millions. + A `SCAN SUMMARY` block follows the result lines. This first run parses the + raw signatures, which is slow and needs memory to spare (see + [what it costs](/scanner/guides/prebuilt-database/#what-it-costs)); a process + that dies with no message ran out of memory. exav always knows EICAR, so the + check that the ClamAV signatures loaded is `Known viruses` in the summary, + which should be in the millions. 4. **Scan a file.** The output follows `clamscan`'s format: @@ -89,8 +90,8 @@ pass. See [which code a run exits with](/scanner/reference/verdicts/#process-exi ## Faster startup: build a database once -Loading the raw signatures parses millions of them on every start. Do it once -and reuse the result: +Parsing the raw signatures takes a while on every start. Do it once and reuse +the result: ```sh exav --build-db exav.exavdb # run daily, e.g. in CI @@ -107,20 +108,18 @@ load cost. The daemon stays in the foreground, so run the client from a second terminal: ```sh -exav --listen /tmp/exav.sock -d exav.exavdb # load once, serve -exav --connect /tmp/exav.sock /data # client +exav --listen $XDG_RUNTIME_DIR/exav.sock -d exav.exavdb # load once, serve +exav --connect $XDG_RUNTIME_DIR/exav.sock /data # client ``` A daemon can also keep its signatures current itself, fetching from a mirror with `--auto-update` (a `--features http` build); see [Signatures](/scanner/guides/signatures/#built-in-updater---auto-update). -`--listen` accepts connections and `--connect` makes one. The protocol travels in -the address: `--listen icap://0.0.0.0:1344` serves ICAP, and naming both serves -both from one process. ClamAV's own `clamdscan` works too, given a `clamd.conf` -whose `LocalSocket` or `TCPSocket` points at the daemon. - -See the [daemon guide](/scanner/guides/daemon/). +The protocol travels in the address: `--listen icap://0.0.0.0:1344` serves ICAP, +and naming both protocols serves both from one process. ClamAV's own `clamdscan` +works too, given a `clamd.conf` whose `LocalSocket` or `TCPSocket` points at the +daemon. See the [daemon guide](/scanner/guides/daemon/). ## Next diff --git a/www/src/content/docs/scanner/guides/daemon.mdx b/www/src/content/docs/scanner/guides/daemon.mdx index 8a2278b4..12581c48 100644 --- a/www/src/content/docs/scanner/guides/daemon.mdx +++ b/www/src/content/docs/scanner/guides/daemon.mdx @@ -48,8 +48,9 @@ or a daemon running as another user. `-` (stdin) is always sent as contents. A directory names its whole tree in all three modes, as it does for `clamdscan`. The client walks it and sends one request per file, so `--exclude` / `--include` -apply to the tree and each request has one reply. The parts of a byte-split -archive (`x.001`, `x.002`, ...) are sent together so the daemon can rejoin them +apply to the tree and each request has one reply. The parts of a split archive +(`x.001`, `x.002`, ...; a RAR's `x.part1.rar` or `x.r00`; a ZIP's `x.z01` +and `x.zip`) are sent together so the daemon can rejoin them (see [split archives](#split-archives)). By path, a directory holding such parts goes as one `CONTSCAN`, and the client's filters and `--no-recursive` do not apply to it; with `contents` or `fd`, the parts of each directory go in one @@ -158,9 +159,15 @@ daemon could not hold in full has what it held scanned and is answered scanned. The reply carries `"truncated": true` when the request ended before its terminator, so some files never arrived. -RAR `.partN` and ZIP `.zNN` volumes are not rejoined this way: each carries its -own headers, so concatenating them produces garbage that still looks like an -archive. Each is scanned on its own. +Volumes that carry their own headers are rejoined too, by the format's own +join and not by concatenation (which would produce garbage that still looks like +an archive): a RAR set (`x.part1.rar`, `x.part2.rar`, ..., or `x.rar`, `x.r00`, +...), a ZIP set (`x.z01`, ..., and the `x.zip` that ends it) and a set of +cabinets, found from the names their headers give one another. A set with a +volume missing, or whose volumes do not fit together, is `PARTIAL`, never +clean. The client sends a directory as one request when it holds a byte-split, +RAR or ZIP part by name, with its `.zip` and `.cab` files; a directory of +cabinets alone is rejoined when the daemon scans it by path (`CONTSCAN`). ## Size over the wire @@ -295,19 +302,9 @@ deployment reloads on swap too, with no explicit `RELOAD`. ## As a managed service A systemd unit (`packaging/exav.service`, also shipped in the `.deb`) runs exav -in place of `clamav-daemon` on the same socket, reading the raw signatures -`freshclam` keeps in `/var/lib/clamav`. It runs as the `clamav` user, which the -package does not create, so install it where ClamAV is installed: - -```sh -sudo systemctl disable --now clamav-daemon.socket clamav-daemon.service -sudo systemctl enable --now exav -clamdscan --ping 1 -``` - -Stopping `clamav-daemon.socket` matters on Debian and Ubuntu: while it holds the -socket, the first connection starts clamd again. `systemctl reload exav` sends -`SIGUSR2`. - -See [Migrating from ClamAV](/scanner/guides/migrating-from-clamav/) for the full swap, -and the [Docker guide](/scanner/guides/docker/) for the containerised daemon. +in place of `clamav-daemon` on the same socket. It runs as the `clamav` user, +which the package does not create, so install it where ClamAV is installed; +`systemctl reload exav` sends `SIGUSR2`. See +[Migrating from ClamAV](/scanner/guides/migrating-from-clamav/#as-a-managed-service) +for the swap, and the [Docker guide](/scanner/guides/docker/) for the +containerised daemon. diff --git a/www/src/content/docs/scanner/guides/docker.mdx b/www/src/content/docs/scanner/guides/docker.mdx index 23e80041..c907e1a1 100644 --- a/www/src/content/docs/scanner/guides/docker.mdx +++ b/www/src/content/docs/scanner/guides/docker.mdx @@ -81,13 +81,18 @@ docker exec exav /exav --ping # what this container serves exav --ping --connect clamd://scanner.internal:3310 # from anywhere ``` +Under the worker pool the scan listener answers a `PING` when a worker is free, +which under heavy load can be after the check's timeout. To have the check +answered whatever the workers are doing, add a health listener +(`EXAV_LISTEN_HEALTH=clamd://127.0.0.1:3311`); `exav --ping` then probes it +instead, and the command of the check does not change. See +[the health listener](/scanner/reference/cli/#health-listener). + `HEALTHCHECK` is a Docker extension of the image configuration, not part of the -OCI spec. Docker honours it either way. Podman reads it only from an image -published with Docker mediatypes, which the publish workflow checks for images -published after 0.0.1; on older tags, or an image a registry re-exports as OCI, -`podman image inspect` shows no health check. Kubernetes ignores it entirely. -Where it matters, say it in the orchestrator's own configuration, with the same -command: +OCI spec. Docker honours it. Podman reads it only from an image published with +Docker mediatypes, so an image a registry re-exports as OCI shows no health +check in `podman image inspect`. Kubernetes ignores it. Where it matters, say it +in the orchestrator's own configuration, with the same command: ```yaml # Compose diff --git a/www/src/content/docs/scanner/guides/icap.mdx b/www/src/content/docs/scanner/guides/icap.mdx index 8553544c..fa230d30 100644 --- a/www/src/content/docs/scanner/guides/icap.mdx +++ b/www/src/content/docs/scanner/guides/icap.mdx @@ -302,10 +302,10 @@ hot-reload as in [daemon mode](/scanner/guides/daemon/#hot-reloading-signatures) ```http ICAP/1.0 200 OK -Server: exav/0.0.2 +Server: exav/0.0.3 ISTag: "exav-ae0b21d4572e711f" Methods: REQMOD, RESPMOD -Service: exav/0.0.2 ICAP service +Service: exav/0.0.3 ICAP service Allow: 204 Max-Connections: 100 Preview: 4096 diff --git a/www/src/content/docs/scanner/guides/prebuilt-database.mdx b/www/src/content/docs/scanner/guides/prebuilt-database.mdx index 2ee1ee46..aff8cfb3 100644 --- a/www/src/content/docs/scanner/guides/prebuilt-database.mdx +++ b/www/src/content/docs/scanner/guides/prebuilt-database.mdx @@ -56,9 +56,8 @@ See [Signatures](/scanner/guides/signatures/) for the former. -Build the database daily on a capable host or in CI, distribute the file, and -have every CLI and daemon instance load it. Constrained hosts never pay the build -peak. +Build daily on a capable host or in CI and have every CLI and daemon instance +load the file, so constrained hosts never pay the build peak. ## Rebuild after an exav upgrade @@ -70,12 +69,12 @@ half-understood: exav: loading database old.exavdb: unsupported database version 3 (this build expects 4) ``` -Rebuild after an upgrade whose changelog mentions a new format version. Nothing -is lost: the sources are the `.cvd`/`.cld` files `freshclam` or `cvdupdate` -already fetched. If you ship a prebuilt database to other hosts, upgrade the builder and -the scanners together: a download is checked for integrity, not for its format +Rebuild after an upgrade whose changelog mentions a new format version; the +sources are the `.cvd`/`.cld` files `freshclam` or `cvdupdate` already fetched. +If you ship a prebuilt database to other hosts, upgrade the builder and the +scanners together: a download is checked for integrity, not for its format version, so a scanner offered a newer format keeps the database it has on a -reload, and fails to start from scratch. +reload and fails to start from scratch.