diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c919b1d..89261e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,24 @@ 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-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: @@ -194,7 +205,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/www.yml b/.github/workflows/www.yml index bdf952f..68659e8 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 420c585..9795919 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 3df5588..16744a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,484 @@ follow [semantic versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Pictures of pages from the formats that draw something.** + `controllers.pictures` gives `raster(page, options)`, `toBlob(page, options)` + and `render(page, longEdge)`. A page is drawn again from the document's own + data at the size asked for, with a `region` in the format's coordinates (a + fraction of a page, pixels of an image, drawing units with `layout`, a 3D + camera) or `"current"`, `fit` ("contain" or "cover"), `lineweight`, + `background` and an `AbortSignal`. The result reports the region actually + drawn. `controllers.viewport` is what the session shows now, in the same + coordinates. Errors are a `RasterError` (`unsupported`, `too-large`, + `aborted`, `invalid`, `failed`). Pictures are limited to 16,384 px a side and + 64 Mi pixels in all, in the page and in the sandboxed frame, and are encoded + by the host page. The demo has an "Export image" button beside the file's + name, which opens a dialog of options. +- **A full screen button** in the viewer's tools (`FullscreenToggle`, a piece a + host can replace) puts the viewer's box in full screen, from the host page, + and leaves it. It is not shown where the browser has no full screen. Hosts + drawing their own controls get `useFullscreen(() => element)` and + `usePanel(info?)` (the side panel's fold state, shared by the rails and the + toggle) from `@exav/viewer/react`. +- **`session.replace` works in the sandboxed frame**, so zoom and place are + kept when a host swaps a document, for PDF and images. +- **A format registry** (`formats/registry.json`, 117 formats) from which the + format matrix page and the viewer summary are generated, and which + `make test-formats` and the viewer's and the scanner's tests check against + the extractor's formats, the scanner's file types and the viewer's matchers. +- **Virtual disks read in place.** VHD (fixed and dynamic), VHDX, QCOW2 + (stored, deflate and zstd clusters) and VMDK (sparse and streamOptimized) are + read through a `Read + Seek` that reads the container's tables as the walk + reaches them and keeps only a 32 MiB cache of decoded units, so a disk of any + declared size costs what it holds. A disk over the buffer limit is walked + through its partitions and its NTFS and FAT files come out one at a time; a + smaller one is still one member. What a disk decodes is counted against the + scan budget. NTFS and FAT are read in place too, and a raw NTFS or FAT image + or partition over the limit is walked instead of refused or streamed whole. + ext is still copied into memory, as its volume only. +- **3D model pictures and viewport.** IFC, STL, OBJ, PLY and glTF expose + `controllers.pictures` (one page) and `controllers.viewport`. A picture is + redrawn from the geometry by an off-screen renderer at the requested size, in + tiles beyond GPU limits, with MSAA or supersampling and a transparent or any + CSS colour background. Regions are cameras in the file's own coordinates. + `region: "current"` is the live camera, `fit` contain or cover applies, and + hidden categories stay hidden. The `rasterTile` and `rasterSupersample` + plugin options are for testing and tuning. +- **DWG and DXF pictures and viewport.** `pictures.raster` draws any layout + again from the drawing's vectors at the asked pixel size, in a WebGL context + of its own (tiled and composed past the GPU's limits). It takes a region in + drawing units (y up, model units or a sheet's paper units), a size, `fit`, + `lineweight` "screen" or "scaled", any CSS colour or a transparent ground, + and a `layout` id, and reports the region actually drawn. The `viewport` + store gives the layout and region in view, and `region: "current"` draws it. + One picture is drawn at a time per session, abortable. The `rasterTile` + option tunes the tile size. +- **Pictures and viewport for Word, PowerPoint, Excel and CSV.** + `controllers.pictures` draws a page, slide or sheet at any size and region + (fractions of the page; a sheet's page is its used area at 100% without + headers), and `controllers.viewport` reports the page most in view and the + visible part. The converted formats (DOC, ODT, RTF, XLS, ODS, Numbers, PPT, + ODP, Keynote, ODG and the external converter's PDF or XLSX) get them too. + Pictures are drawn from a second copy of the document and queued one at a + time. A region whose whole page would pass the engines' 16.7 M pixel limit is + refused as too large, and `background: "transparent"` is refused. +- **Pictures of a video's current frame.** `controllers.pictures` on video + (page 1, space "frame"). A video the page cannot read (another origin without + CORS, or a URL given to the sandboxed frame) is refused as unsupported + instead of drawn blank. +- **IFC curved faces, trimming, alignments and storeys.** B-rep faces on + spheres, tori and cylinders (sphere caps and seamless tubes included) are + meshed in their surface's parameters instead of flat. B-spline faces are cut + by their bounds, holes included. `StartParam`/`EndParam` of swept disks and + directrix sweeps are honoured. IFC4X3 curve segments (lines, circles, + clothoid and polynomial spirals, polynomial curves), gradient curves and + linear placements without a Cartesian position are evaluated, and + `IfcGridPlacement` sits at its grid intersection. exav-render reports + `Element::storey`, `Batch::storey` and `Node::elevation`, and `@exav/viewer` + has a `storeys` controller with a show/hide list. Conical faces + (`IfcSurfaceOfRevolution`) are still drawn flat. +- **Camera RAW files open in the viewer and in exav-render**, shown by the + largest JPEG or RGB preview they embed, turned by their Exif orientation + (`exav_render::image::raw`, feature `raw`, default; `decode_any` reads a RAW + file by its preview, `Format::detect` and `decode` still read a TIFF-structured + one as the TIFF it also is, so exav-imagehash is unchanged). DNG, Canon CR2, + CR3 and CRW, Nikon NEF and NRW, Sony ARW, SR2 and SRF, Olympus ORF, Panasonic + RW2, RAW and Leica RWL, Fujifilm RAF, Pentax PEF, Samsung SRW, Hasselblad 3FR, + Phase One IIQ, Sigma X3F, Kodak, Epson and Leaf files are told by their bytes, + never their names, and a plain TIFF stays a TIFF. The sensor data is not + decoded: a file that embeds no preview (most phones' DNG, Minolta MRW, the + newest CR3 whose preview is HEIF) is `Error::NoPreview`, and the viewer says + so (`image_no_preview`). Every offset and count in the file is bounds-checked, + directory chains and loops are capped, and previews count against the decode + budget as the RGBA a page holds. New `raw_preview` fuzz target. +- **The visible text of legacy and flat office files.** `text-office` + (`exav-render`, `exav-grep`) and `visible-text-office` (`exav-core`, `exav`), + opt-in, put the text of DOC, XLS, PPT, RTF, Word 2003 XML, SpreadsheetML 2003, + SYLK, FODT, FODS, FODP, FODG, Numbers, Pages and Keynote in the text view. A + match is located by sheet or slide, a link is `label (target)` and + `--detect phishing` judges it; an encrypted, over-limit or damaged file makes + the scan PARTIAL, never clean. Compound files are read as office documents by + their extension only. +- **Demo examples.** 243 freely licensed example files (145 committed, 98 + fetched by `npm run demo:examples` and checked against their SHA-256), with a + manifest, an attributions page and a NOTICE. +- **Office documents, spreadsheets, presentations and drawings converted in + safe Rust, inside exav-render.** The features `office-sheets`, + `office-slides`, `office-docs` and `office` (`exav_render::office`; none is + a default feature) turn XLS (BIFF2 to BIFF8), ODS, FODS, OTS, Numbers ('08 and + later), Excel 2003 XML, HTML tables and SYLK into XLSX; PowerPoint 95 to 2003 + (with .pps and .pot), ODP, OTP, FODP and Keynote 1 and later into PPTX; + DOC and DOT (Word 6 to 2003), ODT, OTT, FODT, RTF, Word 2003 XML and Apple + Pages (5 and later) into DOCX; and ODG, OTG, FODG and Visio 2013+ (VSDX, VSDM, + VSTX, VSTM) drawings into PPTX, a page to a slide. @exav/viewer shows the + result with `xlsx()`, `pptx()` and `docx()`, in a worker per file, from the + `sheets`, `slides` and `docs` wasm modules (450, 434 and 359 KB brotli). The + files are read as they are + stored, nothing recalculated; tables, lists, sections, notes, fields, + pictures, charts and shapes are kept; tracked changes in DOC are drawn as + strike-through and underline. Input, cells, paragraphs, memory and + decompressed parts are bounded (`Limits`); an encrypted file, one over its + limits or a damaged one is reported with its reason. Fuzz targets + `sheets_xls`, `sheets_ods`, `sheets_numbers`, `sheets_text`, `slides_ppt`, + `slides_odp`, `slides_keynote`, `slides_odg`, `slides_vsdx`, `docs_doc`, + `docs_odt`, `docs_rtf`, `docs_wml` and `docs_pages`. +- **Older presentations.** PowerPoint 95 and Keynote 1 to 5 (XML) are read. + PowerPoint 2 to 4 (Windows, Macintosh, BinHex) are recognised and reported as + unsupported. A presentation the converter recognises and cannot convert ends + in an error with its kind. Also added: picture bullets (ODP, PPT), + charts stored in PPT files as ODF packages, Keynote tables, charts and movies, + inline pictures and charts in flat ODP, tile sizes from the picture's + resolution, text areas of custom shapes, per-path fill and stroke, even-odd + fills and the default style of unstyled shapes. +- **Large decks and long documents are laid out progressively.** The viewer + asks @silurus/ooxml for `progressiveLayout` past 32 slides or 64 KB of + inflated `document.xml`; on a 300-page document the first page shows in about + 2 s instead of about 19 s. +- **`maxArchiveEntries` for Word, Excel, PowerPoint and CSV** (`docx()`, + `xlsx()`, `pptx()`, `csv()`): the most entries a document's ZIP may list. + The default is now 20000; @silurus/ooxml's own 4096 refused a deck of about a + thousand slides with pictures. The size a document may inflate to keeps its + limit. +- **Volume sets are scanned as the archive they are.** A RAR set + (`x.part1.rar`, `x.r00`), a ZIP set (`x.z01`, ..., `x.zip`) and a CAB set (the + cabinets name one another) are rejoined and scanned whole, when their volumes + sit in one directory (`exav DIR`), when one volume is named and its siblings + are next to it (regular files only, never a symbolic link), and when they are + sent together to the daemon. A set with a volume missing is `PARTIAL`, never + clean. A cabinet read alone now reports the member that continues in another + one instead of leaving it out. +- **ELF and Mach-O `EP` and section offsets** (`EP`, `Sn`, `SL`, `SEn`) match + as they do in clamscan, read from the headers. Each was measured against + clamscan 1.4.3. `EP` on a Mach-O is not resolved there either. +- **The viewer opens more archives:** cab, chm, iso, lzh, arj, ar and deb, + cpio, xar and pkg, dmg, wim, zst, lz4, lz and Z files, tarballs of each + compression, zoo, arc, alz, egg, jar and the other packages that are zips + (apk, ipa, xpi, whl, nupkg, vsix, msix), comic books (cbz, cbr, cb7, cbt), + virtual disks (vhd, vhdx, vmdk, qcow2) and `winmail.dat`, by name, by the + type a desktop gives the file, or by the first bytes for a member of an + archive. A `.tgz` typed `application/x-compressed-tar`, as a Linux desktop + types it, is claimed now. +- **Text previews in the viewer** (`text()`, `@exav/viewer/text`): plain text, + logs, Markdown, JSON, XML, YAML, TOML and INI, source code, subtitles and + diffs, and HTML as source. Nothing in the file is parsed as markup; Markdown + is read in WebAssembly by pulldown-cmark (CommonMark, tables, strikethrough, + entities and reference links) through exav-render's new `markdown` feature + (`forbid(unsafe_code)`, linear on every hostile input tried, a `markdown` + fuzz target), and drawn element by element. A Markdown link shows its address + and opens it on a click, as a PDF's links do (the host asks first in the + frame), images are never loaded, bidirectional and control characters are made + visible, a file is cut at 2 MiB and 50,000 lines with a warning, and a binary + file is refused. The new `exav_viewer_text.wasm` module is about 190 KB (82 KB + gzipped), loaded when a Markdown file is opened; if it fails to load, the + text is shown as it is with a warning. +- **A visible-text layer, in the scanner and in `exav-grep`.** A document is + matched through the text a reader sees as well as its bytes: its words without + the marks, tags, escapes or run boundaries, entities decoded, and each link + followed by where it goes (`label (target)`, even for a `javascript:` target + or one with credentials), as a text view that `Target:0` and `Target:7` + signatures see. That lets a signature match a phrase Word split across runs, + a PDF kerned into pieces, or a link whose label names one site over a target + on another. Read: Markdown (pulldown-cmark), HTML (html5gum's tokenizer, no + tree), the XML parts of Word, PowerPoint, Excel and OpenDocument files by + their path (quick-xml), AutoCAD drawings (the text, attributes, dimension and + multileader texts, block and layer names, from exav-render's drawing model), as + the input or as an archive member; and PDF (hayro) with the `visible-text-pdf` + feature, which is not a default (see below). With `--alert-phishing`, a link + whose label names one site and whose target another is now judged in these + documents as an HTML anchor is, which also catches an HTML link whose target is + written in entities. It is the new `visible-text` feature of `exav-core` and + `exav` (default; off under `--clamav-compat`); an object past + `--max-object-bytes` is not read and the scan is reported incomplete. + `exav-grep --text` searches the same text, reporting `file.md!text` with the + text's line numbers. exav-unpack has the `text` and `text-pdf` features and + `exav_unpack::text`, exav-render `text` and `text-pdf` and + `exav_render::text::visible_text`, and there is a `visible_text` fuzz target. + RTF and email headers have none yet (see `docs/BACKLOG.md`). +- **PDF text is opt-in (`visible-text-pdf`, `exav-grep`'s `text-pdf`).** + hayro-interpret turns on `hayro-syntax`'s `unsafe` feature itself, which + switches every `flate2` in the build to zlib-rs (a damaged stream then gives + back other bytes: two of exav-unpack's tests fail with it) and adds SIMD to + the image decoders. hayro also puts no bound on what a stream decompresses to + (a 1 MB PDF took 2.1 GB), so exav gives it a PDF only after its own reader has + taken every stream within the limits. Built without hayro's predefined CJK + CMaps, which would bring `brotli`. +- **More 3D models in the viewer and exav-render:** Wavefront OBJ, Stanford + PLY (ASCII and binary) and glTF 2.0 (`.gltf` with embedded buffers, `.glb`), + read in safe Rust with bounded triangulation, JSON, node trees and + accessors. Nothing a model names is fetched; a glTF that needs an external + file is reported, not shown. There is a `mesh` fuzz target for them. +- **JPEG XL in the viewer** (`.jxl`, first frame, 8-bit), decoded in + WebAssembly by zenjxl-decoder, a fork of the libjxl team's jxl-rs that is + `forbid(unsafe_code)`, within the viewer's decode budget. The image module + grows from 1.1 to 1.65 MB. exav-render has it as the `jxl` feature, off by + default. +- **A host can draw the whole viewer UI itself, the files still sandboxed.** + The frame draws the document and nothing else; new controllers give the + rest to the page: `media` (play, pause, seek, volume, rate; `video({ + controls: "none" })` drops the browser's own player controls), `sheets` (an + Excel workbook's sheets, whose tabs the UI now draws: the engine's are gone), + `pictures` (a page of a PDF or an image drawn whole as an `ImageBitmap`, for + thumbnails and printing, at most 4096 px a side), and zoom for Word, Excel + and CSV, page jumps for Word and slides. `printPictures` prints from those + pictures, which the frame cannot and which a TIFF or a PDF cannot from its + URL. The arrows and Escape pressed in the frame, when the document does not + use them, reach the page's listeners (navigation keys only, at most 30 a + second; `keys`). The frame takes the page's `--exav-viewer-*` colours and colour + scheme, read where it is mounted (`theme`, `restyle()`), so a text file or a + Markdown page in it follows a dark theme. In the frame, the iframe fills its + element without the stylesheet. `demo/headless.html` is such a host, in + plain DOM, and has the browser tests of all this. +- **SVG in the viewer**: drawn as an ``, which runs none of its script and fetches nothing it names, + and drawn again sharp when zoomed. **FLAC** is played with the rest. +- **The viewer speaks German, Spanish and Dutch**, and its strings are exported + by `@exav/viewer` for hosts without React. +- **The viewer says what a load is doing, and how far it is.** A loading + status carries a `step` (the file's bytes, the engine's download, its start, + reading, converting, drawing) and the share of that step done, where it is + counted; the default UI words each step and draws a bar, and the sandboxed + frame passes both to the host. +- **DWG and DXF drawings closer to AutoCAD's.** A polyline with width is a + filled band (its outline under `$FILLMODE` 0). Lineweights are millimetres on + a layout's sheet, shown when the drawing's `$LWDISPLAY` says so (the cad + `lineweights` option: `"drawing"`, `"always"`, `"never"`). A text crossing a + viewport's edge is cut at it rather than kept or dropped whole. A block + clipped with XCLIP shows only what is inside its boundary, with the frame + `$XCLIPFRAME` asks for. Transparency holds for an entity in its layer's or + block's colour; translucent geometry blends over everything behind it in + draw order, text included, and is no darker where a polyline's segments + meet. What is not drawn is said: entities by type (a warning's new + `names`), xrefs counted with the other external references and their path + drawn where they are inserted, the files they name listed; a drawing with + nothing drawn here (a 3D model) keeps the thumbnail it was saved with. + Text follows its block mirrored or stretched, and TEXT's backward and + upside-down flags; Bottom and Middle alignment sit on the descender as in + AutoCAD; Aligned keeps its width factor. MTEXT takes its style's width + factor and slant, and draws `\W`, `\Q`, `\T`, paragraph justification, + stacked fractions, background masks and frames; multiline attributes and a + multileader's text are drawn as MTEXT. `%%nnn` and `\X` are read; symbol.ttf, + the SHX Greek fonts and gdt.shx show the characters they stand for; text the + stroke font lacks (Chinese in an SHX style) is drawn in an outline face, + completed from the browser's fonts; an invisible constant attribute is not + drawn; Greek and Cyrillic wrap at their own widths. A drawing with more + characters than the glyph atlas affords at a close zoom draws them as + distance fields, sharp at any zoom. A hatch pattern a unit or less apart + fades as it crowds below a pixel instead of turning solid; one too dense to + draw at all fills its region in a half tone instead of leaving it empty. + 3DFACE hides the edges its flags hide; points take `$PDMODE` and + `$PDSIZE`; polyface and polygon meshes and MESH are drawn as their edges; + TOLERANCE draws its feature control frame; a leader's arrowhead is its + style's (`DIMLDRBLK`), and a multileader draws its leader to the landing + with its arrowhead. Circles, arcs and a polyline's bulges are true arcs + however close the view, no longer polygons. A dashed polyline restarts its + pattern at each vertex unless `PLINEGEN` runs it on. AutoCAD's complex + linetypes (TRACKS, ZIGZAG, FENCELINE1 and 2, BATTING) draw their shapes, + and a linetype's text sits in its gap rather than a gap's length early, + over the dash before it. A dimension saved without the block AutoCAD draws + it from, as some generators write them, is drawn from its definition + points and its style: linear, aligned, radius, diameter, angular and + ordinate, its measurement in the style's decimal places, separator and + zero suppression. Yellow, cyan and the + lightest colours are darkened on a light ground, the darkest blues lightened + on a dark one, so a line or a text stays legible (fills keep their colours); + the cad `monochrome` option draws everything in ink, as monochrome.ctb + plots. A layout is drawn on its sheet of paper, with the ground around it. + A line's anti-aliased edge blends over what lies + behind it, without the halo of paper it used to leave where it crossed + another line, and a solid fill's edge is anti-aliased too. A stray entity far from the drawing (a line left at the + origin of a plan in national grid coordinates) no longer shrinks it to a + speck nor costs it its precision; the zoom stops where precision would run + out, and the zoom controller says how far it goes. Tessellating takes a + third less memory at its peak, the page no longer keeps a copy of the + geometry once it is on the GPU, and a lost GL context comes back with the + drawing on it. + +### Changed + +- **Dependencies are at their latest versions**, except the image decoders + pinned to the versions libclamav 1.5.4 links (`image`, `png`, `gif`, + `zune-jpeg`, `fax`, `weezl`), which `exav-imagehash` needs for ClamAV's hash. + Notable: `quick-xml` 0.42, `hfsplus` and `apfs` 0.4, `cfb` 0.15, + `lzma-rust2` 0.23, `sevenz-rust2` 0.24, `delharc` 0.8 (which also reads + PMarc LHA archives), `base64` 0.23, `bit-set` 0.11, `yara-x-parser` 1.19; + and for the viewer and docs, Vite 8, vitest 5, jsdom 30 and Playwright 1.64. + This clears three RustSec advisories (`ctutils`, `rustls-webpki`, + `webpki-roots`). +- **The minimum Rust version is 1.95** (it was 1.92), which `delharc` 0.8 + requires. It also leaves a single `lzma-rust2` in the tree. +- **QCOW2** images with an external data file, extended L2 entries or other + unknown incompatible features are reported instead of misread, and **VMDK** + grain table entries are read with the header's `numGTEsPerGT`. +- **Salvage keeps more of a damaged cabinet and archive.** A CAB MSZIP block cut + or damaged part way keeps what it decoded; a cut at the end of the file is + not reported, damage is. A ZIP member of a codec the `zip` crate lacks that + fails part way keeps the bytes before the failure and is reported. An + InstallShield Z member that stops short or fails part way keeps its decoded + start and is reported, where it used to come out as whole or be dropped. +- The sandboxed frame's protocol is version 5 (it was 4): `draw` and `drawn` + became `raster` and `rastered`, `replace` and `replaced` are new, and each + `read` names its document. `MAX_PICTURE_EDGE` is 16,384 (was 4,096) with a + new `MAX_PICTURE_PIXELS`, and the `pictures` store holds `{ count, space }`. + The page and the frame must come from the same release. +- **`controllers.viewport` is worked out only while it is used.** A session that + never reads it no longer measures the layout of Word, PowerPoint and PDF + pages, and in the sandboxed frame no longer opens a second copy of every + spreadsheet's workbook, which the frame's own reading of it did. The viewport is kept up to date while the store has a + listener, and a `get()` without one works it out once (a sheet's is measured + asynchronously, so its `get()` has the last measure and starts the next). In + the sandboxed frame, the host sends `watch` when the first listener arrives + and `unwatch` when the last leaves; the frame reports the viewport only + between them, and a `get()` without a listener returns the last value the + frame reported and asks for a fresh one. The demo's "Export image" follows it + only while "What I see" is chosen. +- The viewer's description names OBJ, PLY and glTF wherever it named IFC and + STL. +- A batch of an IFC scene now holds one storey's elements, so a scene can hold + more batches. A face whose edge loop cannot be read counts in + `warnings.invalid`, and surface evaluation and edge sampling have their own + per-file budget. +- **External converter for the formats the package does not read.** A binary + Visio, CorelDRAW, Publisher, WordPerfect or other Document Liberation file + goes to an external converter: an ES module exporting + `convert(bytes, { hint, maxMemory, signal, onProgress })` that resolves to + `{ format: "pdf" | "xlsx", bytes }`, set with the `externalConverter` option, + `exavViewer({ externalConverter })` in `@exav/viewer/vite` or + `exav-viewer-assets --external-converter`; the `converter` option is `"exav"` + or `"external"`. Without one, such a file ends in the new error + `needs_converter`. +- `@silurus/ooxml` 0.89.1. +- The PPTX writer names slides (``) and writes another Application + string in `app.xml`. + +- **The sandboxed frame may autoplay** (`allow="autoplay"`, `autoplay=*` in its + `Permissions-Policy`): a player started by the page's controls would + otherwise refuse to play. Update a frame served with its own headers. The + frame's protocol is version 4: the frame and the page must come from the + same release. +- Escape handed back by the frame closes `ViewerDialog`. +- **The viewer's CSS prefix is `exav-viewer-`**, in place of `exv-`: its custom + properties (`--exav-viewer-accent`...) and its class names. Rename them in a + theme or a stylesheet written for 0.0.2. + +- A JBIG2 stream in a PDF has the same bound on the pixels of its regions as a + JBIG2 file (512 MiB), whatever memory the caller allows. +- The `extreme` sweep reports a decoder that does not end (10 s) as well as one + that panics. +- **Fewer dependencies, less `unsafe`.** DMG's LZFSE and LZVN runs are decoded + by a port of Apple's reference decoder in exav-unpack, checked against it on + damaged streams too (differential tests, run on request), in place of + `lzfse_rust`. InstallShield Z archives are + read in-tree, in place of `unshield` and `explode` (and `arraydeque`). The + vendored TIFF decoder holds 16-bit float samples as their bits, which drops + `half` and `zerocopy`; zstd is built without its checksum (`twox-hash`), which + exav never checked; and `goblin` without the parsers exav does not use. A + default build has 8 crates fewer, and 1,155 fewer `unsafe` uses in the code it + links (plus 807 in `zerocopy`'s derive macro). + +### Fixed + +- **A malformed `raster` message to the sandboxed frame is answered.** The frame + dropped one whose options failed the checks, so the host's promise stayed + pending until aborted; the frame now answers `rastered` with the error + `invalid`, which rejects with a `RasterError`. +- **QCOW2:** an L2 entry with flags and no offset read the file's own header as + disk data. **VMDK:** a grain table entry of 1, a zeroed grain, read sector 1 + of the file. +- **LZFSE, LZVN and DCL on damaged streams.** An LZVN payload whose end opcode + was read but whose declared size exceeded the input was reported as cut + instead of damaged. An LZVN instruction past the block's declared size was + called damage before the output limit was applied, dropping the part that + fits. A DCL stream with a bad first header byte and no second was reported + cut. Found by comparing the decoders with Apple's lzfse and zlib's blast on + damaged streams. +- IFC: a cylindrical B-rep face bounded by two circles was drawn flat, a + B-spline face was drawn over its whole surface, and a grid-placed element sat + at its parent's origin. +- **PDF strings:** `\b` and `\f` were read as the letters, which gave a wrong + owner key, so an encrypted PDF that opens with an empty password was reported + password-protected. A short octal escape such as `\0` also swallowed the byte + after it, closing parenthesis included. Found by a differential run against + ClamAV (`docs/DIFFTEST_TRIAGE.md`). +- **An object is scanned once per scan when its bytes were already scanned + clean** in the same place: same container type, same types above it, same + name extension, at the same depth or shallower. A document that holds copies + of itself used to be scanned once per copy (a 5 MB file that unpacks to 660 + MB, 152 members of which 50 differ, took 30 s and now takes 3 s). Only a + member of 64 KiB to 512 MiB that came out clean and complete is remembered, + by a SHA-256 of its bytes and place; a copy that is not clean, or that sits + deeper, is scanned as before. +- **Carving** counts a ZIP stapled into an object once. Each local header after + the first used to be an archive candidate of its own: a zip of a few dozen + entries filled the cap of 32 and the object was reported over a limit, and the + rest of the object was rescanned once per entry. A header at the offset the + previous one says the next entry begins (no data descriptor, encryption or + ZIP64 size) is no longer a candidate; the first one's walk reads it. + +- **A zstd stream of a few kilobytes can no longer take gigabytes of memory** + (RUSTSEC-2026-0344): ruzstd 0.9.1 bounds what one block decodes to. Also + rustls 0.23.46 (RUSTSEC-2026-0346 to 0349), in the `http` builds. +- **Two InstallShield Z members with the same path are both scanned.** The + reader kept only one of them. +- **Each dynamic block in an AutoCAD 2007 or later DWG is drawn as itself.** + AutoCAD's files can name several anonymous blocks plain `*U`, and every + insert of one drew the first; they are numbered as AutoCAD numbers them. + +Found by an 8-hour fuzzing run: + +- **An InstallShield Z archive can no longer ask for billions of members.** A + few hundred bytes of directory records made the reader build an entry for each + declared member before anything was checked (1.5 GB). A table declaring more + than 20,000 is reported as unexamined. +- **A YARA rule with an error deep inside parentheses is refused at once.** The + parser took about 2^d steps for an error inside d open parentheses (5.6 s for + 3.5 KB of rule text). Nesting past 12 is a compile error. +- **RAR key derivation is bounded for the whole scan.** The bound was per + archive, so archives inside archives each paid it, and one header asking for + 2^24 PBKDF2 iterations cost 2.6 s. It is now 2^22 iterations (about 0.7 s) for + RAR5 and 256 derivations for RAR3, counted across the scan. An archive asking + for a larger count is reported as encrypted, like one no password opens. + +Found by fuzzing the virtual disk walker and the Office readers: + +- **A FAT directory or file at cluster 1 no longer ends the walk of the whole + volume.** The `fatfs` crate subtracts 2 from a cluster number it takes from + the volume without looking at it, so a directory entry or a chain naming + cluster 1 made it panic, and the panic was caught at the walk's boundary as + an undecodable volume, losing every other file. It now ends only that + directory or file, which is reported. +- **A PowerPoint 95 bullet size of 4,294,968 percent or more no longer panics.** + The size is dropped when it is not from 25 to 400, but the product was taken + first, and overflowed: a panic in a build that checks overflow. +- **Pinch and ctrl-wheel zoom on a DWG or DXF drawing no longer lags.** A frame + was drawn for every zoom event, and on a drawing that takes the GPU longer + than a frame they queued, so the view followed the fingers seconds late. + While the zoom goes on the canvas is now scaled where it is (a CSS + transform, at most once per animation frame) and the drawing is drawn sharp + when the zoom pauses. The zoom and viewport stores are published once per + frame instead of once per event, which is one message less per event to a + host of the sandboxed frame. The glyph page the closer view needs, made of + distance fields when the text is too large for the atlas budget, was made in + one piece, which held the page's thread for hundreds of milliseconds, when + the zoom stopped; it is now made in slices of 8 ms while the old page stays up. +- **A trackpad pinch on a DWG or DXF drawing zooms in Safari.** Safari reports + the pinch as gesture events, which the drawing only cancelled; they now zoom + by their scale about the fingers, as on an image. The ctrl+wheel events Safari + may send for the same pinch are ignored while the gesture goes on, so the + zoom is not applied twice. +- **A PDF zooms out past the page fitted to the width**, down to a quarter of + it (`pdf({ minZoom })`, default 0.25 instead of 1), so a whole page is in + view on any screen. The button back to the width shows once zoomed either way. +- **The side panel's toggle is among the tools**, bottom right beside the zoom + buttons (a `PanelToggle` piece), instead of in the panel's header and a + square at the top right when it is folded. It stays on a touch screen: what + is for a mouse (the zoom and drag groups, full screen) has the attribute + `data-pointer-only`, which a host's own piece can carry too. +- **The fit button of the zoom controls is shown only once the view is not the + fitted one**, and has a "back to the start" icon instead of the full screen + one. A Word document's zoom controller now reports its scale relative to the + page fitted to the width, as the others do relative to their fit. + ## [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 8737f16..6c41e58 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" @@ -89,19 +98,48 @@ dependencies = [ [[package]] name = "apfs" -version = "0.2.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c1b4ecd96379464ea8b4ef2517c8eb674d8658ad364e9176bf5b8c278aa801e" +checksum = "6f959493f524db6102b1bd1884b58e3d632af70009441645287a7ee072944494" dependencies = [ "byteorder", + "cmpfs", "thiserror", ] [[package]] -name = "arraydeque" -version = "0.4.5" +name = "archmage" +version = "0.9.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0ffd3d69bd89910509a5d31d1f1353f38ccffdd116dd0099bbd6627f7bd8ad8" +checksum = "15785ee3576fbd41276a25f063b1e6b5bf323af9b4924632e25a8de6a3065f06" +dependencies = [ + "archmage-macros", + "safe_unaligned_simd", + "winarm-cpufeatures", +] + +[[package]] +name = "archmage-macros" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 +148,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" @@ -127,26 +165,24 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "beef" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" - [[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" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] [[package]] name = "bitflags" @@ -187,6 +223,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 +269,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 +334,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 +344,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 +417,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -358,6 +432,18 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cmpfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9532b7b405749f94737f0c75eff95f1cdd3ab030754f06998f0206aab060a0d5" +dependencies = [ + "flate2", + "lzfse_rust", + "lzvn-core", + "thiserror", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -378,9 +464,12 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] [[package]] name = "core-foundation-sys" @@ -432,19 +521,13 @@ 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 +539,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 +554,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", + "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 +593,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,11 +611,46 @@ 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" @@ -550,12 +669,13 @@ dependencies = [ name = "exav-core" version = "0.0.2" dependencies = [ - "base64 0.22.1", + "base64", "bit-set", "cab", "cfb", "crc32fast", "exav-imagehash", + "exav-render", "exav-unpack", "exav-x86", "flate2", @@ -589,6 +709,7 @@ name = "exav-grep" version = "0.0.2" dependencies = [ "clap", + "exav-render", "exav-unpack", "regex", "walkdir", @@ -617,20 +738,27 @@ name = "exav-render" version = "0.0.2" dependencies = [ "bytemuck", + "cfb", + "encoding_rs", "exav-unpack", "fax", "flate2", "gif", - "half", "hayro-ccitt", "hayro-jbig2", "hayro-jpeg2000", "image", + "md-5", "png", + "pulldown-cmark", "quick-error", + "quick-xml", "serde_json", + "sha1", "sha2", "weezl", + "zenjxl-decoder", + "zip", "zune-core 0.4.12", "zune-core 0.5.0", "zune-jpeg 0.4.21", @@ -643,7 +771,7 @@ version = "0.0.2" dependencies = [ "aes", "apfs", - "base64 0.22.1", + "base64", "bitstream-io", "byteorder", "bzip2", @@ -659,14 +787,17 @@ dependencies = [ "digest", "encoding_rs", "exav-pe-emu", - "explode", "ext4-view", "fatfs", "flate2", + "hayro-interpret", + "hayro-syntax", "hfsplus", "hmac", + "html5gum", + "kurbo", "lzfse_rust", - "lzma-rust2 0.21.0", + "lzma-rust2", "lznt1", "lzxd", "mail-parser", @@ -684,7 +815,6 @@ dependencies = [ "tar", "thiserror", "tinyvec", - "unshield", "xz4rust", "zip", ] @@ -705,15 +835,6 @@ 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" @@ -724,6 +845,12 @@ dependencies = [ "crc", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fatfs" version = "0.3.6" @@ -764,6 +891,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 +909,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 +930,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 +1007,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 +1037,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 2.13.2", + "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,6 +1080,31 @@ name = "hayro-jpeg2000" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-syntax" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "zune-jpeg 0.5.8", +] [[package]] name = "heck" @@ -929,11 +1114,12 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hfsplus" -version = "0.2.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1003d8eed80d61e8e8ea6a958bbe1afd3aef060882b9d6134e50500dd307c0dc" +checksum = "c8dd01379b7ef63f131bcd053ee6acd0fa940cdadec0caae4e3c8d502447fd3d" dependencies = [ "byteorder", + "cmpfs", "thiserror", ] @@ -946,6 +1132,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 +1207,7 @@ dependencies = [ "color_quant", "gif", "image-webp", - "moxcms", + "moxcms 0.7.11", "num-traits", "png", "qoi", @@ -1043,9 +1235,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 +1251,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 +1275,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 +1301,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 +1313,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 +1351,9 @@ checksum = "5edeac3ec657925dececc672bdd58fa2212291886ddcb3e08aa46924c78340c6" [[package]] name = "lzma-rust2" -version = "0.16.5" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" - -[[package]] -name = "lzma-rust2" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b" +checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" dependencies = [ "sha2", ] @@ -1171,6 +1367,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "lzvn-core" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c51f6215e459d29ee8b61c52074f545fe5518bcb351c590c6e98aec21a5a00" + [[package]] name = "lzxd" version = "0.2.7" @@ -1208,6 +1410,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 +1449,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" @@ -1280,6 +1501,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 +1542,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 +1564,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" @@ -1351,17 +1631,26 @@ dependencies = [ "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 +1661,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 +1701,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -1404,9 +1735,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 +1782,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 +1857,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 +1880,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 +1905,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 +1929,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 +1946,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 +1994,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 +2024,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 +2042,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 +2067,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 +2094,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 +2170,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 +2223,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1887,6 +2257,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 +2297,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 +2309,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 +2323,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 +2342,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 +2350,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 +2367,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 +2391,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 +2447,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -2082,9 +2472,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 +2494,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 +2641,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,9 +2658,9 @@ 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", @@ -2269,36 +2674,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", diff --git a/Cargo.toml b/Cargo.toml index 8ab9bf7..12f1c33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,14 +11,15 @@ 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 e6c653a..7f6c4d2 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 d235de8..602f981 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 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: diff --git a/NOTICE b/NOTICE index a0fb856..f3888ed 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,44 @@ 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. + + ------------------------------------------------------------------------------- 1h. oletools test sample, BSD-2-Clause (test fixture) ------------------------------------------------------------------------------- @@ -461,6 +509,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 +545,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 +718,23 @@ 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 +746,17 @@ 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, +deflate64, fatfs, fax, fax_derive, goblin, hfsplus, 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,8 +766,9 @@ 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: 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: 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 dev-only crates add CC0-1.0 / MIT-0 (ppmd-rust) and bzip2-1.0.6 diff --git a/README.md b/README.md index 61720a7..4532278 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 ef9104f..49e111d 100644 --- a/crates/exav-core/Cargo.toml +++ b/crates/exav-core/Cargo.toml @@ -32,8 +32,9 @@ 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. @@ -50,21 +51,26 @@ tlsh2 = { version = "1", features = ["diff"] } # 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 } +# 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.2", 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"] @@ -235,18 +257,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/src/engine/mod.rs b/crates/exav-core/src/engine/mod.rs index f95e442..f495798 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 diff --git a/crates/exav-core/src/jsnorm.rs b/crates/exav-core/src/jsnorm.rs index 8311967..2e11fa1 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 diff --git a/crates/exav-core/src/lib.rs b/crates/exav-core/src/lib.rs index eab32a2..86cd5f9 100644 --- a/crates/exav-core/src/lib.rs +++ b/crates/exav-core/src/lib.rs @@ -1829,7 +1829,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 +1848,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,7 +1957,7 @@ 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 { .. })) { @@ -2087,6 +2087,235 @@ where 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::{group, parse, Scheme}; + + #[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) { + if !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) => { + held_total = held_total.saturating_add(d.len()); + if held_total > opts.deep_analysis_max as usize { + lost = Some("part of a multi-volume set too large to hold for rejoining (--max-object-bytes)"); + break; + } + 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 +} + /// 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. @@ -2790,6 +3019,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 +3077,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 +3103,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 +3163,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 +3190,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 +3204,7 @@ impl<'a> Obj<'a> { container, core_type: None, filename: None, + name: None, core: true, carve: true, embedded: false, @@ -3748,6 +4052,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 { @@ -3938,8 +4294,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 +4362,30 @@ 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() { + if 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(); @@ -5123,6 +5508,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(); diff --git a/crates/exav-core/src/pe.rs b/crates/exav-core/src/pe.rs index a8723f2..6634894 100644 --- a/crates/exav-core/src/pe.rs +++ b/crates/exav-core/src/pe.rs @@ -538,6 +538,160 @@ 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), + f.word(p + offset_at, wide), + f.word(p + vaddr_at, wide), + f.word(p + memsz_at, 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)) = + (f.u32(s + 4), f.word(s + offset_at, wide), f.word(s + size_at, 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`). /// @@ -778,9 +932,34 @@ 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, 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. @@ -800,6 +979,7 @@ impl Carving { Carving { finders: CARVE_MAGICS.iter().map(|(m, _)| memchr::memmem::Finder::new(m)).collect(), hits: Vec::new(), + zip_next: None, found: Embedded::default(), } } @@ -822,14 +1002,30 @@ 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; 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)), @@ -838,9 +1034,16 @@ impl Carving { 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); + if zip { + *zip_next = zip_next_header(data, off); + } } else if matches!(kind, Magic::Pe) { found.pe_capped = true; } else { @@ -1235,6 +1438,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 6a01295..ace8d4a 100644 --- a/crates/exav-core/src/phishing.rs +++ b/crates/exav-core/src/phishing.rs @@ -235,6 +235,30 @@ pub(crate) fn scan_complete_source( (None, true) } +/// [`scan_complete`] over links already read: `(display, href)` pairs, from a +/// document that is not HTML (Markdown, a PDF) and has its own way of saying +/// where a link goes. Judged as an anchor is, allow-list included. +#[cfg(feature = "visible-text")] +pub(crate) fn scan_links(links: &[(String, String)], db: &PhishingDb) -> (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) +} + /// Offset of the next ` 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:?}")))); @@ -1419,6 +1424,72 @@ 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). +const MAX_PAREN_DEPTH: usize = 12; + +/// Refuses a source nested deeper than [`MAX_PAREN_DEPTH`] 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, mut depth) = (0, 0usize); + // 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'(' => { + depth += 1; + if depth > MAX_PAREN_DEPTH { + return Err(Error::new(format!( + "parentheses nested more than {MAX_PAREN_DEPTH} deep" + ))); + } + } + b')' => depth = depth.saturating_sub(1), + _ => {} + } + 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 +1505,59 @@ fn int_kind(name: &str) -> Option { _ => return None, }) } + +#[cfg(test)] +mod paren_depth_tests { + use super::{Compiler, 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); + } + + #[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()); + } + + #[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/tests/fixtures/fuzz/yara_nested_parens.yar b/crates/exav-core/tests/fixtures/fuzz/yara_nested_parens.yar new file mode 100644 index 0000000..68cb702 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/volumes/set4.r00 b/crates/exav-core/tests/fixtures/volumes/set4.r00 new file mode 100644 index 0000000..3cd62dc 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 0000000..a2f7ed6 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 0000000..a7a9a8a 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 0000000..b95cd62 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 0000000..344e4d8 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 0000000..dedba0a 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 0000000..24bd103 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 0000000..05bd743 Binary files /dev/null and b/crates/exav-core/tests/fixtures/volumes/split.zip differ diff --git a/crates/exav-core/tests/suites/embedded_carve.rs b/crates/exav-core/tests/suites/embedded_carve.rs index 0331c0f..8618356 100644 --- a/crates/exav-core/tests/suites/embedded_carve.rs +++ b/crates/exav-core/tests/suites/embedded_carve.rs @@ -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 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 0000000..5084024 --- /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::{analyze, loader, ScanOptions, Verdict}; + +/// 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 0000000..4cd403c --- /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/mod.rs b/crates/exav-core/tests/suites/mod.rs index 26f6924..788f847 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")] @@ -51,12 +54,18 @@ mod salvage; #[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/split_sets.rs b/crates/exav-core/tests/suites/split_sets.rs new file mode 100644 index 0000000..28c22e1 --- /dev/null +++ b/crates/exav-core/tests/suites/split_sets.rs @@ -0,0 +1,305 @@ +//! Sets of volumes whose headers say how they join (RAR, ZIP, CAB), scanned as +//! the archives they are. +//! +//! Each volume holds part of a member's bytes, so no volume read alone shows +//! what the member is. The signatures here are the ones only the whole member +//! satisfies: its MD5 (`.hdb`) for the RAR and ZIP sets, and for the CAB a +//! marker that straddles the place the folder was cut. A control in each test +//! scans the parts one at a time and finds nothing. +//! +//! Fixtures: the RAR sets are official RAR's (`rar a -ma5 -m3 -v8k` and +//! `-ma4 -m3 -vn -v8k`, `text.txt` and `noise.bin`; the MD5s are of the inputs, +//! made by `random.Random(20261001)`, see exav-unpack's `rar_solid` suite); the +//! ZIP is `zip -0 -s 64k split.zip payload.bin` of 100,000 bytes of +//! `random.Random(77)`, whose rejoined archive (`zip -s 0`) holds the payload. + +use exav_core::{analyze, analyze_split_sets, loader, ScanOptions, Scanner, Verdict}; +use std::cell::RefCell; + +fn fixture(name: &str) -> Vec { + let path = format!( + "{}/tests/fixtures/volumes/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +fn db(files: &[(&str, &str)]) -> Scanner { + let mut b = loader::Builder::new(); + for (name, text) in files { + b.add_named_bytes(name, text.as_bytes(), true); + } + b.build().expect("database") +} + +fn infected(v: &Verdict) -> Option<&str> { + match v { + Verdict::Infected { signature, .. } => Some(signature.as_str()), + _ => None, + } +} + +fn names(v: &[&str]) -> Vec { + v.iter().map(|s| (*s).to_string()).collect() +} + +const NOISE_MD5: &str = "147b5c8ca1767bca1ef297b3024fb0dd"; +const PAYLOAD_MD5: &str = "2e94a8cfc2ebdd36f886a897aeb0cf96"; + +#[test] +fn rar_sets_are_scanned_as_the_archive_they_are() { + let scanner = db(&[("t.hdb", &format!("{NOISE_MD5}:20000:Test.Noise\n"))]); + let opts = ScanOptions::default(); + for (set, parts) in [ + ( + "set5.rar", + ["set5.part1.rar", "set5.part2.rar", "set5.part3.rar"], + ), + ("set4.rar", ["set4.rar", "set4.r00", "set4.r01"]), + ] { + // Alone, no volume shows the member. + for p in parts { + assert!( + infected(&analyze(&scanner, &fixture(p), &opts).verdict).is_none(), + "{p} alone" + ); + } + let mut given = names(&parts); + given.push("notes.txt".into()); + let fetched = RefCell::new(Vec::new()); + let got = analyze_split_sets(&scanner, &given, &opts, |n, head| { + fetched.borrow_mut().push(n.to_string()); + assert!(head.is_none(), "a RAR volume is read whole or not at all"); + Ok(fixture(n)) + }); + assert_eq!(got.len(), 3, "{set}"); + for v in &got { + assert_eq!( + infected(&v.report.verdict), + Some("Test.Noise"), + "{set}: {}", + v.name + ); + assert_eq!(v.set, set); + } + assert!( + !fetched.borrow().iter().any(|n| n == "notes.txt"), + "a file of no set is not read" + ); + } +} + +#[test] +fn a_rar_set_with_a_volume_missing_is_unscannable_not_clean() { + let scanner = db(&[("t.hdb", &format!("{NOISE_MD5}:20000:Test.Noise\n"))]); + let given = names(&["set5.part1.rar", "set5.part3.rar"]); + let got = analyze_split_sets(&scanner, &given, &ScanOptions::default(), |n, _| { + Ok(fixture(n)) + }); + assert_eq!(got.len(), 2); + for v in &got { + assert!( + matches!(v.report.verdict, Verdict::Unscannable { .. }), + "{}: {:?}", + v.name, + v.report.verdict + ); + } + // One volume of a set is a file, not a set. + let alone = analyze_split_sets( + &scanner, + &names(&["set5.part1.rar"]), + &ScanOptions::default(), + |n, _| Ok(fixture(n)), + ); + assert!(alone.is_empty()); +} + +#[test] +fn a_split_zip_is_scanned_as_the_archive_it_is() { + let scanner = db(&[("t.hdb", &format!("{PAYLOAD_MD5}:100000:Test.Payload\n"))]); + let opts = ScanOptions::default(); + for p in ["split.z01", "split.zip"] { + assert!( + infected(&analyze(&scanner, &fixture(p), &opts).verdict).is_none(), + "{p} alone" + ); + } + let got = analyze_split_sets( + &scanner, + &names(&["split.zip", "other.bin", "split.z01"]), + &opts, + |n, _| Ok(fixture(n)), + ); + assert_eq!(got.len(), 2); + for v in &got { + assert_eq!( + infected(&v.report.verdict), + Some("Test.Payload"), + "{}", + v.name + ); + assert_eq!(v.set, "split.zip"); + } + // Without its last part, the `.zip`, the others are a set with a hole. + let hole = analyze_split_sets( + &scanner, + &names(&["split.z01", "other.bin"]), + &opts, + |n, _| Ok(fixture(n)), + ); + assert_eq!(hole.len(), 1); + assert!( + matches!(hole[0].report.verdict, Verdict::Unscannable { .. }), + "{:?}", + hole[0].report.verdict + ); +} + +/// A cabinet of one folder, uncompressed, as exav-unpack's tests build them. +fn cab( + prev: Option<&str>, + next: Option<&str>, + index: u16, + files: &[(u32, u32, u16, &str)], + blocks: &[(u16, &[u8])], +) -> Vec { + let flags = u16::from(prev.is_some()) | (u16::from(next.is_some()) << 1); + let mut links = Vec::new(); + for n in [prev, next].into_iter().flatten() { + links.extend_from_slice(n.as_bytes()); + links.extend_from_slice(b"\0disk\0"); + } + let mut records = Vec::new(); + for (size, offset, place, name) in files { + records.extend_from_slice(&size.to_le_bytes()); + records.extend_from_slice(&offset.to_le_bytes()); + records.extend_from_slice(&place.to_le_bytes()); + records.extend_from_slice(&[0x21, 0x5a, 0x00, 0x60, 0x20, 0x00]); + records.extend_from_slice(name.as_bytes()); + records.push(0); + } + let coff_files = 36 + links.len() + 8; + let data_at = coff_files + records.len(); + let mut data = Vec::new(); + for (uncompressed, payload) in 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 [1u16, files.len() as u16, flags, 5, index] { + out.extend_from_slice(&v.to_le_bytes()); + } + out.extend(links); + out.extend_from_slice(&(data_at as u32).to_le_bytes()); + out.extend_from_slice(&(blocks.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend(records); + out.extend(data); + out +} + +const MARKER: &[u8] = b"exav-cab-set-marker-0123456789-end"; + +fn cab_set() -> (Vec, Vec) { + let mut stream = vec![b'.'; 600]; + stream.extend_from_slice(MARKER); + stream.extend_from_slice(&[b'.'; 600]); + // One block cut inside the marker. + let cut = 600 + MARKER.len() / 2; + let one = cab( + None, + Some("part2.cab"), + 0, + &[(stream.len() as u32, 0, 0xFFFE, "marker.txt")], + &[(0, &stream[..cut])], + ); + let two = cab( + Some("part1.cab"), + None, + 1, + &[(stream.len() as u32, 0, 0xFFFD, "marker.txt")], + &[(stream.len() as u16, &stream[cut..])], + ); + (one, two) +} + +fn cab_db() -> Scanner { + let hex: String = MARKER.iter().map(|b| format!("{b:02x}")).collect(); + db(&[("t.ndb", &format!("Test.Cab:0:*:{hex}\n"))]) +} + +#[test] +fn a_cab_set_is_found_by_the_names_its_headers_give() { + let (one, two) = cab_set(); + let scanner = cab_db(); + let opts = ScanOptions::default(); + for (p, name) in [(&one, "part1.cab"), (&two, "part2.cab")] { + assert!( + infected(&analyze(&scanner, p, &opts).verdict).is_none(), + "{name} alone" + ); + } + let files = [ + ("part1.cab", one.clone()), + ("part2.cab", two.clone()), + ("Readme.txt", b"hello".to_vec()), + ]; + let given = names(&["part2.cab", "readme.txt", "part1.cab", "solo.cab"]); + let reads = RefCell::new(Vec::new()); + let got = analyze_split_sets(&scanner, &given, &opts, |n, head| { + reads.borrow_mut().push((n.to_string(), head)); + files + .iter() + .find(|(f, _)| *f == n) + .map(|(_, d)| d.clone()) + .ok_or_else(|| std::io::Error::other("no such file")) + }); + assert_eq!(got.len(), 2, "{got:?}"); + for v in &got { + assert_eq!(infected(&v.report.verdict), Some("Test.Cab"), "{}", v.name); + assert_eq!(v.set, "part1.cab"); + } + // A text file is not read at all; a cabinet is read by its head to see whether it names a neighbour. + assert!(!reads.borrow().iter().any(|(n, _)| n == "readme.txt")); + assert!(reads + .borrow() + .iter() + .any(|(n, h)| n == "part1.cab" && h.is_some())); +} + +#[test] +fn cabinets_that_do_not_follow_one_another_as_their_headers_say_are_not_joined() { + let (one, two) = cab_set(); + let scanner = cab_db(); + let files = [("part1.cab", one), ("renamed.cab", two)]; + let got = analyze_split_sets( + &scanner, + &names(&["part1.cab", "renamed.cab"]), + &ScanOptions::default(), + |n, _| { + files + .iter() + .find(|(f, _)| *f == n) + .map(|(_, d)| d.clone()) + .ok_or_else(|| std::io::Error::other("no such file")) + }, + ); + assert_eq!(got.len(), 2); + for v in &got { + assert!( + matches!(v.report.verdict, Verdict::Unscannable { .. }), + "{}: {:?}", + v.name, + v.report.verdict + ); + } +} diff --git a/crates/exav-core/tests/suites/target_text.rs b/crates/exav-core/tests/suites/target_text.rs index 2c15a85..04284af 100644 --- a/crates/exav-core/tests/suites/target_text.rs +++ b/crates/exav-core/tests/suites/target_text.rs @@ -12,6 +12,11 @@ //! //! exav ran 3, 4 and 7 over raw bytes of any text-like type, and HTML and text //! views over every one of them: matches clamscan cannot make. +//! +//! The clamscan cases hold under `ScanOptions::clamav_compat()`. With the +//! `visible-text` feature, the default options add a text view of the words a +//! reader sees in an HTML file (Markdown, Office XML and drawings too), which +//! `Target:7` signatures match: a widening on purpose, tested in `visible_text`. use exav_core::{analyze, loader, ScanOptions, Scanner, Verdict}; @@ -31,6 +36,12 @@ fn found(target: u8, body: &str, file: &[u8]) -> bool { matches!(v, Verdict::Infected { .. }) } +/// As clamscan scans: no visible-text view. +fn found_compat(target: u8, body: &str, file: &[u8]) -> bool { + let v = analyze(&scanner(target, body), file, &ScanOptions::clamav_compat()).verdict; + matches!(v, Verdict::Infected { .. }) +} + const TEXT: &[u8] = b"Some text HELLO WORLD MARKER here\n"; const RTF: &[u8] = b"{\\rtf1\\ansi HELLO WORLD MARKER}\n"; const HTML: &[u8] = b"HELLO WORLD MARKER\n"; @@ -44,13 +55,24 @@ fn text_signatures_match_the_text_view_alone() { assert!(found(0, lower, TEXT)); assert!(found(7, lower, TEXT)); assert!(!found(7, upper, TEXT), "never the raw bytes"); - assert!(!found(7, lower, HTML), "nor an HTML file"); + assert!(!found_compat(7, lower, HTML), "nor an HTML file"); assert!( !found(0, lower, b"Some text hello world marker\n"), "a text file's entities are not decoded" ); } +/// The visible-text view is what lets a text signature see an HTML file; its +/// markup is still not part of it. +#[cfg(feature = "visible-text")] +#[test] +fn text_signatures_see_the_words_of_an_html_file_by_default() { + let lower = "hello world marker"; + assert!(found(7, lower, HTML)); + assert!(!found(7, "hello world marker", HTML), "no tags"); + assert!(!found(7, "HELLO WORLD MARKER", HTML), "never the raw bytes"); +} + #[test] fn html_signatures_match_the_html_view_alone() { let lower = "hello world marker"; diff --git a/crates/exav-core/tests/suites/visible_text.rs b/crates/exav-core/tests/suites/visible_text.rs new file mode 100644 index 0000000..a907594 --- /dev/null +++ b/crates/exav-core/tests/suites/visible_text.rs @@ -0,0 +1,345 @@ +//! A Markdown document is matched through the text a reader sees as well as +//! its bytes: its words without the marks, entities decoded, and where each +//! link leads. Which files are Markdown is their name's: `.md` and +//! `.markdown`, the input's or a member's. + +use exav_core::{analyze, loader, ScanOptions, Scanner, Verdict}; + +/// A one-signature database: `Target:7`, which a text view alone matches. +fn scanner(target: u8, body: &str) -> Scanner { + let hex: String = body.bytes().map(|b| format!("{b:02x}")).collect(); + let mut l = loader::Builder::new(); + l.add_named_bytes( + "t.ndb", + format!("Test.Visible:{target}:*:{hex}\n").as_bytes(), + true, + ); + l.build().unwrap() +} + +fn verdict(db: &Scanner, name: Option<&str>, file: &[u8], compat: bool) -> Verdict { + let mut o = ScanOptions::default(); + o.filename = name.map(str::to_string); + o.clamav_compat = compat; + analyze(db, file, &o).verdict +} + +fn found(target: u8, body: &str, name: Option<&str>, file: &[u8]) -> bool { + matches!( + verdict(&scanner(target, body), name, file, false), + Verdict::Infected { .. } + ) +} + +#[test] +fn the_words_are_matched_without_the_marks_between_them() { + let md = b"Please *Download* the **new** version.\n"; + let sig = "please download the new version."; + assert!(found(7, sig, Some("README.md"), md)); + assert!(found(7, sig, Some("notes.MARKDOWN"), md)); + // The same bytes under another name are text, whose view keeps the marks. + assert!(!found(7, sig, Some("notes.txt"), md)); + assert!(!found(7, sig, None, md)); +} + +#[test] +fn an_entity_is_matched_as_the_character_it_is() { + let md = b"Fish & chips <now>\n"; + assert!(found(7, "fish & chips ", Some("a.md"), md)); + assert!(!found(7, "fish & chips ", Some("a.txt"), md)); +} + +#[test] +fn a_link_is_matched_as_its_label_and_where_it_goes() { + let md = b"[Click here](https://evil.example/payload.exe) to continue\n"; + assert!(found( + 7, + "click here (https://evil.example/payload.exe) to continue", + Some("a.md"), + md + )); + // The label names one site, the target another. + let spoof = b"Sign in at [https://www.paypal.com/login](https://evil.example/steal)\n"; + assert!(found( + 7, + "https://www.paypal.com/login (https://evil.example/steal)", + Some("a.md"), + spoof + )); + // A target the viewer would refuse is still seen. + assert!(found( + 7, + "run (javascript:alert(1))", + Some("a.md"), + b"[run](javascript:alert(1))\n" + )); +} + +#[test] +fn target_zero_signatures_see_it_too_and_html_ones_do_not() { + let md = b"a *b* c\n"; + assert!(found(0, "a b c", Some("a.md"), md)); + assert!(!found(3, "a b c", Some("a.md"), md)); +} + +#[test] +fn the_bytes_are_still_matched_as_before() { + assert!(found(0, "a *b* c", Some("a.md"), b"A *B* C\n")); +} + +#[test] +fn not_under_clamav_compat() { + let md = b"Please *download* it\n"; + let db = scanner(7, "please download it"); + assert!(matches!( + verdict(&db, Some("a.md"), md, false), + Verdict::Infected { .. } + )); + assert!(matches!( + verdict(&db, Some("a.md"), md, true), + Verdict::Clean + )); +} + +/// A stored-only ZIP holding one member. +fn zip_with(name: &str, data: &[u8]) -> Vec { + let crc = { + let mut c = !0u32; + for &b in data { + c ^= b as u32; + for _ in 0..8 { + c = if c & 1 != 0 { + (c >> 1) ^ 0xEDB8_8320 + } else { + c >> 1 + }; + } + } + !c + }; + let mut out = Vec::new(); + out.extend_from_slice(b"PK\x03\x04"); + for v in [20u16, 0, 0, 0, 0] { + out.extend_from_slice(&v.to_le_bytes()); + } + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + let cd = out.len() as u32; + let mut c = Vec::new(); + c.extend_from_slice(b"PK\x01\x02"); + for v in [20u16, 20, 0, 0, 0, 0] { + c.extend_from_slice(&v.to_le_bytes()); + } + c.extend_from_slice(&crc.to_le_bytes()); + c.extend_from_slice(&(data.len() as u32).to_le_bytes()); + c.extend_from_slice(&(data.len() as u32).to_le_bytes()); + c.extend_from_slice(&(name.len() as u16).to_le_bytes()); + for v in [0u16, 0, 0, 0] { + c.extend_from_slice(&v.to_le_bytes()); + } + c.extend_from_slice(&0u32.to_le_bytes()); + c.extend_from_slice(&0u32.to_le_bytes()); + c.extend_from_slice(name.as_bytes()); + let cl = c.len() as u32; + out.extend_from_slice(&c); + out.extend_from_slice(b"PK\x05\x06"); + for v in [0u16, 0, 1, 1] { + out.extend_from_slice(&v.to_le_bytes()); + } + out.extend_from_slice(&cl.to_le_bytes()); + out.extend_from_slice(&cd.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out +} + +#[test] +fn a_member_is_markdown_by_its_own_name() { + let md = b"Please *download* the **new** version.\n"; + let sig = scanner(7, "please download the new version."); + let yes = zip_with("docs/Readme.md", md); + let no = zip_with("docs/readme.txt", md); + assert!(matches!( + verdict(&sig, None, &yes, false), + Verdict::Infected { .. } + )); + assert!(matches!(verdict(&sig, None, &no, false), Verdict::Clean)); + // The archive's own name does not make its members Markdown. + assert!(matches!( + verdict(&sig, Some("x.md"), &no, false), + Verdict::Clean + )); +} + +/// A one-page PDF, its text drawn by `content`, padded to `pad` bytes. +#[cfg(feature = "visible-text-pdf")] +fn pdf_with(content: &str, pad: usize) -> Vec { + let mut v = format!( + "%PDF-1.4\n1 0 obj<>endobj\n\ + 2 0 obj<>endobj\n\ + 3 0 obj<>>>/Contents 4 0 R>>endobj\n\ + 4 0 obj<>\nstream\n{content}\nendstream\nendobj\n\ + 5 0 obj<>endobj\n\ + trailer<>\n%%EOF\n", + content.len() + ) + .into_bytes(); + if v.len() < pad { + v.resize(pad, b'\n'); + } + v +} + +#[cfg(feature = "visible-text-pdf")] +#[test] +fn a_pdfs_text_is_matched_though_its_bytes_hold_it_in_pieces() { + let pdf = pdf_with( + "BT /F1 12 Tf 72 700 Td [(Pay to ev) 10 (il.exa) 5 (mple)] TJ ET", + 0, + ); + assert!(found(7, "pay to evil.example", None, &pdf)); + assert!(found(0, "pay to evil.example", None, &pdf)); +} + +#[cfg(feature = "visible-text-pdf")] +#[test] +fn a_compressed_pdf_stream_is_read_through() { + use std::io::Write; + let content = "BT /F1 12 Tf 72 700 Td (Compressed bait text) Tj ET"; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(content.as_bytes()).unwrap(); + let z = z.finish().unwrap(); + let mut pdf = b"%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n3 0 obj<>>>/Contents 4 0 R>>endobj\n".to_vec(); + pdf.extend_from_slice( + format!( + "4 0 obj<>\nstream\n", + z.len() + ) + .as_bytes(), + ); + pdf.extend_from_slice(&z); + pdf.extend_from_slice(b"\nendstream\nendobj\n5 0 obj<>endobj\ntrailer<>\n%%EOF\n"); + assert!(found(7, "compressed bait text", None, &pdf)); +} + +#[test] +fn html_is_matched_without_its_tags() { + let html = b"Click here to continue"; + assert!(found( + 7, + "click here to continue (http://evil.example/x)", + Some("mail.html"), + html + )); + // Found by its content too, with no name. + assert!(found( + 7, + "click here to continue (http://evil.example/x)", + None, + html + )); + // A link whose label names another site. + let spoof = b"https://www.paypal.com/login"; + assert!(found( + 7, + "https://www.paypal.com/login (https://evil.example/steal)", + None, + spoof + )); +} + +fn phishing(name: &str, file: &[u8], alert: bool) -> Verdict { + let mut o = ScanOptions::default(); + o.filename = Some(name.to_string()); + o.alert_phishing = alert; + analyze(&scanner(7, "never present"), file, &o).verdict +} + +#[test] +fn a_link_that_says_one_site_and_goes_to_another_is_phishing_in_markdown_too() { + let md = b"Sign in at [https://www.paypal.com/login](https://evil.example/steal) today\n"; + match phishing("mail.md", md, true) { + Verdict::Infected { signature, .. } => { + assert_eq!(signature, "Heuristics.Phishing.Email.SpoofedDomain") + } + v => panic!("{v:?}"), + } + // Off unless asked for, as for HTML. + assert!(matches!(phishing("mail.md", md, false), Verdict::Clean)); + // A link that is what it says is not. + assert!(matches!( + phishing( + "mail.md", + b"[www.paypal.com](https://www.paypal.com/login)\n", + true + ), + Verdict::Clean + )); + // Nor a label that names no site. + assert!(matches!( + phishing("mail.md", b"[click here](https://evil.example/x)\n", true), + Verdict::Clean + )); +} + +#[test] +fn an_html_link_whose_target_is_written_in_entities_is_judged_as_it_reads() { + // The raw anchor reader sees `https://...`; the page reads `https://...`. + let html = b"www.paypal.com"; + assert!(matches!( + phishing("mail.html", html, true), + Verdict::Infected { .. } + )); +} + +#[test] +fn the_parts_of_an_office_file_are_matched_by_their_path() { + let part = b"Please download the attachment"; + let sig = scanner(7, "please download the attachment"); + let docx = zip_with("word/document.xml", part); + let other = zip_with("word/other.xml", part); + assert!(matches!( + verdict(&sig, None, &docx, false), + Verdict::Infected { .. } + )); + assert!(matches!(verdict(&sig, None, &other, false), Verdict::Clean)); +} + +#[test] +fn a_drawings_text_is_matched_without_its_formatting() { + let dxf = " 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"; + assert!(found(7, "pay now", Some("plan.dxf"), dxf.as_bytes())); + assert!(!found(7, "pay now", Some("plan.txt"), b"Pay\\Pnow")); +} + +#[test] +fn an_object_not_held_in_memory_is_not_reported_clean_when_it_has_text() { + let db = scanner(7, "never present"); + // An HTML page: binary objects get no text view for the size to matter to. + let mut page = b"words".to_vec(); + page.resize(16 * 1024, b' '); + let verdict = |max: u64| { + let mut o = ScanOptions::default(); + o.deep_analysis_max = max; + o.filename = Some("page.html".to_string()); + analyze(&db, &page, &o).verdict + }; + assert!(matches!(verdict(1 << 20), Verdict::Clean)); + // `analyze` holds the bytes whatever the limit; a larger input is read through a cache. + let mut o = ScanOptions::default(); + o.deep_analysis_max = 4096; + o.filename = Some("page.html".to_string()); + let v = exav_core::scan_seekable( + &db, + std::io::Cursor::new(page.clone()), + page.len() as u64, + &o, + ) + .expect("scan") + .verdict; + assert!(!matches!(v, Verdict::Clean), "{v:?}"); +} diff --git a/crates/exav-core/tests/suites/visible_text_office.rs b/crates/exav-core/tests/suites/visible_text_office.rs new file mode 100644 index 0000000..e3e4cec --- /dev/null +++ b/crates/exav-core/tests/suites/visible_text_office.rs @@ -0,0 +1,138 @@ +//! The office formats' text (`visible-text-office`): a DOC, an XLS, a PPT, an +//! RTF, a flat OpenDocument file is matched through the words a reader sees, +//! its links are judged, and one whose text cannot be taken out is never +//! reported clean. + +use exav_core::{analyze, loader, ScanOptions, Scanner, Verdict}; + +const DOC: &[u8] = include_bytes!("../../../exav-render/tests/fixtures/office/docs/sample.doc"); +const XLS: &[u8] = include_bytes!("../../../exav-render/tests/fixtures/office/sheets/sample.xls"); +const PPT: &[u8] = include_bytes!("../../../exav-render/tests/fixtures/office/slides/sample.ppt"); + +/// A one-signature database: `Target:7`, which a text view alone matches. +fn scanner(body: &str) -> Scanner { + let hex: String = body.bytes().map(|b| format!("{b:02x}")).collect(); + let mut l = loader::Builder::new(); + l.add_named_bytes( + "t.ndb", + format!("Test.Visible:7:*:{hex}\n").as_bytes(), + true, + ); + l.build().unwrap() +} + +fn verdict(db: &Scanner, name: &str, file: &[u8], phishing: bool) -> Verdict { + let mut o = ScanOptions::default(); + o.filename = Some(name.to_string()); + o.alert_phishing = phishing; + analyze(db, file, &o).verdict +} + +fn found(words: &str, name: &str, file: &[u8]) -> bool { + matches!( + verdict(&scanner(words), name, file, false), + Verdict::Infected { .. } + ) +} + +#[test] +fn an_rtf_phrase_split_by_its_groups_is_matched_as_the_words() { + let rtf = b"{\\rtf1\\ansi Please down{\\b load} the \\i attach\\i0 ment\\par}"; + assert!(found("please download the attachment", "mail.rtf", rtf)); + // The words of another document are not there. + assert!(!found("please upload the attachment", "mail.rtf", rtf)); +} + +#[test] +fn the_words_of_a_doc_an_xls_and_a_ppt_are_matched() { + assert!(found("revenue grew by twelve percent", "report.doc", DOC)); + assert!(found("merged note across three columns", "budget.xls", XLS)); + assert!(found("revenue up 12%", "deck.ppt", PPT)); + // A compound file is read by its name: another one is not read. + assert!(!found("revenue up 12%", "deck.bin", PPT)); +} + +#[test] +fn a_flat_opendocument_file_is_matched_without_its_markup() { + let fodt = br#"Wire the funds today"#; + assert!(found("wire the funds today", "invoice.fodt", fodt)); +} + +#[test] +fn a_link_that_says_one_site_and_goes_to_another_is_phishing_in_a_legacy_format_too() { + let rtf = b"{\\rtf1\\ansi Sign in: {\\field{\\*\\fldinst HYPERLINK \"https://evil.example/steal\"}{\\fldrslt https://www.paypal.com/login}}\\par}"; + let db = scanner("never present"); + match verdict(&db, "mail.rtf", rtf, true) { + Verdict::Infected { signature, .. } => { + assert_eq!(signature, "Heuristics.Phishing.Email.SpoofedDomain") + } + v => panic!("{v:?}"), + } + assert!(matches!( + verdict(&db, "mail.rtf", rtf, false), + Verdict::Clean + )); +} + +/// The sample DOC with its FIB's encrypted flag set. +fn encrypted_doc() -> Vec { + let mut d = DOC.to_vec(); + // The WordDocument stream starts a sector with the FIB's magic, 0xA5EC. + let at = (1..d.len() / 512) + .map(|s| s * 512) + .find(|&o| d[o] == 0xEC && d[o + 1] == 0xA5) + .expect("the FIB"); + // fEncrypted: bit 8 of the flags word at 0x0A. + d[at + 0x0B] |= 0x01; + d +} + +#[test] +fn a_document_whose_text_cannot_be_read_is_not_reported_clean() { + let db = scanner("never present"); + // Read, nothing found: clean. + assert!(matches!( + verdict(&db, "report.doc", DOC, false), + Verdict::Clean + )); + // Encrypted: its text is not seen, and the scan says so. + let v = verdict(&db, "report.doc", &encrypted_doc(), false); + assert!( + !matches!(v, Verdict::Clean | Verdict::Infected { .. }), + "{v:?}" + ); +} + +#[test] +fn a_damaged_document_whose_text_cannot_be_taken_out_makes_the_scan_incomplete() { + // The FIB's fields erased: the reader refuses it as damaged, and nothing + // else in the scan looks at what a reader would see. + let mut d = DOC.to_vec(); + let at = (1..d.len() / 512) + .map(|s| s * 512) + .find(|&o| d[o] == 0xEC && d[o + 1] == 0xA5) + .expect("the FIB"); + d[at + 0x20..at + 0x60].fill(0xFF); + let db = scanner("never present"); + let v = verdict(&db, "broken.doc", &d, false); + assert!( + !matches!(v, Verdict::Clean | Verdict::Infected { .. }), + "{v:?}" + ); + // The same bytes under a name that is not an office document's: read as + // nothing, clean. + assert!(matches!( + verdict(&db, "broken.bin", &d, false), + Verdict::Clean + )); +} + +#[test] +fn a_file_that_only_looks_like_a_document_by_its_name_is_not_reported_incomplete() { + // Not a compound file at all, under a .doc name: nothing to read. + let db = scanner("never present"); + assert!(matches!( + verdict(&db, "notes.doc", b"just some plain words", false), + Verdict::Clean + )); +} diff --git a/crates/exav-grep/Cargo.toml b/crates/exav-grep/Cargo.toml index d6be113..f5fe5b3 100644 --- a/crates/exav-grep/Cargo.toml +++ b/crates/exav-grep/Cargo.toml @@ -20,9 +20,22 @@ exav-unpack = { path = "../exav-unpack", version = "0.0.2", default-features = f regex = "1" clap = { workspace = true } walkdir = { workspace = true } +# The text of a document as a reader sees it (`text` feature): exav-render's +# `text`: Markdown, HTML, Office and OpenDocument XML parts, PDF and drawings. +exav-render = { path = "../exav-render", version = "0.0.2", default-features = false, features = ["text"], optional = true } [features] -default = ["decrypt"] +default = ["decrypt", "text"] +# `--text`: search the text a reader sees rather than the bytes, for the +# formats that have one (Markdown, HTML, Office and OpenDocument XML parts, +# PDF, drawings). +text = ["dep:exav-render"] +# And PDF's, read by hayro. +text-pdf = ["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, by sheet, slide and part. The largest readers: not a default. +text-office = ["text", "exav-render/text-office"] # Decrypt encrypted members (ZIP/7z/DMG/Office) so their contents are searchable # rather than reported as unreadable. decrypt = ["exav-unpack/decrypt"] diff --git a/crates/exav-grep/src/lib.rs b/crates/exav-grep/src/lib.rs index ac0a8b3..e574428 100644 --- a/crates/exav-grep/src/lib.rs +++ b/crates/exav-grep/src/lib.rs @@ -103,6 +103,14 @@ pub struct Options { pub max_count: usize, /// Treat a member as binary and report only that it matched, not the line. pub binary_as_matches: bool, + /// Search the text a reader sees rather than the bytes, for a member that + /// has one: Markdown, HTML, the XML parts of Office and OpenDocument files, + /// PDF and drawings are searched as their words, with entities decoded and + /// each link followed by where it goes, under the path `file.md!text`, its + /// line numbers being the text's. A document that is also a container (a + /// PDF, a drawing) is walked for its members as before. Does nothing + /// without the `text` feature. + pub text: bool, } /// One result of the search. @@ -280,8 +288,8 @@ impl Searcher { }); } let Some(fmt) = exav_unpack::detect(&data).or_else(|| packed_executable(data)) else { - // A leaf: search its bytes. - return self.search_one(label, data, sink); + // A leaf: search its bytes, or its text. + return self.search_leaf(label, data, sink); }; // A container is searched through its MEMBERS, not its raw bytes — for a // stored member those are the same bytes, and reporting both would @@ -350,9 +358,88 @@ impl Searcher { if keep_going && members == 0 { return self.search_one(label, data, sink); } + // A container with text of its own (a PDF, a drawing) is searched as + // that too, besides its members. + #[cfg(feature = "text")] + if keep_going && self.opts.text { + if let Some(go) = self.search_text(label, data, sink) { + return go; + } + } keep_going } + /// Search a leaf: its text under `--text` when it has one, else its bytes. + fn search_leaf(&mut self, label: &str, data: &[u8], sink: Sink) -> bool { + #[cfg(feature = "text")] + if self.opts.text { + if let Some(go) = self.search_text(label, data, sink) { + return go; + } + } + self.search_one(label, data, sink) + } + + /// Search the text a reader sees in `data`, as `label!text` (and + /// `label!text/` for a sheet or a slide), when it is a kind of + /// document that has some (`None` when it is not, and also when its text + /// could not be taken out: that is reported unreadable and its bytes are + /// searched instead). A text cut short is reported unreadable, as a + /// member that failed part way is. + #[cfg(feature = "text")] + fn search_text(&mut self, label: &str, data: &[u8], sink: Sink) -> Option { + let name = label.rsplit(NESTING_SEP).next().unwrap_or(label); + let cap = usize::try_from(self.opts.limits.max_buffer_bytes).unwrap_or(usize::MAX); + let cap = data + .len() + .saturating_mul(4) + .saturating_add(64 << 10) + .min(cap); + let visible = exav_render::text::visible_text(name, data, cap, &self.opts.limits)?; + let path = format!("{label}{NESTING_SEP}text"); + if let Some(why) = &visible.unreadable { + // A document of a kind that has text whose text could not be + // taken out: said, and its bytes are searched instead. + self.unreadable += 1; + if !sink(Event::Unreadable { + path, + reason: format!("visible text: {why}"), + }) { + return Some(false); + } + return None; + } + // One search to a section (a sheet, a slide), so that a match says + // where it is: `book.xls!text/sheet "Budget"`; what comes before the + // first section, and a document of one block, is `!text`. + let mut at = 0; + let mut parts: Vec<(String, std::ops::Range)> = Vec::new(); + for (section, range) in &visible.sections { + if range.start > at { + parts.push((path.clone(), at..range.start)); + } + parts.push((format!("{path}/{section}"), range.clone())); + at = range.end; + } + if at < visible.text.len() || parts.is_empty() { + parts.push((path.clone(), at..visible.text.len())); + } + for (part, range) in parts { + let bytes = visible.text.as_bytes().get(range).unwrap_or_default(); + if !self.search_one(&part, bytes, sink) { + return Some(false); + } + } + if visible.truncated { + self.unreadable += 1; + return Some(sink(Event::Unreadable { + path, + reason: format!("visible text cut at {cap} bytes"), + })); + } + Some(true) + } + /// Line-match a single object's bytes. fn search_one(&mut self, path: &str, data: &[u8], sink: Sink) -> bool { if looks_binary(data) { diff --git a/crates/exav-grep/src/main.rs b/crates/exav-grep/src/main.rs index 2dacb59..070b426 100644 --- a/crates/exav-grep/src/main.rs +++ b/crates/exav-grep/src/main.rs @@ -101,6 +101,15 @@ struct Cli { /// hiding them turns "I couldn't look" into an indistinguishable "no match". #[arg(long)] quiet_unreadable: bool, + + /// Search the text a reader sees rather than the bytes, for the files that + /// have one: Markdown, HTML, Office and OpenDocument XML parts, PDF and + /// drawings (DXF, DWG). Entities are decoded and each link is followed by + /// where it goes; matches are reported under `file.md!text`, with the + /// text's line numbers. + #[cfg(feature = "text")] + #[arg(long)] + text: bool, } fn main() -> ExitCode { @@ -137,6 +146,10 @@ fn main() -> ExitCode { after_context: cli.after_context.max(ctx), max_count: cli.max_count, binary_as_matches: true, + #[cfg(feature = "text")] + text: cli.text, + #[cfg(not(feature = "text"))] + text: false, }; let mut searcher = Searcher::new(matcher, opts); diff --git a/crates/exav-grep/tests/text.rs b/crates/exav-grep/tests/text.rs new file mode 100644 index 0000000..bf90bea --- /dev/null +++ b/crates/exav-grep/tests/text.rs @@ -0,0 +1,178 @@ +//! `--text`: a Markdown file is searched as the text a reader sees. + +#![cfg(feature = "text")] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_exav-grep"); + +/// A scratch directory unique to one test, removed when the test ends. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Self { + let d = std::env::temp_dir().join(format!("exav-grep-text-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("scratch dir"); + Scratch(d) + } + + fn write(&self, name: &str, bytes: &[u8]) -> PathBuf { + let p = self.0.join(name); + std::fs::write(&p, bytes).expect("write fixture"); + p + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn run(args: &[&str], file: &Path) -> Output { + Command::new(BIN) + .args(args) + .arg(file) + .output() + .expect("spawn exav-grep") +} + +fn code(o: &Output) -> i32 { + o.status.code().expect("exited normally, not by signal") +} + +fn stdout(o: &Output) -> String { + String::from_utf8_lossy(&o.stdout).into_owned() +} + +/// Minimal stored-member ZIP (local headers only: exav reads those). +fn zip_of(name: &str, body: &[u8]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"PK\x03\x04"); + for n in [20u16, 0, 0, 0, 0] { + v.extend_from_slice(&n.to_le_bytes()); + } + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&(body.len() as u32).to_le_bytes()); + v.extend_from_slice(&(body.len() as u32).to_le_bytes()); + v.extend_from_slice(&(name.len() as u16).to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); + v.extend_from_slice(name.as_bytes()); + v.extend_from_slice(body); + v +} + +#[test] +fn words_split_by_marks_are_found_only_with_text() { + let s = Scratch::new("marks"); + let f = s.write( + "notes.md", + b"# Title\n\nPlease *download* the **new** version.\n", + ); + let with = run(&["--text", "-F", "Please download the new version"], &f); + assert_eq!(code(&with), 0, "{}", stdout(&with)); + assert_eq!( + stdout(&with), + format!("{}!text:2:Please download the new version.\n", f.display()) + ); + // Without it, the bytes are searched, marks and all. + assert_eq!( + code(&run(&["-F", "Please download the new version"], &f)), + 1 + ); + // And what is in the bytes still is, with it, only as the reader sees it. + assert_eq!(code(&run(&["--text", "-F", "**new**"], &f)), 1); +} + +#[test] +fn a_link_is_followed_by_where_it_goes_and_an_entity_is_decoded() { + let s = Scratch::new("links"); + let f = s.write( + "a.markdown", + b"Sign in at [https://www.paypal.com/login](https://evil.example/steal) & pay\n", + ); + let o = run(&["--text", "evil.example/steal"], &f); + assert_eq!(code(&o), 0); + assert!( + stdout(&o).contains("https://www.paypal.com/login (https://evil.example/steal) & pay"), + "{}", + stdout(&o) + ); +} + +#[test] +fn a_member_of_an_archive_is_searched_by_its_own_name() { + let s = Scratch::new("zip"); + let body = b"Please *download* it\n"; + let md = s.write("a.zip", &zip_of("docs/readme.md", body)); + let o = run(&["--text", "-F", "Please download it"], &md); + assert_eq!(code(&o), 0); + assert_eq!( + stdout(&o), + format!( + "{}!docs/readme.md!text:1:Please download it\n", + md.display() + ) + ); + let txt = s.write("b.zip", &zip_of("docs/readme.txt", body)); + assert_eq!(code(&run(&["--text", "-F", "Please download it"], &txt)), 1); +} + +#[test] +fn html_is_searched_without_its_tags() { + let s = Scratch::new("html"); + let f = s.write( + "page.html", + b"Click here to go on", + ); + let o = run( + &[ + "--text", + "-F", + "Click here to go on (http://evil.example/x)", + ], + &f, + ); + assert_eq!(code(&o), 0, "{}", stdout(&o)); + assert_eq!( + stdout(&o), + format!( + "{}!text:1:Click here to go on (http://evil.example/x)\n", + f.display() + ) + ); + assert_eq!(code(&run(&["-F", "Click here to go on"], &f)), 1); +} + +#[cfg(feature = "text-pdf")] +#[test] +fn a_pdf_is_searched_as_its_text_besides_its_streams() { + let s = Scratch::new("pdf"); + let content = "BT /F1 12 Tf 72 700 Td [(Pay to ev) 10 (il.exa) 5 (mple)] TJ ET"; + let pdf = format!( + "%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n\ + 3 0 obj<>>>/Contents 4 0 R>>endobj\n\ + 4 0 obj<>\nstream\n{content}\nendstream\nendobj\n\ + 5 0 obj<>endobj\ntrailer<>\n%%EOF\n", + content.len() + ); + let f = s.write("doc.pdf", pdf.as_bytes()); + let o = run(&["--text", "-F", "Pay to evil.example"], &f); + assert_eq!(code(&o), 0, "{}", stdout(&o)); + assert_eq!( + stdout(&o), + format!("{}!text:1:Pay to evil.example\n", f.display()) + ); + assert_eq!(code(&run(&["-F", "Pay to evil.example"], &f)), 1); +} + +#[test] +fn other_files_are_searched_as_they_are() { + let s = Scratch::new("other"); + let f = s.write("notes.txt", b"a *b* c\n"); + let o = run(&["--text", "-F", "a *b* c"], &f); + assert_eq!(code(&o), 0); + assert_eq!(stdout(&o), format!("{}:1:a *b* c\n", f.display())); +} diff --git a/crates/exav-grep/tests/text_office.rs b/crates/exav-grep/tests/text_office.rs new file mode 100644 index 0000000..b811184 --- /dev/null +++ b/crates/exav-grep/tests/text_office.rs @@ -0,0 +1,129 @@ +//! `--text` on the office formats the XML parts do not reach: a match says +//! its sheet or slide, and a document whose text cannot be taken out is +//! reported unreadable. + +#![cfg(feature = "text-office")] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_exav-grep"); + +const XLS: &[u8] = include_bytes!("../../exav-render/tests/fixtures/office/sheets/sample.xls"); +const PPT: &[u8] = include_bytes!("../../exav-render/tests/fixtures/office/slides/sample.ppt"); +const DOC: &[u8] = include_bytes!("../../exav-render/tests/fixtures/office/docs/sample.doc"); + +/// A scratch directory unique to one test, removed when the test ends. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Self { + let d = std::env::temp_dir().join(format!("exav-grep-office-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("scratch dir"); + Scratch(d) + } + + fn write(&self, name: &str, bytes: &[u8]) -> PathBuf { + let p = self.0.join(name); + std::fs::write(&p, bytes).expect("write fixture"); + p + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn run(args: &[&str], file: &Path) -> Output { + Command::new(BIN) + .args(args) + .arg(file) + .output() + .expect("spawn exav-grep") +} + +fn code(o: &Output) -> i32 { + o.status.code().expect("exited normally, not by signal") +} + +fn stdout(o: &Output) -> String { + String::from_utf8_lossy(&o.stdout).into_owned() +} + +#[test] +fn a_match_in_a_workbook_says_its_sheet() { + let s = Scratch::new("xls"); + let f = s.write("budget.xls", XLS); + let o = run(&["--text", "-F", "Second sheet"], &f); + assert_eq!(code(&o), 0, "{}", stdout(&o)); + // The text, by sheet (the workbook's own stream is a member too, and its + // bytes match as well). + let line = format!("{}!text/sheet \"Notes\":2:Second sheet\t1\n", f.display()); + assert!(stdout(&o).contains(&line), "{}", stdout(&o)); + assert!(!stdout(&o).contains("sheet \"Budget\""), "{}", stdout(&o)); + // Another sheet's words are in that sheet. + let o = run(&["--text", "-F", "Merged note"], &f); + assert!( + stdout(&o).contains(&format!("{}!text/sheet \"Budget\":", f.display())), + "{}", + stdout(&o) + ); +} + +#[test] +fn a_match_in_a_presentation_says_its_slide() { + let s = Scratch::new("ppt"); + let f = s.write("deck.ppt", PPT); + let o = run(&["--text", "-F", "Second slide text"], &f); + assert_eq!(code(&o), 0, "{}", stdout(&o)); + assert_eq!( + stdout(&o), + format!("{}!text/slide 2:2:Second slide text\n", f.display()) + ); +} + +#[test] +fn a_documents_words_are_found_without_its_formatting_and_one_block_is_the_text() { + let s = Scratch::new("doc"); + let f = s.write("report.doc", DOC); + let o = run(&["--text", "-F", "costs stayed flat"], &f); + assert_eq!(code(&o), 0, "{}", stdout(&o)); + assert!( + stdout(&o).starts_with(&format!("{}!text:", f.display())), + "{}", + stdout(&o) + ); +} + +#[test] +fn an_encrypted_document_is_reported_unreadable_and_its_bytes_are_searched() { + let s = Scratch::new("locked"); + let mut doc = DOC.to_vec(); + let at = (1..doc.len() / 512) + .map(|n| n * 512) + .find(|&o| doc[o] == 0xEC && doc[o + 1] == 0xA5) + .expect("the FIB"); + doc[at + 0x0B] |= 0x01; + let f = s.write("locked.doc", &doc); + let o = run(&["--text", "-F", "no such words"], &f); + // No match, and a part that could not be read: the search is incomplete. + assert_eq!(code(&o), 3, "{}", stdout(&o)); + let all = format!("{}{}", stdout(&o), String::from_utf8_lossy(&o.stderr)); + assert!( + all.contains("visible text") && all.contains("ncrypted"), + "{all}" + ); +} + +#[test] +fn without_text_the_bytes_are_searched_as_before() { + let s = Scratch::new("bytes"); + let f = s.write("deck.ppt", PPT); + let o = run(&["-a", "-F", "Second slide text"], &f); + // The text is stored as UTF-16 or 8-bit in a compound file: the word + // search on bytes may or may not see it, and never says a sheet or a slide. + assert!(!stdout(&o).contains("!text"), "{}", stdout(&o)); +} diff --git a/crates/exav-pe-emu/NOTICE b/crates/exav-pe-emu/NOTICE index d61ed5d..c0b3d88 100644 --- a/crates/exav-pe-emu/NOTICE +++ b/crates/exav-pe-emu/NOTICE @@ -1,7 +1,85 @@ exav-pe-emu: third-party notices +================================ -This crate is part of exav and contains no third-party source. The notices -for the components exav links are in the NOTICE file at the root of the exav -repository, which also ships in every release archive: +exav-pe-emu is licensed under the MIT License (see LICENSE). This file gives the +notices for the third-party data in this crate's source. The notices for the +components the exav binaries link are in the NOTICE file at the root of the +exav repository: https://github.com/sylvinus/exav/blob/main/NOTICE -https://github.com/sylvinus/exav/blob/main/NOTICE + +Microsoft API documentation, CC BY 4.0 (export name lists) +---------------------------------------------------------- + +src/exports/{kernel32,ntdll,user32,advapi32}.txt list the export names the +synthetic system DLLs advertise. scripts/gen-exports.py, in this crate, +extracts them from the metadata of Microsoft's API reference pages: for each +page marked as a DLL export, the function name and the DLLs that export it. +Nothing else from the pages is used. The lists are a selection and +rearrangement of that metadata (names only, sorted, one per line). One name is +added by the script with its reason stated there: ntdll's RtlDecompressBuffer, +which the documentation records only under NtosKrnl.exe. + +Sources, pinned by commit: + + MicrosoftDocs/sdk-api + https://github.com/MicrosoftDocs/sdk-api + commit 9e0d205d747b5e0f08019cc7e202112c3aa1f68c + MicrosoftDocs/windows-driver-docs-ddi + https://github.com/MicrosoftDocs/windows-driver-docs-ddi + commit 7515063cea4c9e98db6a92986c5b4ddb0463fd16 + +Copyright (c) Microsoft Corporation. + +The documentation is licensed under the Creative Commons Attribution 4.0 +International Public License (CC BY 4.0): +https://creativecommons.org/licenses/by/4.0/legalcode + +The material is provided as-is, without warranties of any kind, as stated in +section 5 of that licence. + + +Layout facts for the synthetic TEB, PEB, loader lists and KUSER_SHARED_DATA +--------------------------------------------------------------------------- + +src/win.rs tags each field it writes with where its offset comes from. No text +is copied; these are the sources of the facts, all Microsoft documentation +under the CC BY 4.0 notice above: + + [pe] PE Format specification, "The .tls Section" (TEB+0x2C TLS array) + MicrosoftDocs/win32, desktop-src/Debug/pe-format.md, + commit 8a9c5d8f32001411f5e572a17e6d736d6fcc055f + [dbg] Debugger documentation, `dt` output from 32-bit Windows: TEB NtTib + (0x00), ClientId (0x20), ThreadLocalStoragePointer (0x2C), + ProcessEnvironmentBlock (0x30); PEB Ldr (0x0C); PEB_LDR_DATA Length + (0x28), Initialized (0x04) and its three list heads (0x0C, 0x14, 0x1C). + MicrosoftDocs/windows-driver-docs, commit + 6de78e042e0eaba466569c7f5ed65c250c644a0b: debugger/ + debugging-a-stack-overflow.md, debuggercmds/dt--display-type-.md, + debuggercmds/c---numbers-and-operators.md + [wtl] winternl.h reference: LDR_DATA_TABLE_ENTRY (InMemoryOrderLinks 0x08, + DllBase 0x18, FullDllName 0x24, and the positions of the reserved + slots at 0x00, 0x10, 0x1C and 0x2C), MicrosoftDocs/sdk-api (commit + above), ns-winternl-peb_ldr_data.md + [kus] KUSER_SHARED_DATA reference: field order only, + MicrosoftDocs/windows-driver-docs-ddi (commit above), + ns-ntddk-kuser_shared_data.md + +Fields tagged [obs] are not given by these sources. Each holds a value a +32-bit Windows process can compare against an API that reports the same thing, +which is how the offset is established: + + TEB 0x00/0x04/0x08 ExceptionList, StackBase, StackLimit: the SEH chain + head and the stack bounds of the running thread + TEB 0x18 Self: equals the TEB address + TEB 0x20/0x24 GetCurrentProcessId(), GetCurrentThreadId() + PEB 0x08 GetModuleHandle(NULL) + PEB 0x18 GetProcessHeap() + PEB 0x64 GetSystemInfo().dwNumberOfProcessors + PEB 0x68 global flags as set with gflags for the image + PEB 0xA4..0xB0 RtlGetVersion(): major, minor, build, platform id + LDR entry 0x00/0x10 walking the load-order and initialisation-order lists + reaches the same entries the memory-order list does + LDR entry 0x1C/0x20 the module's entry point and SizeOfImage from its headers + LDR entry 0x2C the module's file name + KUSER 0x00..0x270 GetTickCount(), the system time, the system root and + RtlGetVersion() major/minor diff --git a/crates/exav-pe-emu/scripts/gen-exports.py b/crates/exav-pe-emu/scripts/gen-exports.py new file mode 100644 index 0000000..afe9fdb --- /dev/null +++ b/crates/exav-pe-emu/scripts/gen-exports.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# Regenerate src/exports/.txt: the export-name sets the synthetic system +# DLLs advertise, so a stub that resolves imports by hashing export names finds +# the function it looks for even when the emulator does not implement it. +# +# Source: Microsoft's own API documentation, which records for every exported +# function the DLLs that export it. Each reference page carries YAML front +# matter with `UID: NF:.` (the exact exported name; ANSI and +# Unicode variants each have their own page), `api_type: DllExport` and an +# `api_location` list of DLLs. Only those three fields are read. +# +# MicrosoftDocs/sdk-api (Win32 API reference) +# MicrosoftDocs/windows-driver-docs-ddi (WDK reference; Rtl*/Zw* in ntdll) +# +# Both are (c) Microsoft Corporation, documentation licensed under CC BY 4.0. +# The attribution is in this crate's NOTICE. Nothing else is taken from them. +# +# Usage: download the two archives below (any path), then +# gen-exports.py sdk-api.tar.gz windows-driver-docs-ddi.tar.gz +# The archives are read in place, never extracted. The commit is the pin; the +# SHA-256 is of GitHub's tar.gz for that commit as downloaded on 2026-10-18. +# GitHub does not promise byte-stable archives, so on a hash mismatch rebuild the +# archive from the pinned commit (`git archive --format=tar.gz`) and re-pin. + +import hashlib +import os +import re +import sys +import tarfile + +SOURCES = [ + { + "repo": "MicrosoftDocs/sdk-api", + "commit": "9e0d205d747b5e0f08019cc7e202112c3aa1f68c", + "url": "https://codeload.github.com/MicrosoftDocs/sdk-api/tar.gz/" + "9e0d205d747b5e0f08019cc7e202112c3aa1f68c", + "sha256": "7ea18a09a9eb9dc2d50fddc8835f488bb349c6763a2e4eff1d1d3f99a4b53928", + }, + { + "repo": "MicrosoftDocs/windows-driver-docs-ddi", + "commit": "7515063cea4c9e98db6a92986c5b4ddb0463fd16", + "url": "https://codeload.github.com/MicrosoftDocs/windows-driver-docs-ddi/tar.gz/" + "7515063cea4c9e98db6a92986c5b4ddb0463fd16", + "sha256": "3c3fb42a76ec66aa3b7d61a78901ad1d5612220f9e89847f61ae94a5c11dc2fc", + }, +] + +# The preloaded DLLs the documentation records exports for. `msvcrt.dll` is +# absent: the CRT reference does not map functions to it. +DLLS = ["kernel32.dll", "ntdll.dll", "user32.dll", "advapi32.dll"] + +# Names added by hand, each with its justification. The documentation records +# the DLLs a function is exported from, and a few user-mode ntdll exports are +# documented only under the kernel. +ADDED = { + "ntdll.dll": [ + # Documented in the WDK (ntifs.h, nf-ntifs-rtldecompressbuffer) with + # api_location NtosKrnl.exe only. ntdll.dll exports it too, and + # Alienyze resolves it there by hashing ntdll's export names; without + # it the stub skips decompression. + "RtlDecompressBuffer", + ], +} + +# Cap per module, so a synthetic module's export directory stays inside the +# region below its trap range (see `MAX_EXPORT_DIR` in src/win.rs). +MAX_PER_MODULE = 2000 + +IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def front_matter(text): + """The scalar and list fields of a page's leading `---` block.""" + if not text.startswith("---"): + return None + end = text.find("\n---", 3) + if end < 0: + return None + fields, key = {}, None + for line in text[3:end].splitlines(): + if line.startswith(" - ") and key is not None: + fields.setdefault(key, []).append(line[3:].strip()) + continue + m = re.match(r"([A-Za-z0-9_.-]+):\s*(.*)$", line) + if m: + key, value = m.group(1), m.group(2).strip() + fields[key] = [value] if value else [] + return fields + + +def exports_in(archive, out): + with tarfile.open(archive, "r:gz") as tar: + for member in tar: + if not member.isfile() or not member.name.endswith(".md"): + continue + # Reference pages only; a page is a few kilobytes, so anything far + # larger is not one and is skipped unread. + if member.size > 1 << 20: + continue + raw = tar.extractfile(member).read().decode("utf-8", "replace") + fm = front_matter(raw) + if not fm: + continue + if "DllExport" not in fm.get("api_type", []): + continue + # VersionHelpers.h functions are inline in the header, not exports, + # though their pages list ntdll.dll. + if (fm.get("req.header") or [""])[0].lower() == "versionhelpers.h": + continue + uid = (fm.get("UID") or [""])[0] + if not uid.startswith("NF:") or "." not in uid: + continue + name = uid.rsplit(".", 1)[1] + if not IDENT.fullmatch(name) or name not in fm.get("api_name", []): + continue + for loc in fm.get("api_location", []): + dll = loc.strip().lower() + if dll in out: + out[dll].add(name) + + +def sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(1 << 20), b""): + h.update(block) + return h.hexdigest() + + +def main(): + if len(sys.argv) != len(SOURCES) + 1: + sys.exit("usage: gen-exports.py sdk-api.tar.gz windows-driver-docs-ddi.tar.gz") + out = {dll: set() for dll in DLLS} + for src, path in zip(SOURCES, sys.argv[1:]): + got = sha256(path) + if got != src["sha256"]: + sys.exit(f"{path}: sha256 {got}, expected {src['sha256']} ({src['repo']}@{src['commit']})") + exports_in(path, out) + for dll, names in ADDED.items(): + out[dll].update(names) + out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src", "exports") + os.makedirs(out_dir, exist_ok=True) + for dll in DLLS: + names = sorted(out[dll])[:MAX_PER_MODULE] + dest = os.path.join(out_dir, dll.removesuffix(".dll") + ".txt") + with open(dest, "w") as f: + f.write("".join(n + "\n" for n in names)) + print(f"{dll}: {len(names)} names") + + +if __name__ == "__main__": + main() diff --git a/crates/exav-pe-emu/src/cpu.rs b/crates/exav-pe-emu/src/cpu.rs index f060be5..cdce6a5 100644 --- a/crates/exav-pe-emu/src/cpu.rs +++ b/crates/exav-pe-emu/src/cpu.rs @@ -1559,7 +1559,8 @@ impl Cpu { }; self.cf = r & 1 != 0; if count == 1 { - self.of = ((r ^ (r << 1)) & sign_bit(size)) != 0; + // MSB of the result xor the bit rotated into CF. + self.of = (r & sign_bit(size) != 0) != self.cf; } r } @@ -1592,7 +1593,16 @@ impl Cpu { } } self.cf = (acc >> bits) & 1 != 0; - (acc as u32) & mask(size) + let r = (acc as u32) & mask(size); + if count == 1 { + let msb = r & sign_bit(size) != 0; + self.of = if m == Rcl { + msb != self.cf + } else { + msb != (r & (sign_bit(size) >> 1) != 0) + }; + } + r } _ => unreachable!("shift dispatch"), }; @@ -2442,6 +2452,30 @@ mod tests { assert!(cpu.cf); } + #[test] + fn rotate_by_one_sets_overflow_from_the_result() { + // OF after a 1-bit rotate is MSB(result) xor CF-bit-rotated. A tElock + // decryptor branches on it; getting it wrong sent the stub into an + // `EB FE` self-loop. Values checked against a reference x86. + // rol: 0x80000000 rol 1 -> CF=1, result MSB=0, so OF = 1 ^ 0 = 1. + let (cpu, _) = run(&[0xb8, 0, 0, 0, 0x80, 0xd1, 0xc0], |_| {}); + assert_eq!(cpu.regs[EAX], 1); + assert!(cpu.cf && cpu.of, "rol: CF=1, OF=1"); + // rol 0x40000000 by 1 -> 0x80000000, CF=0, MSB=1, OF = 1 ^ 0 = 1. + let (cpu, _) = run(&[0xb8, 0, 0, 0, 0x40, 0xd1, 0xc0], |_| {}); + assert_eq!(cpu.regs[EAX], 0x8000_0000); + assert!(!cpu.cf && cpu.of, "rol: CF=0, OF=1"); + // ror 1 of odd value: 0x00000001 -> 0x80000000, CF=1, top two bits + // 10, so OF = MSB xor next-bit = 1 ^ 0 = 1. + let (cpu, _) = run(&[0xb8, 1, 0, 0, 0, 0xd1, 0xc8], |_| {}); + assert_eq!(cpu.regs[EAX], 0x8000_0000); + assert!(cpu.cf && cpu.of, "ror: CF=1, OF=1"); + // rcr 1 with CF=0 of 1 -> 0, CF out = 1, both top bits 0, OF = 0. + let (cpu, _) = run(&[0xb8, 1, 0, 0, 0, 0xd1, 0xd8], |c| c.cf = false); + assert_eq!(cpu.regs[EAX], 0); + assert!(cpu.cf && !cpu.of, "rcr: CF=1, OF=0"); + } + #[test] fn mul_and_div_roundtrip() { // b8 00 00 10 00 mov eax, 0x100000 diff --git a/crates/exav-pe-emu/src/exports/advapi32.txt b/crates/exav-pe-emu/src/exports/advapi32.txt new file mode 100644 index 0000000..907e3c8 --- /dev/null +++ b/crates/exav-pe-emu/src/exports/advapi32.txt @@ -0,0 +1,541 @@ +AbortSystemShutdownA +AbortSystemShutdownW +AccessCheck +AccessCheckAndAuditAlarmA +AccessCheckAndAuditAlarmW +AccessCheckByType +AccessCheckByTypeAndAuditAlarmA +AccessCheckByTypeAndAuditAlarmW +AccessCheckByTypeResultList +AccessCheckByTypeResultListAndAuditAlarmA +AccessCheckByTypeResultListAndAuditAlarmByHandleA +AccessCheckByTypeResultListAndAuditAlarmByHandleW +AccessCheckByTypeResultListAndAuditAlarmW +AddAccessAllowedAce +AddAccessAllowedAceEx +AddAccessAllowedObjectAce +AddAccessDeniedAce +AddAccessDeniedAceEx +AddAccessDeniedObjectAce +AddAce +AddAuditAccessAce +AddAuditAccessAceEx +AddAuditAccessObjectAce +AddConditionalAce +AddMandatoryAce +AddUsersToEncryptedFile +AdjustTokenGroups +AdjustTokenPrivileges +AllocateAndInitializeSid +AllocateLocallyUniqueId +AreAllAccessesGranted +AreAnyAccessesGranted +AuditComputeEffectivePolicyBySid +AuditComputeEffectivePolicyByToken +AuditEnumerateCategories +AuditEnumeratePerUserPolicy +AuditEnumerateSubCategories +AuditFree +AuditLookupCategoryGuidFromCategoryId +AuditLookupCategoryIdFromCategoryGuid +AuditLookupCategoryNameA +AuditLookupCategoryNameW +AuditLookupSubCategoryNameA +AuditLookupSubCategoryNameW +AuditQueryGlobalSaclA +AuditQueryGlobalSaclW +AuditQueryPerUserPolicy +AuditQuerySecurity +AuditQuerySystemPolicy +AuditSetGlobalSaclA +AuditSetGlobalSaclW +AuditSetPerUserPolicy +AuditSetSecurity +AuditSetSystemPolicy +BackupEventLogA +BackupEventLogW +BuildExplicitAccessWithNameA +BuildExplicitAccessWithNameW +BuildSecurityDescriptorA +BuildSecurityDescriptorW +BuildTrusteeWithNameA +BuildTrusteeWithNameW +BuildTrusteeWithObjectsAndNameA +BuildTrusteeWithObjectsAndNameW +BuildTrusteeWithObjectsAndSidA +BuildTrusteeWithObjectsAndSidW +BuildTrusteeWithSidA +BuildTrusteeWithSidW +ChangeServiceConfig2A +ChangeServiceConfig2W +ChangeServiceConfigA +ChangeServiceConfigW +CheckTokenMembership +ClearEventLogA +ClearEventLogW +CloseEncryptedFileRaw +CloseEventLog +CloseServiceHandle +CloseThreadWaitChainSession +ControlService +ControlServiceExA +ControlServiceExW +ConvertSecurityDescriptorToStringSecurityDescriptorA +ConvertSecurityDescriptorToStringSecurityDescriptorW +ConvertSidToStringSidA +ConvertSidToStringSidW +ConvertStringSecurityDescriptorToSecurityDescriptorA +ConvertStringSecurityDescriptorToSecurityDescriptorW +ConvertStringSidToSidA +ConvertStringSidToSidW +ConvertToAutoInheritPrivateObjectSecurity +CopySid +CreatePrivateObjectSecurity +CreatePrivateObjectSecurityEx +CreatePrivateObjectSecurityWithMultipleInheritance +CreateProcessAsUserA +CreateProcessAsUserW +CreateProcessWithLogonW +CreateProcessWithTokenW +CreateRestrictedToken +CreateServiceA +CreateServiceW +CreateWellKnownSid +CredDeleteA +CredDeleteW +CredEnumerateA +CredEnumerateW +CredFindBestCredentialA +CredFindBestCredentialW +CredFree +CredGetSessionTypes +CredGetTargetInfoA +CredGetTargetInfoW +CredIsMarshaledCredentialA +CredIsMarshaledCredentialW +CredIsProtectedA +CredIsProtectedW +CredMarshalCredentialA +CredMarshalCredentialW +CredMarshalTargetInfo +CredProtectA +CredProtectW +CredReadA +CredReadDomainCredentialsA +CredReadDomainCredentialsW +CredReadW +CredRenameA +CredRenameW +CredUnmarshalCredentialA +CredUnmarshalCredentialW +CredUnprotectA +CredUnprotectW +CredWriteA +CredWriteDomainCredentialsA +CredWriteDomainCredentialsW +CredWriteW +CryptAcquireContextA +CryptAcquireContextW +CryptContextAddRef +CryptCreateHash +CryptDecrypt +CryptDeriveKey +CryptDestroyHash +CryptDestroyKey +CryptDuplicateHash +CryptDuplicateKey +CryptEncrypt +CryptEnumProviderTypesA +CryptEnumProviderTypesW +CryptEnumProvidersA +CryptEnumProvidersW +CryptExportKey +CryptGenKey +CryptGenRandom +CryptGetDefaultProviderA +CryptGetDefaultProviderW +CryptGetHashParam +CryptGetKeyParam +CryptGetProvParam +CryptGetUserKey +CryptHashData +CryptHashSessionKey +CryptImportKey +CryptReleaseContext +CryptSetHashParam +CryptSetKeyParam +CryptSetProvParam +CryptSetProviderA +CryptSetProviderExA +CryptSetProviderExW +CryptSetProviderW +CryptSignHashA +CryptSignHashW +CryptVerifySignatureA +CryptVerifySignatureW +CveEventWrite +DecryptFileA +DecryptFileW +DeleteAce +DeleteService +DeregisterEventSource +DestroyPrivateObjectSecurity +DuplicateEncryptionInfoFile +DuplicateToken +DuplicateTokenEx +EncryptFileA +EncryptFileW +EncryptionDisable +EnumDependentServicesA +EnumDependentServicesW +EnumDynamicTimeZoneInformation +EnumServicesStatusA +EnumServicesStatusExA +EnumServicesStatusExW +EnumServicesStatusW +EqualDomainSid +EqualPrefixSid +EqualSid +EventAccessControl +EventAccessQuery +EventAccessRemove +FileEncryptionStatusA +FileEncryptionStatusW +FindFirstFreeAce +FreeEncryptionCertificateHashList +FreeInheritedFromArray +FreeSid +GetAce +GetAclInformation +GetAuditedPermissionsFromAclA +GetAuditedPermissionsFromAclW +GetCurrentHwProfileA +GetCurrentHwProfileW +GetDynamicTimeZoneInformationEffectiveYears +GetEffectiveRightsFromAclA +GetEffectiveRightsFromAclW +GetEventLogInformation +GetExplicitEntriesFromAclA +GetExplicitEntriesFromAclW +GetFileSecurityA +GetFileSecurityW +GetInheritanceSourceA +GetInheritanceSourceW +GetKernelObjectSecurity +GetLengthSid +GetLocalManagedApplications +GetManagedApplicationCategories +GetManagedApplications +GetNamedSecurityInfoA +GetNamedSecurityInfoW +GetNumberOfEventLogRecords +GetOldestEventLogRecord +GetPrivateObjectSecurity +GetSecurityDescriptorControl +GetSecurityDescriptorDacl +GetSecurityDescriptorGroup +GetSecurityDescriptorLength +GetSecurityDescriptorOwner +GetSecurityDescriptorRMControl +GetSecurityDescriptorSacl +GetSecurityInfo +GetServiceDisplayNameA +GetServiceDisplayNameW +GetServiceKeyNameA +GetServiceKeyNameW +GetSidIdentifierAuthority +GetSidLengthRequired +GetSidSubAuthority +GetSidSubAuthorityCount +GetThreadWaitChain +GetTokenInformation +GetTrusteeFormA +GetTrusteeFormW +GetTrusteeNameA +GetTrusteeNameW +GetTrusteeTypeA +GetTrusteeTypeW +GetUserNameA +GetUserNameW +GetWindowsAccountDomainSid +ImpersonateAnonymousToken +ImpersonateLoggedOnUser +ImpersonateNamedPipeClient +ImpersonateSelf +InitializeAcl +InitializeSecurityDescriptor +InitializeSid +InitiateShutdownA +InitiateShutdownW +InitiateSystemShutdownA +InitiateSystemShutdownExA +InitiateSystemShutdownExW +InitiateSystemShutdownW +InstallApplication +IsTextUnicode +IsTokenRestricted +IsValidAcl +IsValidSecurityDescriptor +IsValidSid +IsWellKnownSid +LockServiceDatabase +LogonUserA +LogonUserExA +LogonUserExW +LogonUserW +LookupAccountNameA +LookupAccountNameW +LookupAccountSidA +LookupAccountSidW +LookupPrivilegeDisplayNameA +LookupPrivilegeDisplayNameW +LookupPrivilegeNameA +LookupPrivilegeNameW +LookupPrivilegeValueA +LookupPrivilegeValueW +LookupSecurityDescriptorPartsA +LookupSecurityDescriptorPartsW +LsaAddAccountRights +LsaClose +LsaCreateTrustedDomainEx +LsaDeleteTrustedDomain +LsaEnumerateAccountRights +LsaEnumerateAccountsWithUserRight +LsaEnumerateTrustedDomains +LsaEnumerateTrustedDomainsEx +LsaFreeMemory +LsaGetAppliedCAPIDs +LsaLookupNames +LsaLookupNames2 +LsaLookupPrivilegeValue +LsaLookupSids +LsaLookupSids2 +LsaNtStatusToWinError +LsaOpenPolicy +LsaOpenTrustedDomainByName +LsaQueryCAPs +LsaQueryDomainInformationPolicy +LsaQueryForestTrustInformation +LsaQueryInformationPolicy +LsaQueryTrustedDomainInfo +LsaQueryTrustedDomainInfoByName +LsaRemoveAccountRights +LsaRetrievePrivateData +LsaSetDomainInformationPolicy +LsaSetForestTrustInformation +LsaSetInformationPolicy +LsaSetTrustedDomainInfoByName +LsaSetTrustedDomainInformation +LsaStorePrivateData +MSChapSrvChangePassword +MSChapSrvChangePassword2 +MakeAbsoluteSD +MakeSelfRelativeSD +MapGenericMask +NotifyBootConfigStatus +NotifyChangeEventLog +NotifyServiceStatusChangeA +NotifyServiceStatusChangeW +ObjectCloseAuditAlarmA +ObjectCloseAuditAlarmW +ObjectDeleteAuditAlarmA +ObjectDeleteAuditAlarmW +ObjectOpenAuditAlarmA +ObjectOpenAuditAlarmW +ObjectPrivilegeAuditAlarmA +ObjectPrivilegeAuditAlarmW +OpenBackupEventLogA +OpenBackupEventLogW +OpenEncryptedFileRawA +OpenEncryptedFileRawW +OpenEventLogA +OpenEventLogW +OpenProcessToken +OpenSCManagerA +OpenSCManagerW +OpenServiceA +OpenServiceW +OpenThreadToken +OpenThreadWaitChainSession +OperationEnd +OperationStart +PerfAddCounters +PerfCloseQueryHandle +PerfCreateInstance +PerfDecrementULongCounterValue +PerfDecrementULongLongCounterValue +PerfDeleteCounters +PerfDeleteInstance +PerfEnumerateCounterSet +PerfEnumerateCounterSetInstances +PerfIncrementULongCounterValue +PerfIncrementULongLongCounterValue +PerfOpenQueryHandle +PerfQueryCounterData +PerfQueryCounterInfo +PerfQueryCounterSetRegistrationInfo +PerfQueryInstance +PerfSetCounterRefValue +PerfSetCounterSetInfo +PerfSetULongCounterValue +PerfSetULongLongCounterValue +PerfStartProvider +PerfStartProviderEx +PerfStopProvider +PrivilegeCheck +PrivilegedServiceAuditAlarmA +PrivilegedServiceAuditAlarmW +QueryRecoveryAgentsOnEncryptedFile +QuerySecurityAccessMask +QueryServiceConfig2A +QueryServiceConfig2W +QueryServiceConfigA +QueryServiceConfigW +QueryServiceDynamicInformation +QueryServiceLockStatusA +QueryServiceLockStatusW +QueryServiceObjectSecurity +QueryServiceStatus +QueryServiceStatusEx +QueryUsersOnEncryptedFile +ReadEncryptedFileRaw +ReadEventLogA +ReadEventLogW +RegCloseKey +RegConnectRegistryA +RegConnectRegistryW +RegCopyTreeA +RegCopyTreeW +RegCreateKeyA +RegCreateKeyExA +RegCreateKeyExW +RegCreateKeyTransactedA +RegCreateKeyTransactedW +RegCreateKeyW +RegDeleteKeyA +RegDeleteKeyExA +RegDeleteKeyExW +RegDeleteKeyTransactedA +RegDeleteKeyTransactedW +RegDeleteKeyValueA +RegDeleteKeyValueW +RegDeleteKeyW +RegDeleteTreeA +RegDeleteTreeW +RegDeleteValueA +RegDeleteValueW +RegDisablePredefinedCache +RegDisablePredefinedCacheEx +RegDisableReflectionKey +RegEnableReflectionKey +RegEnumKeyA +RegEnumKeyExA +RegEnumKeyExW +RegEnumKeyW +RegEnumValueA +RegEnumValueW +RegFlushKey +RegGetKeySecurity +RegGetValueA +RegGetValueW +RegLoadAppKeyA +RegLoadAppKeyW +RegLoadKeyA +RegLoadKeyW +RegLoadMUIStringA +RegLoadMUIStringW +RegNotifyChangeKeyValue +RegOpenCurrentUser +RegOpenKeyA +RegOpenKeyExA +RegOpenKeyExW +RegOpenKeyTransactedA +RegOpenKeyTransactedW +RegOpenKeyW +RegOpenUserClassesRoot +RegOverridePredefKey +RegQueryInfoKeyA +RegQueryInfoKeyW +RegQueryMultipleValuesA +RegQueryMultipleValuesW +RegQueryReflectionKey +RegQueryValueA +RegQueryValueExA +RegQueryValueExW +RegQueryValueW +RegReplaceKeyA +RegReplaceKeyW +RegRestoreKeyA +RegRestoreKeyW +RegSaveKeyA +RegSaveKeyExA +RegSaveKeyExW +RegSaveKeyW +RegSetKeySecurity +RegSetKeyValueA +RegSetKeyValueW +RegSetValueA +RegSetValueExA +RegSetValueExW +RegSetValueW +RegUnLoadKeyA +RegUnLoadKeyW +RegisterEventSourceA +RegisterEventSourceW +RegisterServiceCtrlHandlerA +RegisterServiceCtrlHandlerExA +RegisterServiceCtrlHandlerExW +RegisterServiceCtrlHandlerW +RegisterWaitChainCOMCallback +RemoveUsersFromEncryptedFile +ReportEventA +ReportEventW +RevertToSelf +RtlDecryptMemory +RtlEncryptMemory +RtlGenRandom +SaferCloseLevel +SaferComputeTokenFromLevel +SaferCreateLevel +SaferGetLevelInformation +SaferGetPolicyInformation +SaferIdentifyLevel +SaferRecordEventLogEntry +SaferSetLevelInformation +SaferSetPolicyInformation +SaferiIsExecutableFileType +SetAclInformation +SetEntriesInAclA +SetEntriesInAclW +SetFileSecurityA +SetFileSecurityW +SetKernelObjectSecurity +SetNamedSecurityInfoA +SetNamedSecurityInfoW +SetPrivateObjectSecurity +SetPrivateObjectSecurityEx +SetSecurityAccessMask +SetSecurityDescriptorControl +SetSecurityDescriptorDacl +SetSecurityDescriptorGroup +SetSecurityDescriptorOwner +SetSecurityDescriptorRMControl +SetSecurityDescriptorSacl +SetSecurityInfo +SetServiceBits +SetServiceObjectSecurity +SetServiceStatus +SetThreadToken +SetTokenInformation +SetUserFileEncryptionKey +StartServiceA +StartServiceCtrlDispatcherA +StartServiceCtrlDispatcherW +StartServiceW +TreeResetNamedSecurityInfoA +TreeResetNamedSecurityInfoW +TreeSetNamedSecurityInfoA +TreeSetNamedSecurityInfoW +UninstallApplication +UnlockServiceDatabase +WriteEncryptedFileRaw diff --git a/crates/exav-pe-emu/src/exports/kernel32.txt b/crates/exav-pe-emu/src/exports/kernel32.txt new file mode 100644 index 0000000..4c5c232 --- /dev/null +++ b/crates/exav-pe-emu/src/exports/kernel32.txt @@ -0,0 +1,1267 @@ +AcquireSRWLockExclusive +AcquireSRWLockShared +ActivateActCtx +AddAtomA +AddAtomW +AddDllDirectory +AddIntegrityLabelToBoundaryDescriptor +AddRefActCtx +AddResourceAttributeAce +AddSIDToBoundaryDescriptor +AddScopedPolicyIDAce +AddSecureMemoryCacheCallback +AddVectoredContinueHandler +AddVectoredExceptionHandler +AllocateUserPhysicalPages +AllocateUserPhysicalPagesNuma +AppPolicyGetCreateFileAccess +AppPolicyGetProcessTerminationMethod +AppPolicyGetShowDeveloperDiagnostic +AppPolicyGetThreadInitializationType +AppPolicyGetWindowingModel +ApplicationRecoveryFinished +ApplicationRecoveryInProgress +AreFileApisANSI +AssignProcessToJobObject +BackupRead +BackupSeek +BackupWrite +Beep +BeginUpdateResourceA +BeginUpdateResourceW +BindIoCompletionCallback +BuildCommDCBA +BuildCommDCBAndTimeoutsA +BuildCommDCBAndTimeoutsW +BuildCommDCBW +CallEnclave +CallNamedPipeA +CallNamedPipeW +CallbackMayRunLong +CancelIo +CancelIoEx +CancelSynchronousIo +CancelThreadpoolIo +CancelWaitableTimer +CeipIsOptedIn +ChangeTimerQueueTimer +CheckNameLegalDOS8Dot3A +CheckNameLegalDOS8Dot3W +CheckRemoteDebuggerPresent +CheckTokenCapability +CheckTokenMembershipEx +ClearCommBreak +ClearCommError +CloseHandle +ClosePackageInfo +ClosePrivateNamespace +CloseThreadpool +CloseThreadpoolCleanupGroup +CloseThreadpoolCleanupGroupMembers +CloseThreadpoolIo +CloseThreadpoolTimer +CloseThreadpoolWait +CloseThreadpoolWork +CommConfigDialogA +CommConfigDialogW +CompareFileTime +CompareObjectHandles +CompareString +CompareStringA +CompareStringEx +CompareStringOrdinal +CompareStringW +ConnectNamedPipe +ContinueDebugEvent +ConvertAuxiliaryCounterToPerformanceCounter +ConvertDefaultLocale +ConvertFiberToThread +ConvertPerformanceCounterToAuxiliaryCounter +ConvertThreadToFiber +ConvertThreadToFiberEx +CopyContext +CopyFile +CopyFile2 +CopyFileA +CopyFileExA +CopyFileExW +CopyFileTransactedA +CopyFileTransactedW +CopyFileW +CreateActCtxA +CreateActCtxW +CreateBoundaryDescriptorA +CreateBoundaryDescriptorW +CreateDirectory +CreateDirectoryA +CreateDirectoryExA +CreateDirectoryExW +CreateDirectoryTransactedA +CreateDirectoryTransactedW +CreateDirectoryW +CreateEnclave +CreateEventA +CreateEventExA +CreateEventExW +CreateEventW +CreateFiber +CreateFiberEx +CreateFile2 +CreateFileA +CreateFileMapping2 +CreateFileMappingA +CreateFileMappingFromApp +CreateFileMappingNumaA +CreateFileMappingNumaW +CreateFileMappingW +CreateFileTransactedA +CreateFileTransactedW +CreateFileW +CreateHardLinkA +CreateHardLinkTransactedA +CreateHardLinkTransactedW +CreateHardLinkW +CreateIoCompletionPort +CreateJobObjectA +CreateJobObjectW +CreateMailslotA +CreateMailslotW +CreateMemoryResourceNotification +CreateMutexA +CreateMutexExA +CreateMutexExW +CreateMutexW +CreateNamedPipeA +CreateNamedPipeW +CreatePipe +CreatePrivateNamespaceA +CreatePrivateNamespaceW +CreateProcessA +CreateProcessAsUserA +CreateProcessAsUserW +CreateProcessW +CreateRemoteThread +CreateRemoteThreadEx +CreateSemaphoreA +CreateSemaphoreExA +CreateSemaphoreExW +CreateSemaphoreW +CreateSymbolicLinkA +CreateSymbolicLinkTransactedA +CreateSymbolicLinkTransactedW +CreateSymbolicLinkW +CreateTapePartition +CreateThread +CreateThreadpool +CreateThreadpoolCleanupGroup +CreateThreadpoolIo +CreateThreadpoolTimer +CreateThreadpoolWait +CreateThreadpoolWork +CreateTimerQueue +CreateTimerQueueTimer +CreateToolhelp32Snapshot +CreateUmsCompletionList +CreateUmsThreadContext +CreateWaitableTimerExW +CreateWaitableTimerW +DeactivateActCtx +DebugActiveProcess +DebugActiveProcessStop +DebugBreak +DebugBreakProcess +DebugSetProcessKillOnExit +DefineDosDeviceA +DefineDosDeviceW +DeleteAtom +DeleteBoundaryDescriptor +DeleteCriticalSection +DeleteEnclave +DeleteFiber +DeleteFile +DeleteFileA +DeleteFileTransactedA +DeleteFileTransactedW +DeleteFileW +DeleteProcThreadAttributeList +DeleteSynchronizationBarrier +DeleteTimerQueue +DeleteTimerQueueEx +DeleteTimerQueueTimer +DeleteUmsCompletionList +DeleteUmsThreadContext +DeleteVolumeMountPointA +DeleteVolumeMountPointW +DequeueUmsCompletionListItems +DeviceIoControl +DisableThreadLibraryCalls +DisableThreadProfiling +DisassociateCurrentThreadFromCallback +DiscardVirtualMemory +DisconnectNamedPipe +DnsHostnameToComputerNameA +DnsHostnameToComputerNameW +DosDateTimeToFileTime +DuplicateHandle +EmptyWorkingSet +EnableThreadProfiling +EndUpdateResourceA +EndUpdateResourceW +EnterCriticalSection +EnterSynchronizationBarrier +EnterUmsSchedulingMode +EnumCalendarInfoA +EnumCalendarInfoExA +EnumCalendarInfoExEx +EnumCalendarInfoExW +EnumCalendarInfoW +EnumDateFormatsA +EnumDateFormatsExA +EnumDateFormatsExEx +EnumDateFormatsExW +EnumDateFormatsW +EnumDeviceDrivers +EnumDynamicTimeZoneInformation +EnumLanguageGroupLocalesA +EnumLanguageGroupLocalesW +EnumPageFilesA +EnumPageFilesW +EnumProcessModules +EnumProcessModulesEx +EnumProcesses +EnumResourceLanguagesA +EnumResourceLanguagesExA +EnumResourceLanguagesExW +EnumResourceLanguagesW +EnumResourceNamesA +EnumResourceNamesExA +EnumResourceNamesExW +EnumResourceNamesW +EnumResourceTypesA +EnumResourceTypesExA +EnumResourceTypesExW +EnumResourceTypesW +EnumSystemCodePagesA +EnumSystemCodePagesW +EnumSystemFirmwareTables +EnumSystemGeoID +EnumSystemGeoNames +EnumSystemLanguageGroupsA +EnumSystemLanguageGroupsW +EnumSystemLocalesA +EnumSystemLocalesEx +EnumSystemLocalesW +EnumTimeFormatsA +EnumTimeFormatsEx +EnumTimeFormatsW +EnumUILanguagesA +EnumUILanguagesW +EraseTape +EscapeCommFunction +ExecuteUmsThread +ExitProcess +ExitThread +ExpandEnvironmentStringsA +ExpandEnvironmentStringsW +FatalAppExitA +FatalAppExitW +FatalExit +FileTimeToDosDateTime +FileTimeToLocalFileTime +FileTimeToSystemTime +FindActCtxSectionGuid +FindActCtxSectionStringA +FindActCtxSectionStringW +FindAtomA +FindAtomW +FindClose +FindCloseChangeNotification +FindFirstChangeNotificationA +FindFirstChangeNotificationW +FindFirstFileA +FindFirstFileExA +FindFirstFileExW +FindFirstFileNameTransactedW +FindFirstFileNameW +FindFirstFileTransactedA +FindFirstFileTransactedW +FindFirstFileW +FindFirstStreamTransactedW +FindFirstStreamW +FindFirstVolumeA +FindFirstVolumeMountPointA +FindFirstVolumeMountPointW +FindFirstVolumeW +FindNLSString +FindNLSStringEx +FindNextChangeNotification +FindNextFileA +FindNextFileNameW +FindNextFileW +FindNextStreamW +FindNextVolumeA +FindNextVolumeMountPointA +FindNextVolumeMountPointW +FindNextVolumeW +FindPackagesByPackageFamily +FindResourceA +FindResourceExA +FindResourceExW +FindResourceW +FindStringOrdinal +FindVolumeClose +FindVolumeMountPointClose +FlsAlloc +FlsFree +FlsGetValue +FlsSetValue +FlushFileBuffers +FlushInstructionCache +FlushProcessWriteBuffers +FlushViewOfFile +FoldStringA +FoldStringW +FormatApplicationUserModelId +FormatMessage +FormatMessageA +FormatMessageW +FreeEnvironmentStringsA +FreeEnvironmentStringsW +FreeLibrary +FreeLibraryAndExitThread +FreeLibraryWhenCallbackReturns +FreeMemoryJobObject +FreeResource +FreeUserPhysicalPages +GetACP +GetActiveProcessorCount +GetActiveProcessorGroupCount +GetAppContainerNamedObjectPath +GetApplicationRecoveryCallback +GetApplicationRestartSettings +GetApplicationUserModelId +GetApplicationUserModelIdFromToken +GetAtomNameA +GetAtomNameW +GetBinaryTypeA +GetBinaryTypeW +GetCPInfo +GetCPInfoExA +GetCPInfoExW +GetCalendarInfoA +GetCalendarInfoEx +GetCalendarInfoW +GetCommConfig +GetCommMask +GetCommModemStatus +GetCommProperties +GetCommState +GetCommTimeouts +GetCommandLineA +GetCommandLineW +GetCompressedFileSizeA +GetCompressedFileSizeTransactedA +GetCompressedFileSizeTransactedW +GetCompressedFileSizeW +GetComputerNameA +GetComputerNameExA +GetComputerNameExW +GetComputerNameW +GetCurrencyFormatA +GetCurrencyFormatEx +GetCurrencyFormatW +GetCurrentActCtx +GetCurrentApplicationUserModelId +GetCurrentDirectory +GetCurrentPackageFamilyName +GetCurrentPackageFullName +GetCurrentPackageId +GetCurrentPackageInfo +GetCurrentPackagePath +GetCurrentProcess +GetCurrentProcessId +GetCurrentProcessorNumber +GetCurrentProcessorNumberEx +GetCurrentThread +GetCurrentThreadId +GetCurrentThreadStackLimits +GetCurrentUmsThread +GetDateFormatA +GetDateFormatEx +GetDateFormatW +GetDefaultCommConfigA +GetDefaultCommConfigW +GetDeviceDriverBaseNameA +GetDeviceDriverBaseNameW +GetDeviceDriverFileNameA +GetDeviceDriverFileNameW +GetDevicePowerState +GetDiskFreeSpaceA +GetDiskFreeSpaceExA +GetDiskFreeSpaceExW +GetDiskFreeSpaceW +GetDiskSpaceInformationA +GetDiskSpaceInformationW +GetDllDirectoryA +GetDllDirectoryW +GetDriveTypeA +GetDriveTypeW +GetDurationFormat +GetDurationFormatEx +GetDynamicTimeZoneInformation +GetDynamicTimeZoneInformationEffectiveYears +GetEnabledXStateFeatures +GetEnvironmentStrings +GetEnvironmentStringsW +GetEnvironmentVariable +GetEnvironmentVariableA +GetEnvironmentVariableW +GetErrorMode +GetExitCodeProcess +GetExitCodeThread +GetFileAttributesA +GetFileAttributesExA +GetFileAttributesExW +GetFileAttributesTransactedA +GetFileAttributesTransactedW +GetFileAttributesW +GetFileBandwidthReservation +GetFileInformationByHandle +GetFileInformationByHandleEx +GetFileMUIInfo +GetFileMUIPath +GetFileSize +GetFileSizeEx +GetFileTime +GetFileType +GetFinalPathNameByHandleA +GetFinalPathNameByHandleW +GetFirmwareEnvironmentVariableA +GetFirmwareEnvironmentVariableExA +GetFirmwareEnvironmentVariableExW +GetFirmwareEnvironmentVariableW +GetFirmwareType +GetFullPathNameA +GetFullPathNameTransactedA +GetFullPathNameTransactedW +GetFullPathNameW +GetGeoInfoA +GetGeoInfoEx +GetGeoInfoW +GetHandleInformation +GetLargePageMinimum +GetLastError +GetLocalTime +GetLocaleInfoA +GetLocaleInfoEx +GetLocaleInfoW +GetLogicalDriveStringsA +GetLogicalDriveStringsW +GetLogicalDrives +GetLogicalProcessorInformation +GetLogicalProcessorInformationEx +GetLongPathNameA +GetLongPathNameTransactedA +GetLongPathNameTransactedW +GetLongPathNameW +GetMachineTypeAttributes +GetMailslotInfo +GetMappedFileNameA +GetMappedFileNameW +GetMaximumProcessorCount +GetMaximumProcessorGroupCount +GetMemoryErrorHandlingCapabilities +GetModuleBaseNameA +GetModuleBaseNameW +GetModuleFileNameA +GetModuleFileNameExA +GetModuleFileNameExW +GetModuleFileNameW +GetModuleHandleA +GetModuleHandleExA +GetModuleHandleExW +GetModuleHandleW +GetModuleInformation +GetNLSVersion +GetNLSVersionEx +GetNamedPipeClientComputerNameA +GetNamedPipeClientComputerNameW +GetNamedPipeClientProcessId +GetNamedPipeClientSessionId +GetNamedPipeHandleStateA +GetNamedPipeHandleStateW +GetNamedPipeInfo +GetNamedPipeServerProcessId +GetNamedPipeServerSessionId +GetNativeSystemInfo +GetNextUmsListItem +GetNumaAvailableMemoryNode +GetNumaAvailableMemoryNodeEx +GetNumaHighestNodeNumber +GetNumaNodeNumberFromHandle +GetNumaNodeProcessorMask +GetNumaNodeProcessorMaskEx +GetNumaProcessorNode +GetNumaProcessorNodeEx +GetNumaProximityNode +GetNumaProximityNodeEx +GetNumberFormatA +GetNumberFormatEx +GetNumberFormatW +GetOEMCP +GetOverlappedResult +GetOverlappedResultEx +GetPackageApplicationIds +GetPackageFamilyName +GetPackageFamilyNameFromToken +GetPackageFullName +GetPackageFullNameFromToken +GetPackageId +GetPackageInfo +GetPackagePath +GetPackagePathByFullName +GetPackagesByPackageFamily +GetPerformanceInfo +GetPhysicallyInstalledSystemMemory +GetPriorityClass +GetPrivateProfileInt +GetPrivateProfileIntA +GetPrivateProfileIntW +GetPrivateProfileSection +GetPrivateProfileSectionA +GetPrivateProfileSectionNames +GetPrivateProfileSectionNamesA +GetPrivateProfileSectionNamesW +GetPrivateProfileSectionW +GetPrivateProfileString +GetPrivateProfileStringA +GetPrivateProfileStringW +GetPrivateProfileStruct +GetPrivateProfileStructA +GetPrivateProfileStructW +GetProcAddress +GetProcessAffinityMask +GetProcessDEPPolicy +GetProcessDefaultCpuSetMasks +GetProcessGroupAffinity +GetProcessHandleCount +GetProcessHeap +GetProcessHeaps +GetProcessId +GetProcessIdOfThread +GetProcessImageFileNameA +GetProcessImageFileNameW +GetProcessInformation +GetProcessIoCounters +GetProcessMemoryInfo +GetProcessMitigationPolicy +GetProcessPreferredUILanguages +GetProcessPriorityBoost +GetProcessShutdownParameters +GetProcessTimes +GetProcessVersion +GetProcessWorkingSetSize +GetProcessWorkingSetSizeEx +GetProcessorSystemCycleTime +GetProductInfo +GetProfileIntA +GetProfileIntW +GetProfileSectionA +GetProfileSectionW +GetProfileStringA +GetProfileStringW +GetQueuedCompletionStatus +GetQueuedCompletionStatusEx +GetShortPathNameA +GetShortPathNameW +GetStagedPackageOrigin +GetStagedPackagePathByFullName +GetStartupInfoW +GetStringScripts +GetStringTypeA +GetStringTypeExW +GetStringTypeW +GetSystemDEPPolicy +GetSystemDefaultLCID +GetSystemDefaultLangID +GetSystemDefaultLocaleName +GetSystemDefaultUILanguage +GetSystemDirectoryA +GetSystemDirectoryW +GetSystemFileCacheSize +GetSystemFirmwareTable +GetSystemInfo +GetSystemPowerStatus +GetSystemPreferredUILanguages +GetSystemRegistryQuota +GetSystemTime +GetSystemTimeAdjustment +GetSystemTimeAsFileTime +GetSystemTimePreciseAsFileTime +GetSystemTimes +GetSystemWindowsDirectoryA +GetSystemWindowsDirectoryW +GetSystemWow64Directory2A +GetSystemWow64Directory2W +GetSystemWow64DirectoryA +GetSystemWow64DirectoryW +GetTapeParameters +GetTapePosition +GetTapeStatus +GetTempFileName +GetTempFileNameA +GetTempFileNameW +GetTempPath2A +GetTempPath2W +GetTempPathA +GetTempPathW +GetThreadContext +GetThreadDescription +GetThreadErrorMode +GetThreadGroupAffinity +GetThreadIOPendingFlag +GetThreadId +GetThreadIdealProcessorEx +GetThreadInformation +GetThreadLocale +GetThreadPreferredUILanguages +GetThreadPriority +GetThreadPriorityBoost +GetThreadSelectedCpuSetMasks +GetThreadSelectorEntry +GetThreadTimes +GetThreadUILanguage +GetTickCount +GetTickCount64 +GetTimeFormatA +GetTimeFormatEx +GetTimeFormatW +GetTimeZoneInformation +GetTimeZoneInformationForYear +GetUILanguageInfo +GetUmsCompletionListEvent +GetUmsSystemThreadInformation +GetUserDefaultGeoName +GetUserDefaultLCID +GetUserDefaultLangID +GetUserDefaultLocaleName +GetUserDefaultUILanguage +GetUserGeoID +GetUserPreferredUILanguages +GetVersion +GetVersionExA +GetVersionExW +GetVolumeInformationA +GetVolumeInformationByHandleW +GetVolumeInformationW +GetVolumeNameForVolumeMountPointA +GetVolumeNameForVolumeMountPointW +GetVolumePathNameA +GetVolumePathNameW +GetVolumePathNamesForVolumeNameA +GetVolumePathNamesForVolumeNameW +GetWindowsDirectoryA +GetWindowsDirectoryW +GetWriteWatch +GetWsChanges +GetWsChangesEx +GetXStateFeaturesMask +GlobalAddAtomA +GlobalAddAtomExA +GlobalAddAtomExW +GlobalAddAtomW +GlobalAlloc +GlobalDeleteAtom +GlobalFindAtomA +GlobalFindAtomW +GlobalFlags +GlobalFree +GlobalGetAtomNameA +GlobalGetAtomNameW +GlobalHandle +GlobalLock +GlobalMemoryStatus +GlobalMemoryStatusEx +GlobalReAlloc +GlobalSize +GlobalUnlock +Heap32First +Heap32ListFirst +Heap32ListNext +Heap32Next +HeapAlloc +HeapCompact +HeapCreate +HeapDestroy +HeapFree +HeapLock +HeapQueryInformation +HeapReAlloc +HeapSetInformation +HeapSize +HeapSummary +HeapUnlock +HeapValidate +HeapWalk +IdnToAscii +ImpersonateNamedPipeClient +InitAtomTable +InitOnceBeginInitialize +InitOnceComplete +InitOnceExecuteOnce +InitOnceInitialize +InitSecurityInterfaceA +InitSecurityInterfaceW +InitializeConditionVariable +InitializeContext +InitializeCriticalSection +InitializeCriticalSectionAndSpinCount +InitializeCriticalSectionEx +InitializeEnclave +InitializeProcThreadAttributeList +InitializeProcessForWsWatch +InitializeSListHead +InitializeSRWLock +InitializeSynchronizationBarrier +InstallELAMCertificateInfo +InterlockedCompareExchange +InterlockedCompareExchange64 +InterlockedDecrement +InterlockedExchange +InterlockedExchangeAdd +InterlockedFlushSList +InterlockedIncrement +InterlockedPopEntrySList +InterlockedPushEntrySList +InterlockedPushListSListEx +IsBadCodePtr +IsBadReadPtr +IsBadStringPtrA +IsBadStringPtrW +IsBadWritePtr +IsDBCSLeadByte +IsDBCSLeadByteEx +IsDebuggerPresent +IsEnclaveTypeSupported +IsNLSDefinedString +IsNativeVhdBoot +IsProcessCritical +IsProcessInJob +IsProcessorFeaturePresent +IsSystemResumeAutomatic +IsThreadAFiber +IsThreadpoolTimerSet +IsValidCodePage +IsValidLanguageGroup +IsValidLocale +IsValidLocaleName +IsValidNLSVersion +IsWow64GuestMachineSupported +IsWow64Process +IsWow64Process2 +LCIDToLocaleName +LCMapStringA +LCMapStringEx +LCMapStringW +LeaveCriticalSection +LeaveCriticalSectionWhenCallbackReturns +LoadEnclaveData +LoadEnclaveImageA +LoadEnclaveImageW +LoadLibraryA +LoadLibraryExA +LoadLibraryExW +LoadLibraryW +LoadModule +LoadPackagedLibrary +LoadResource +LoadStringA +LoadStringByReference +LoadStringW +LocalAlloc +LocalFileTimeToFileTime +LocalFlags +LocalFree +LocalHandle +LocalLock +LocalReAlloc +LocalSize +LocalUnlock +LocaleNameToLCID +LocateXStateFeature +LockFile +LockFileEx +LockResource +MapUserPhysicalPages +MapUserPhysicalPagesScatter +MapViewOfFile +MapViewOfFile2 +MapViewOfFile3 +MapViewOfFile3FromApp +MapViewOfFileEx +MapViewOfFileExNuma +MapViewOfFileFromApp +Module32First +Module32FirstW +Module32Next +Module32NextW +MoveFile +MoveFileA +MoveFileExA +MoveFileExW +MoveFileTransactedA +MoveFileTransactedW +MoveFileW +MoveFileWithProgressA +MoveFileWithProgressW +MulDiv +MultiByteToWideChar +NdisAllocateBuffer +NdisAllocateBufferPool +NdisAllocatePacketPoolEx +NdisCancelSendPackets +NdisCloseAdapter +NdisCopyFromPacketToPacketSafe +NdisDeregisterProtocol +NdisFreePacketPool +NdisGetReceivedPacket +NdisIMCopySendCompletePerPacketInfo +NdisIMCopySendPerPacketInfo +NdisIMDeregisterLayeredMiniport +NdisIMGetDeviceContext +NdisIMNotifyPnPEvent +NdisIMRegisterLayeredMiniport +NdisMDeregisterDevice +NdisMIndicateStatus +NdisMInitializeWrapper +NdisMRegisterDevice +NdisMRegisterUnloadHandler +NdisMSetAttributesEx +NdisOpenAdapter +NdisOpenProtocolConfiguration +NdisPacketPoolUsage +NdisRegisterProtocol +NdisRequest +NdisScheduleWorkItem +NdisTerminateWrapper +NdisUnchainBufferAtBack +NeedCurrentDirectoryForExePathA +NeedCurrentDirectoryForExePathW +NotifyUILanguageChange +OfferVirtualMemory +OpenEventA +OpenEventW +OpenFile +OpenFileById +OpenFileMappingA +OpenFileMappingFromApp +OpenFileMappingW +OpenJobObjectA +OpenJobObjectW +OpenMutexW +OpenPackageInfoByFullName +OpenPrivateNamespaceA +OpenPrivateNamespaceW +OpenProcess +OpenProcessToken +OpenSemaphoreW +OpenThread +OpenThreadToken +OpenWaitableTimerW +OutputDebugStringA +OutputDebugStringW +PackageFamilyNameFromFullName +PackageFamilyNameFromId +PackageFullNameFromId +PackageIdFromFullName +PackageNameAndPublisherIdFromFamilyName +ParseApplicationUserModelId +PeekNamedPipe +PostQueuedCompletionStatus +PowerClearRequest +PowerCreateRequest +PowerSetRequest +PrefetchVirtualMemory +PrepareTape +Process32First +Process32FirstW +Process32Next +Process32NextW +ProcessIdToSessionId +PssCaptureSnapshot +PssDuplicateSnapshot +PssFreeSnapshot +PssQuerySnapshot +PssWalkMarkerCreate +PssWalkMarkerFree +PssWalkMarkerGetPosition +PssWalkMarkerSeekToBeginning +PssWalkMarkerSetPosition +PssWalkSnapshot +PulseEvent +PurgeComm +QueryActCtxSettingsW +QueryActCtxW +QueryAuxiliaryCounterFrequency +QueryDepthSList +QueryDosDeviceA +QueryDosDeviceW +QueryFullProcessImageNameA +QueryFullProcessImageNameW +QueryIdleProcessorCycleTime +QueryIdleProcessorCycleTimeEx +QueryInformationJobObject +QueryInterruptTime +QueryInterruptTimePrecise +QueryIoRateControlInformationJobObject +QueryMemoryResourceNotification +QueryPerformanceCounter +QueryPerformanceFrequency +QueryProcessAffinityUpdateMode +QueryProcessCycleTime +QueryProtectedPolicy +QueryThreadCycleTime +QueryThreadProfiling +QueryThreadpoolStackInformation +QueryUmsThreadInformation +QueryUnbiasedInterruptTime +QueryUnbiasedInterruptTimePrecise +QueryWorkingSet +QueryWorkingSetEx +QueueUserAPC +QueueUserWorkItem +RaiseException +RaiseFailFastException +ReOpenFile +ReadDirectoryChangesExW +ReadDirectoryChangesW +ReadFile +ReadFileEx +ReadFileScatter +ReadProcessMemory +ReadThreadProfilingData +ReclaimVirtualMemory +RegCloseKey +RegDeleteKeyA +RegDeleteKeyExA +RegDeleteKeyExW +RegDeleteKeyValueA +RegDeleteTreeA +RegDeleteTreeW +RegEnumKeyExA +RegEnumValueA +RegNotifyChangeKeyValue +RegOpenKeyExA +RegQueryInfoKeyA +RegQueryValueExA +RegQueryValueExW +RegisterApplicationRecoveryCallback +RegisterApplicationRestart +RegisterBadMemoryNotification +RegisterWaitForSingleObject +RegisterWaitUntilOOBECompleted +ReleaseActCtx +ReleaseMutex +ReleaseMutexWhenCallbackReturns +ReleaseSRWLockExclusive +ReleaseSRWLockShared +ReleaseSemaphore +ReleaseSemaphoreWhenCallbackReturns +RemoveDirectoryA +RemoveDirectoryTransactedA +RemoveDirectoryTransactedW +RemoveDirectoryW +RemoveDllDirectory +RemoveSecureMemoryCacheCallback +RemoveVectoredContinueHandler +RemoveVectoredExceptionHandler +ReplaceFileA +ReplaceFileW +RequestWakeupLatency +ResetEvent +ResetWriteWatch +ResolveLocaleName +RestoreLastError +ResumeThread +RtlAddFunctionTable +RtlCaptureContext +RtlCompareMemory +RtlDeleteFunctionTable +RtlFillMemory +RtlInstallFunctionTableCallback +RtlLookupFunctionEntry +RtlPcToFileHeader +RtlRestoreContext +RtlUnwind +RtlUnwind2 +RtlUnwindEx +RtlVirtualUnwind +SearchPathA +SearchPathW +SetCalendarInfoA +SetCalendarInfoW +SetCommBreak +SetCommConfig +SetCommMask +SetCommState +SetCommTimeouts +SetComputerNameA +SetComputerNameExA +SetComputerNameExW +SetComputerNameW +SetCriticalSectionSpinCount +SetCurrentDirectory +SetDefaultCommConfigA +SetDefaultCommConfigW +SetDefaultDllDirectories +SetDllDirectoryA +SetDllDirectoryW +SetDynamicTimeZoneInformation +SetEndOfFile +SetEnvironmentStringsA +SetEnvironmentStringsW +SetEnvironmentVariable +SetEnvironmentVariableA +SetEnvironmentVariableW +SetErrorMode +SetEvent +SetEventWhenCallbackReturns +SetFileApisToANSI +SetFileApisToOEM +SetFileAttributesA +SetFileAttributesTransactedA +SetFileAttributesTransactedW +SetFileAttributesW +SetFileBandwidthReservation +SetFileCompletionNotificationModes +SetFileInformationByHandle +SetFileIoOverlappedRange +SetFilePointer +SetFilePointerEx +SetFileShortNameA +SetFileShortNameW +SetFileTime +SetFileValidData +SetFirmwareEnvironmentVariableA +SetFirmwareEnvironmentVariableExA +SetFirmwareEnvironmentVariableExW +SetFirmwareEnvironmentVariableW +SetHandleCount +SetHandleInformation +SetInformationJobObject +SetIoRateControlInformationJobObject +SetLastError +SetLocalTime +SetLocaleInfoA +SetLocaleInfoW +SetMailslotInfo +SetNamedPipeHandleState +SetPriorityClass +SetProcessAffinityMask +SetProcessAffinityUpdateMode +SetProcessDEPPolicy +SetProcessDefaultCpuSetMasks +SetProcessInformation +SetProcessMitigationPolicy +SetProcessPreferredUILanguages +SetProcessPriorityBoost +SetProcessShutdownParameters +SetProcessWorkingSetSize +SetProcessWorkingSetSizeEx +SetProtectedPolicy +SetSearchPathMode +SetStdHandleEx +SetSystemFileCacheSize +SetSystemPowerState +SetSystemTime +SetSystemTimeAdjustment +SetTapeParameters +SetTapePosition +SetThreadAffinityMask +SetThreadContext +SetThreadDescription +SetThreadErrorMode +SetThreadExecutionState +SetThreadGroupAffinity +SetThreadIdealProcessor +SetThreadIdealProcessorEx +SetThreadInformation +SetThreadLocale +SetThreadPreferredUILanguages +SetThreadPriority +SetThreadPriorityBoost +SetThreadSelectedCpuSetMasks +SetThreadSelectedCpuSets +SetThreadStackGuarantee +SetThreadToken +SetThreadUILanguage +SetThreadpoolStackInformation +SetThreadpoolThreadMaximum +SetThreadpoolThreadMinimum +SetThreadpoolTimer +SetThreadpoolTimerEx +SetThreadpoolWait +SetThreadpoolWaitEx +SetTimeZoneInformation +SetUmsThreadInformation +SetUnhandledExceptionFilter +SetUserGeoID +SetUserGeoName +SetVolumeLabelA +SetVolumeLabelW +SetVolumeMountPointA +SetVolumeMountPointW +SetWaitableTimer +SetWaitableTimerEx +SetXStateFeaturesMask +SetupComm +SignalObjectAndWait +SizeofResource +Sleep +SleepConditionVariableCS +SleepConditionVariableSRW +SleepEx +StartThreadpoolIo +SubmitThreadpoolWork +SuspendThread +SwitchToFiber +SwitchToThread +SystemTimeToFileTime +SystemTimeToTzSpecificLocalTime +SystemTimeToTzSpecificLocalTimeEx +TerminateEnclave +TerminateJobObject +TerminateProcess +TerminateThread +Thread32First +Thread32Next +TlsAlloc +TlsFree +TlsGetValue +TlsSetValue +Toolhelp32ReadProcessMemory +TransactNamedPipe +TransmitCommChar +TryAcquireSRWLockExclusive +TryAcquireSRWLockShared +TryEnterCriticalSection +TrySubmitThreadpoolCallback +TzSpecificLocalTimeToSystemTime +TzSpecificLocalTimeToSystemTimeEx +UmsThreadYield +UnhandledExceptionFilter +UnlockFile +UnlockFileEx +UnmapViewOfFile +UnmapViewOfFile2 +UnmapViewOfFileEx +UnregisterApplicationRecoveryCallback +UnregisterApplicationRestart +UnregisterBadMemoryNotification +UnregisterWait +UnregisterWaitEx +UnregisterWaitUntilOOBECompleted +UpdateProcThreadAttribute +UpdateResourceA +UpdateResourceW +VerLanguageNameA +VerLanguageNameW +VerSetConditionMask +VerifyScripts +VerifyVersionInfoA +VerifyVersionInfoW +VirtualAlloc +VirtualAlloc2FromApp +VirtualAllocEx +VirtualAllocExNuma +VirtualAllocFromApp +VirtualFree +VirtualFreeEx +VirtualLock +VirtualProtect +VirtualProtectEx +VirtualProtectFromApp +VirtualQuery +VirtualQueryEx +VirtualUnlock +WTSGetActiveConsoleSessionId +WaitCommEvent +WaitForDebugEvent +WaitForDebugEventEx +WaitForMultipleObjects +WaitForMultipleObjectsEx +WaitForSingleObject +WaitForSingleObjectEx +WaitForThreadpoolIoCallbacks +WaitForThreadpoolTimerCallbacks +WaitForThreadpoolWaitCallbacks +WaitForThreadpoolWorkCallbacks +WaitNamedPipeA +WaitNamedPipeW +WaitOnAddress +WakeAllConditionVariable +WakeByAddressAll +WakeByAddressSingle +WakeConditionVariable +WerGetFlags +WerRegisterAdditionalProcess +WerRegisterAppLocalDump +WerRegisterCustomMetadata +WerRegisterExcludedMemoryBlock +WerRegisterFile +WerRegisterMemoryBlock +WerRegisterRuntimeExceptionModule +WerSetFlags +WerUnregisterAdditionalProcess +WerUnregisterAppLocalDump +WerUnregisterExcludedMemoryBlock +WerUnregisterFile +WerUnregisterMemoryBlock +WerUnregisterRuntimeExceptionModule +WideCharToMultiByte +WinExec +Wow64DisableWow64FsRedirection +Wow64GetThreadSelectorEntry +Wow64RevertWow64FsRedirection +Wow64SuspendThread +WriteFile +WriteFileEx +WriteFileGather +WritePrivateProfileSectionA +WritePrivateProfileSectionW +WritePrivateProfileStringA +WritePrivateProfileStringW +WritePrivateProfileStructA +WritePrivateProfileStructW +WriteProcessMemory +WriteProfileSectionA +WriteProfileSectionW +WriteProfileStringA +WriteProfileStringW +WriteTapemark +ZombifyActCtx +_lclose +_lcreat +_llseek +_lopen +_lread +_lwrite +if_nametoindex +lstrcatA +lstrcatW +lstrcmpA +lstrcmpW +lstrcmpiA +lstrcmpiW +lstrcpyA +lstrcpyW +lstrcpynA +lstrcpynW +lstrlenA +lstrlenW +timeBeginPeriod +timeEndPeriod +timeGetDevCaps +timeGetSystemTime +timeGetTime +uaw_wcslen diff --git a/crates/exav-pe-emu/src/exports/ntdll.txt b/crates/exav-pe-emu/src/exports/ntdll.txt new file mode 100644 index 0000000..2a68b6e --- /dev/null +++ b/crates/exav-pe-emu/src/exports/ntdll.txt @@ -0,0 +1,133 @@ +DbgBreakPoint +DbgPrint +DbgPrintEx +DbgPrompt +EtwActivityIdControl +EtwUnregister +EtwWriteTransfer +GetCurrentProcessorNumber +NtClose +NtCreateFile +NtCurrentTeb +NtDeviceIoControlFile +NtNotifyChangeMultipleKeys +NtOpenFile +NtQueryInformationProcess +NtQueryInformationThread +NtQueryMultipleValueKey +NtQueryObject +NtQuerySystemInformation +NtQuerySystemTime +NtRenameKey +NtSetInformationKey +NtWaitForSingleObject +ReleaseSRWLockShared +RtlAbsoluteToSelfRelativeSD +RtlAddAce +RtlAddFunctionTable +RtlAddGrowableFunctionTable +RtlAllocateHeap +RtlAnsiStringToUnicodeString +RtlAppendUnicodeToString +RtlCaptureContext +RtlCaptureStackBackTrace +RtlCharToInteger +RtlClearAllBits +RtlCompareMemory +RtlCompareMemoryUlong +RtlCompareString +RtlConvertDeviceFamilyInfoToString +RtlConvertSidToUnicodeString +RtlCopyMemory +RtlCopyMemoryNonTemporal +RtlCopySid +RtlCopyUnicodeString +RtlCreateHeap +RtlCreateSecurityDescriptor +RtlCreateUnicodeString +RtlDecompressBuffer +RtlDeleteFunctionTable +RtlDeleteGrowableFunctionTable +RtlDowncaseUnicodeChar +RtlEqualPrefixSid +RtlEqualSid +RtlEqualString +RtlEqualUnicodeString +RtlEthernetAddressToStringA +RtlEthernetAddressToStringW +RtlEthernetStringToAddressA +RtlEthernetStringToAddressW +RtlFillMemory +RtlFirstEntrySList +RtlFreeAnsiString +RtlFreeOemString +RtlFreeUnicodeString +RtlGUIDFromString +RtlGetAce +RtlGetEnabledExtendedFeatures +RtlGetOwnerSecurityDescriptor +RtlGrowFunctionTable +RtlInitString +RtlInitUnicodeString +RtlInitializeBitMap +RtlInitializeSListHead +RtlInitializeSid +RtlInstallFunctionTableCallback +RtlIntegerToUnicodeString +RtlInterlockedFlushSList +RtlInterlockedPopEntrySList +RtlInterlockedPushEntrySList +RtlIpv4AddressToStringA +RtlIpv4AddressToStringExA +RtlIpv4AddressToStringExW +RtlIpv4AddressToStringW +RtlIpv4StringToAddressA +RtlIpv4StringToAddressExA +RtlIpv4StringToAddressExW +RtlIpv4StringToAddressW +RtlIpv6AddressToStringA +RtlIpv6AddressToStringExA +RtlIpv6AddressToStringExW +RtlIpv6AddressToStringW +RtlIpv6StringToAddressA +RtlIpv6StringToAddressExA +RtlIpv6StringToAddressExW +RtlIpv6StringToAddressW +RtlIsCloudFilesPlaceholder +RtlIsNameLegalDOS8Dot3 +RtlLengthRequiredSid +RtlLengthSid +RtlLocalTimeToSystemTime +RtlLookupFunctionEntry +RtlMultiByteToUnicodeN +RtlNtStatusToDosError +RtlNumberOfSetBits +RtlPcToFileHeader +RtlQueryDepthSList +RtlRaiseException +RtlRestoreContext +RtlSetBits +RtlSetDaclSecurityDescriptor +RtlSetGroupSecurityDescriptor +RtlSetOwnerSecurityDescriptor +RtlStringFromGUID +RtlSubAuthoritySid +RtlTimeToSecondsSince1970 +RtlTimeToTimeFields +RtlUnicodeStringToAnsiString +RtlUnicodeStringToInteger +RtlUnicodeStringToOemString +RtlUnicodeToMultiByteSize +RtlUniform +RtlUnwind +RtlUnwindEx +RtlUpcaseUnicodeChar +RtlUpcaseUnicodeString +RtlValidSid +RtlVerifyVersionInfo +RtlVirtualUnwind +TryAcquireSRWLockShared +UnhandledExceptionFilter +VerSetConditionMask +vDbgPrintEx +vDbgPrintExWithPrefix diff --git a/crates/exav-pe-emu/src/exports/user32.txt b/crates/exav-pe-emu/src/exports/user32.txt new file mode 100644 index 0000000..ec00b7c --- /dev/null +++ b/crates/exav-pe-emu/src/exports/user32.txt @@ -0,0 +1,755 @@ +ActivateKeyboardLayout +AddClipboardFormatListener +AdjustWindowRect +AdjustWindowRectEx +AdjustWindowRectExForDpi +AllowSetForegroundWindow +AnimateWindow +AnyPopup +AppendMenuA +AppendMenuW +AreDpiAwarenessContextsEqual +ArrangeIconicWindows +AttachThreadInput +BeginDeferWindowPos +BeginPaint +BlockInput +BringWindowToTop +BroadcastSystemMessage +BroadcastSystemMessageA +BroadcastSystemMessageExA +BroadcastSystemMessageExW +BroadcastSystemMessageW +CalculatePopupWindowPosition +CallMsgFilterA +CallMsgFilterW +CallNextHookEx +CallWindowProcA +CallWindowProcW +CascadeWindows +ChangeClipboardChain +ChangeDisplaySettingsA +ChangeDisplaySettingsExA +ChangeDisplaySettingsExW +ChangeDisplaySettingsW +ChangeWindowMessageFilter +ChangeWindowMessageFilterEx +CharLowerA +CharLowerBuffA +CharLowerBuffW +CharLowerW +CharNextA +CharNextExA +CharNextW +CharPrevA +CharPrevExA +CharPrevW +CharToOemA +CharToOemBuffA +CharToOemBuffW +CharToOemW +CharUpperA +CharUpperBuffA +CharUpperBuffW +CharUpperW +CheckDlgButton +CheckMenuItem +CheckMenuRadioItem +CheckRadioButton +ChildWindowFromPoint +ChildWindowFromPointEx +ClientToScreen +ClipCursor +CloseClipboard +CloseDesktop +CloseGestureInfoHandle +CloseTouchInputHandle +CloseWindow +CloseWindowStation +CopyAcceleratorTableA +CopyAcceleratorTableW +CopyIcon +CopyImage +CopyRect +CountClipboardFormats +CreateAcceleratorTableA +CreateAcceleratorTableW +CreateCaret +CreateCursor +CreateDesktopA +CreateDesktopExA +CreateDesktopExW +CreateDesktopW +CreateDialogA +CreateDialogIndirectA +CreateDialogIndirectParamA +CreateDialogIndirectParamW +CreateDialogIndirectW +CreateDialogParamA +CreateDialogParamW +CreateDialogW +CreateIcon +CreateIconFromResource +CreateIconFromResourceEx +CreateIconIndirect +CreateMDIWindowA +CreateMDIWindowW +CreateMenu +CreatePopupMenu +CreateSyntheticPointerDevice +CreateWindowExA +CreateWindowExW +CreateWindowStationA +CreateWindowStationW +DdeAbandonTransaction +DdeAccessData +DdeAddData +DdeClientTransaction +DdeCmpStringHandles +DdeConnect +DdeConnectList +DdeCreateDataHandle +DdeCreateStringHandleA +DdeCreateStringHandleW +DdeDisconnect +DdeDisconnectList +DdeEnableCallback +DdeFreeDataHandle +DdeFreeStringHandle +DdeGetData +DdeGetLastError +DdeImpersonateClient +DdeInitializeA +DdeInitializeW +DdeKeepStringHandle +DdeNameService +DdePostAdvise +DdeQueryConvInfo +DdeQueryNextServer +DdeQueryStringA +DdeQueryStringW +DdeReconnect +DdeSetQualityOfService +DdeSetUserHandle +DdeUnaccessData +DdeUninitialize +DefDlgProcA +DefDlgProcW +DefFrameProcA +DefFrameProcW +DefMDIChildProcA +DefMDIChildProcW +DefRawInputProc +DefWindowProcA +DefWindowProcW +DeferWindowPos +DeleteMenu +DeregisterShellHookWindow +DestroyAcceleratorTable +DestroyCaret +DestroyCursor +DestroyIcon +DestroyMenu +DestroySyntheticPointerDevice +DestroyWindow +DialogBoxA +DialogBoxIndirectA +DialogBoxIndirectParamA +DialogBoxIndirectParamW +DialogBoxIndirectW +DialogBoxParamA +DialogBoxParamW +DialogBoxW +DisableProcessWindowsGhosting +DispatchMessage +DispatchMessageA +DispatchMessageW +DisplayConfigGetDeviceInfo +DisplayConfigSetDeviceInfo +DlgDirListA +DlgDirListComboBoxA +DlgDirListComboBoxW +DlgDirListW +DlgDirSelectComboBoxExA +DlgDirSelectComboBoxExW +DlgDirSelectExA +DlgDirSelectExW +DragDetect +DrawAnimatedRects +DrawCaption +DrawEdge +DrawFocusRect +DrawFrameControl +DrawIcon +DrawIconEx +DrawMenuBar +DrawStateA +DrawStateW +DrawText +DrawTextA +DrawTextExA +DrawTextExW +DrawTextW +EmptyClipboard +EnableMenuItem +EnableMouseInPointer +EnableNonClientDpiScaling +EnableScrollBar +EnableWindow +EndDeferWindowPos +EndDialog +EndMenu +EndPaint +EndTask +EnumChildWindows +EnumClipboardFormats +EnumDesktopWindows +EnumDesktopsA +EnumDesktopsW +EnumDisplayDevicesA +EnumDisplayDevicesW +EnumDisplayMonitors +EnumDisplaySettingsA +EnumDisplaySettingsExA +EnumDisplaySettingsExW +EnumDisplaySettingsW +EnumPropsA +EnumPropsExA +EnumPropsExW +EnumPropsW +EnumThreadWindows +EnumWindowStationsA +EnumWindowStationsW +EnumWindows +EqualRect +EvaluateProximityToPolygon +EvaluateProximityToRect +ExcludeUpdateRgn +ExitWindowsEx +FillRect +FindWindowA +FindWindowExA +FindWindowExW +FindWindowW +FlashWindow +FlashWindowEx +FrameRect +FreeDDElParam +GetActiveWindow +GetAltTabInfoA +GetAltTabInfoW +GetAncestor +GetAsyncKeyState +GetAutoRotationState +GetAwarenessFromDpiAwarenessContext +GetCIMSSM +GetCapture +GetCaretBlinkTime +GetCaretPos +GetClassInfoA +GetClassInfoExA +GetClassInfoExW +GetClassInfoW +GetClassLongA +GetClassLongPtrA +GetClassLongPtrW +GetClassLongW +GetClassName +GetClassNameA +GetClassNameW +GetClassWord +GetClientRect +GetClipCursor +GetClipboardData +GetClipboardFormatNameA +GetClipboardFormatNameW +GetClipboardOwner +GetClipboardSequenceNumber +GetClipboardViewer +GetComboBoxInfo +GetCurrentInputMessageSource +GetCursor +GetCursorInfo +GetCursorPos +GetDC +GetDCEx +GetDesktopWindow +GetDialogBaseUnits +GetDialogControlDpiChangeBehavior +GetDialogDpiChangeBehavior +GetDisplayAutoRotationPreferences +GetDisplayConfigBufferSizes +GetDlgCtrlID +GetDlgItem +GetDlgItemInt +GetDlgItemTextA +GetDlgItemTextW +GetDoubleClickTime +GetDpiAwarenessContextForProcess +GetDpiForSystem +GetDpiForWindow +GetDpiFromDpiAwarenessContext +GetFocus +GetForegroundWindow +GetGUIThreadInfo +GetGestureConfig +GetGestureExtraArgs +GetGestureInfo +GetGuiResources +GetIconInfo +GetIconInfoExA +GetIconInfoExW +GetInputState +GetKBCodePage +GetKeyNameTextA +GetKeyNameTextW +GetKeyState +GetKeyboardLayout +GetKeyboardLayoutList +GetKeyboardLayoutNameA +GetKeyboardLayoutNameW +GetKeyboardState +GetKeyboardType +GetLastActivePopup +GetLastInputInfo +GetLayeredWindowAttributes +GetListBoxInfo +GetMenu +GetMenuBarInfo +GetMenuCheckMarkDimensions +GetMenuContextHelpId +GetMenuDefaultItem +GetMenuInfo +GetMenuItemCount +GetMenuItemID +GetMenuItemInfoA +GetMenuItemInfoW +GetMenuItemRect +GetMenuState +GetMenuStringA +GetMenuStringW +GetMessage +GetMessageA +GetMessageExtraInfo +GetMessagePos +GetMessageTime +GetMessageW +GetMonitorInfoA +GetMonitorInfoW +GetMouseMovePointsEx +GetNextDlgGroupItem +GetNextDlgTabItem +GetOpenClipboardWindow +GetParent +GetPhysicalCursorPos +GetPointerCursorId +GetPointerDevice +GetPointerDeviceCursors +GetPointerDeviceProperties +GetPointerDeviceRects +GetPointerDevices +GetPointerFrameInfo +GetPointerFrameInfoHistory +GetPointerFramePenInfo +GetPointerFramePenInfoHistory +GetPointerFrameTouchInfo +GetPointerFrameTouchInfoHistory +GetPointerInfo +GetPointerInfoHistory +GetPointerInputTransform +GetPointerPenInfo +GetPointerPenInfoHistory +GetPointerTouchInfo +GetPointerTouchInfoHistory +GetPointerType +GetPriorityClipboardFormat +GetProcessDefaultLayout +GetProcessWindowStation +GetPropA +GetPropW +GetQueueStatus +GetRawInputBuffer +GetRawInputData +GetRawInputDeviceInfoA +GetRawInputDeviceInfoW +GetRawInputDeviceList +GetRawPointerDeviceData +GetRegisteredRawInputDevices +GetScrollBarInfo +GetScrollInfo +GetScrollPos +GetScrollRange +GetShellWindow +GetSubMenu +GetSysColor +GetSysColorBrush +GetSystemDpiForProcess +GetSystemMenu +GetSystemMetrics +GetSystemMetricsForDpi +GetTabbedTextExtentA +GetTabbedTextExtentW +GetThreadDesktop +GetThreadDpiAwarenessContext +GetThreadDpiHostingBehavior +GetTitleBarInfo +GetTopWindow +GetTouchInputInfo +GetUnpredictedMessagePos +GetUpdateRect +GetUpdateRgn +GetUpdatedClipboardFormats +GetUserObjectInformationA +GetUserObjectInformationW +GetUserObjectSecurity +GetWindow +GetWindowContextHelpId +GetWindowDC +GetWindowDisplayAffinity +GetWindowDpiAwarenessContext +GetWindowDpiHostingBehavior +GetWindowFeedbackSetting +GetWindowInfo +GetWindowLongA +GetWindowLongPtrA +GetWindowLongPtrW +GetWindowLongW +GetWindowModuleFileNameA +GetWindowModuleFileNameW +GetWindowPlacement +GetWindowRect +GetWindowRgn +GetWindowRgnBox +GetWindowTextA +GetWindowTextLengthA +GetWindowTextLengthW +GetWindowTextW +GetWindowThreadProcessId +GetWindowWord +GrayStringA +GrayStringW +HideCaret +HiliteMenuItem +ImpersonateDdeClientWindow +InSendMessage +InSendMessageEx +InflateRect +InheritWindowMonitor +InitializeTouchInjection +InjectSyntheticPointerInput +InjectTouchInput +InsertMenuA +InsertMenuItemA +InsertMenuItemW +InsertMenuW +InternalGetWindowText +IntersectRect +InvalidateRect +InvalidateRgn +InvertRect +IsCharAlphaA +IsCharAlphaNumericA +IsCharAlphaNumericW +IsCharAlphaW +IsCharLowerA +IsCharLowerW +IsCharUpperA +IsCharUpperW +IsChild +IsClipboardFormatAvailable +IsDialogMessageA +IsDialogMessageW +IsDlgButtonChecked +IsGUIThread +IsHungAppWindow +IsIconic +IsImmersiveProcess +IsMenu +IsMouseInPointerEnabled +IsProcessDPIAware +IsRectEmpty +IsTouchWindow +IsValidDpiAwarenessContext +IsWinEventHookInstalled +IsWindow +IsWindowArranged +IsWindowEnabled +IsWindowUnicode +IsWindowVisible +IsWow64Message +IsZoomed +KillTimer +LoadAcceleratorsA +LoadAcceleratorsW +LoadBitmapA +LoadBitmapW +LoadCursorA +LoadCursorFromFileA +LoadCursorFromFileW +LoadCursorW +LoadIconA +LoadIconW +LoadImageA +LoadImageW +LoadKeyboardLayoutA +LoadKeyboardLayoutW +LoadMenuA +LoadMenuIndirectA +LoadMenuIndirectW +LoadMenuW +LoadStringA +LoadStringW +LockSetForegroundWindow +LockWindowUpdate +LockWorkStation +LogicalToPhysicalPoint +LogicalToPhysicalPointForPerMonitorDPI +LookupIconIdFromDirectory +LookupIconIdFromDirectoryEx +MapDialogRect +MapVirtualKeyA +MapVirtualKeyExA +MapVirtualKeyExW +MapVirtualKeyW +MapWindowPoints +MenuItemFromPoint +MessageBeep +MessageBox +MessageBoxA +MessageBoxExA +MessageBoxExW +MessageBoxIndirectA +MessageBoxIndirectW +MessageBoxW +ModifyMenuA +ModifyMenuW +MonitorFromPoint +MonitorFromRect +MonitorFromWindow +MoveWindow +MsgWaitForMultipleObjects +MsgWaitForMultipleObjectsEx +NotifyWinEvent +OemKeyScan +OemToCharA +OemToCharBuffA +OemToCharBuffW +OemToCharW +OffsetRect +OpenClipboard +OpenDesktopA +OpenDesktopW +OpenIcon +OpenInputDesktop +OpenWindowStationA +OpenWindowStationW +PackDDElParam +PackTouchHitTestingProximityEvaluation +PaintDesktop +PeekMessageA +PeekMessageW +PhysicalToLogicalPoint +PhysicalToLogicalPointForPerMonitorDPI +PostMessageA +PostMessageW +PostQuitMessage +PostThreadMessageA +PostThreadMessageW +PrintWindow +PrivateExtractIconsA +PrivateExtractIconsW +PtInRect +QueryDisplayConfig +RealChildWindowFromPoint +RealGetWindowClassA +RealGetWindowClassW +RedrawWindow +RegisterClassA +RegisterClassExA +RegisterClassExW +RegisterClassW +RegisterClipboardFormatA +RegisterClipboardFormatW +RegisterDeviceNotificationA +RegisterDeviceNotificationW +RegisterHotKey +RegisterPointerDeviceNotifications +RegisterPointerInputTarget +RegisterPointerInputTargetEx +RegisterPowerSettingNotification +RegisterRawInputDevices +RegisterShellHookWindow +RegisterSuspendResumeNotification +RegisterTouchHitTestingWindow +RegisterTouchWindow +RegisterWindowMessageA +RegisterWindowMessageW +ReleaseCapture +ReleaseDC +RemoveClipboardFormatListener +RemoveMenu +RemovePropA +RemovePropW +ReplyMessage +ReuseDDElParam +ScreenToClient +ScrollDC +ScrollWindow +ScrollWindowEx +SendDlgItemMessageA +SendDlgItemMessageW +SendIMEMessageExA +SendIMEMessageExW +SendInput +SendMessage +SendMessageA +SendMessageCallbackA +SendMessageCallbackW +SendMessageTimeoutA +SendMessageTimeoutW +SendMessageW +SendNotifyMessageA +SendNotifyMessageW +SetActiveWindow +SetCapture +SetCaretBlinkTime +SetCaretPos +SetClassLongA +SetClassLongPtrA +SetClassLongPtrW +SetClassLongW +SetClassWord +SetClipboardData +SetClipboardViewer +SetCoalescableTimer +SetCursor +SetCursorPos +SetDialogDpiChangeBehavior +SetDisplayAutoRotationPreferences +SetDisplayConfig +SetDlgItemInt +SetDlgItemTextA +SetDlgItemTextW +SetDoubleClickTime +SetFocus +SetForegroundWindow +SetGestureConfig +SetKeyboardState +SetLastErrorEx +SetLayeredWindowAttributes +SetMenu +SetMenuContextHelpId +SetMenuDefaultItem +SetMenuInfo +SetMenuItemBitmaps +SetMenuItemInfoA +SetMenuItemInfoW +SetMessageExtraInfo +SetParent +SetPhysicalCursorPos +SetProcessDPIAware +SetProcessDefaultLayout +SetProcessDpiAwarenessContext +SetProcessRestrictionExemption +SetProcessWindowStation +SetPropA +SetPropW +SetRect +SetRectEmpty +SetScrollInfo +SetScrollPos +SetScrollRange +SetSysColors +SetSystemCursor +SetThreadCursorCreationScaling +SetThreadDesktop +SetThreadDpiAwarenessContext +SetThreadDpiHostingBehavior +SetTimer +SetUserObjectInformationA +SetUserObjectInformationW +SetUserObjectSecurity +SetWinEventHook +SetWindowContextHelpId +SetWindowDisplayAffinity +SetWindowFeedbackSetting +SetWindowLongA +SetWindowLongPtrA +SetWindowLongPtrW +SetWindowLongW +SetWindowPlacement +SetWindowPos +SetWindowRgn +SetWindowTextA +SetWindowTextW +SetWindowsHookExA +SetWindowsHookExW +ShowCaret +ShowCursor +ShowOwnedPopups +ShowScrollBar +ShowWindow +ShowWindowAsync +ShutdownBlockReasonCreate +ShutdownBlockReasonDestroy +ShutdownBlockReasonQuery +SkipPointerFrameMessages +SoundSentry +SubtractRect +SwapMouseButton +SwitchDesktop +SwitchToThisWindow +SystemParametersInfoA +SystemParametersInfoForDpi +SystemParametersInfoW +TabbedTextOutA +TabbedTextOutW +TileWindows +ToAscii +ToAsciiEx +ToUnicode +ToUnicodeEx +TrackMouseEvent +TrackPopupMenu +TrackPopupMenuEx +TranslateAcceleratorA +TranslateAcceleratorW +TranslateMDISysAccel +TranslateMessage +UnhookWinEvent +UnhookWindowsHookEx +UnionRect +UnloadKeyboardLayout +UnpackDDElParam +UnregisterClassA +UnregisterClassW +UnregisterDeviceNotification +UnregisterHotKey +UnregisterPointerInputTarget +UnregisterPointerInputTargetEx +UnregisterPowerSettingNotification +UnregisterSuspendResumeNotification +UnregisterTouchWindow +UpdateLayeredWindow +UpdateWindow +UserHandleGrantAccess +ValidateRect +ValidateRgn +VkKeyScanA +VkKeyScanExA +VkKeyScanExW +VkKeyScanW +WINNLSEnableIME +WaitForInputIdle +WaitMessage +WinHelpA +WinHelpW +WindowFromDC +WindowFromPhysicalPoint +WindowFromPoint +keybd_event +mouse_event +wsprintfA +wsprintfW +wvsprintfA +wvsprintfW diff --git a/crates/exav-pe-emu/src/image.rs b/crates/exav-pe-emu/src/image.rs index c97a441..fca6fd1 100644 --- a/crates/exav-pe-emu/src/image.rs +++ b/crates/exav-pe-emu/src/image.rs @@ -2,10 +2,17 @@ //! //! Loading follows what the Windows loader does to the parts a stub can //! observe: headers at the image base, each section at its virtual address, -//! zero-fill where the virtual size exceeds the raw size. Nothing else — no -//! relocations (the emulator maps at the image's preferred base, so there are -//! none to apply), no import binding (the stub resolves its own), no TLS -//! callbacks. +//! zero-fill where the virtual size exceeds the raw size. No relocations (the +//! emulator maps at the image's preferred base, so there are none to apply) and +//! no import binding here (the stub resolves its own, with a hand from +//! [`crate::win::Env::bind_imports`]). +//! +//! The one piece of loader work this module parses but [`crate::run`] performs +//! is the **TLS directory** (data directory 9): its callbacks run, with +//! `DLL_PROCESS_ATTACH`, before the entry point, because a packer can put its +//! unpacking there precisely so a tool that starts at the entry point sees +//! nothing. [`PeImage::tls_dir`] is the directory's `(rva, size)`; the run +//! driver reads the `IMAGE_TLS_DIRECTORY32` it points at. //! //! Dumping is the reverse, with one deliberate difference: the dump is written //! in **memory layout**, with `PointerToRawData == VirtualAddress` and the file @@ -46,6 +53,10 @@ pub struct PeImage { /// functions its stub needs, and the *loader* — not the stub — fills those /// slots in. Emulating that binding is what lets the stub call them. pub import_dir: (u32, u32), + /// TLS directory (RVA, size), data directory 9. Zero when absent. The run + /// driver reads the `IMAGE_TLS_DIRECTORY32` here and runs its callbacks + /// before the entry point, as the Windows loader does. + pub tls_dir: (u32, u32), pub sections: Vec, /// Offset of the section table within the file, so the dump can patch it. sec_table_off: usize, @@ -105,6 +116,15 @@ impl PeImage { } else { (0, 0) }; + // Data directory 9 is the TLS directory, at opt + 96 + 9 * 8. + let tls_dir = if num_dirs >= 10 { + ( + u32_at(data, opt + 168).unwrap_or(0), + u32_at(data, opt + 172).unwrap_or(0), + ) + } else { + (0, 0) + }; let mut sections = Vec::with_capacity(num_sections); for i in 0..num_sections { @@ -140,6 +160,7 @@ impl PeImage { file_align, is_dll, import_dir, + tls_dir, sections, sec_table_off, pe_off, diff --git a/crates/exav-pe-emu/src/run.rs b/crates/exav-pe-emu/src/run.rs index 2d886e6..09f6cd2 100644 --- a/crates/exav-pe-emu/src/run.rs +++ b/crates/exav-pe-emu/src/run.rs @@ -35,6 +35,23 @@ //! handler, and resumes from the context the handler leaves behind — including //! any registers or instruction pointer it edited, which is exactly the //! mechanism those stubs are using. +//! +//! ## TLS callbacks +//! +//! The Windows loader runs a module's TLS callbacks before its entry point, so +//! a packer can hide its unpacking there to defeat a tool that starts at the +//! entry point. The driver does the same: it sets up the static TLS block, +//! writes the index to `AddressOfIndex`, points the TEB's +//! `ThreadLocalStoragePointer` at it, then calls each callback as +//! `cb(image_base, DLL_PROCESS_ATTACH, 0)`, re-reading the callback array after +//! each one as the loader does (a callback may extend it). Only then does it +//! start at the entry point. The directory layout, the TEB+0x2C TLS array and +//! the callback prototype are as Microsoft's PE Format specification gives them +//! (section "The .tls Section"). A callback that faults or raises goes through the +//! same SEH machinery as the stub. If nothing handles it, or a callback runs +//! past its slice of the budget, or the callback count cap is hit, the TLS +//! phase is abandoned and execution proceeds to the entry point rather than +//! ending the run: the payload is still worth trying to recover from there. use crate::cpu::{Cpu, Stop, EAX, EBX, ESP}; use crate::image::PeImage; @@ -80,6 +97,24 @@ const MIN_WRITE_SPREAD: u32 = 6; /// skewed, so a run that makes many of them is not executing anything real. const MAX_UNIMPLEMENTED_CALLS: u32 = 64; +/// Most TLS callbacks a run will invoke before giving up on the rest and +/// starting the entry point. A real image has one or two; a hostile directory +/// can name far more, or point its array at itself. +const MAX_TLS_CALLBACKS: u32 = 64; + +/// Fraction of the whole instruction budget any single TLS callback may spend. +/// A callback is bounded so a stub cannot park the emulator in one forever, but +/// generously, since a packer that unpacks from a callback does real work there. +const TLS_CALLBACK_BUDGET_DIVISOR: u64 = 8; + +/// Largest static TLS block the loader will build, so a crafted +/// `SizeOfZeroFill` cannot demand an arbitrary allocation. +const MAX_TLS_BLOCK: u32 = 1 << 20; + +/// `DLL_PROCESS_ATTACH`, the reason a TLS callback and `DllMain` receive at +/// process start. +const DLL_PROCESS_ATTACH: u32 = 1; + /// Everything that bounds a run. /// /// This is the sandbox's contract. The emulator follows control flow supplied @@ -218,32 +253,39 @@ pub fn unpack(file: &[u8], limits: &EmuLimits) -> Report { let _ = env.bind_imports(&mut mem, image.base, dir_rva, dir_size); let mut cpu = Cpu::new(); - cpu.eip = image.base.wrapping_add(image.entry_rva); - cpu.regs[ESP] = INITIAL_ESP; cpu.fs_base = TEB_BASE; - // What a thread sees at the entry point on Windows: EBX holds the PEB, and - // the return address is into kernel32. - cpu.regs[EBX] = crate::win::PEB_BASE; let process_return = env.process_return(); let seh_return = env.seh_return(); - // A DLL is entered as `DllMain(hinstDLL, DLL_PROCESS_ATTACH, NULL)`. Those - // three arguments have to be on the stack: a packed DLL's stub reads them, - // and without them it faults on its first instruction. - let pushed = if image.is_dll { - cpu.push32(&mut mem, 0) - .and_then(|()| cpu.push32(&mut mem, 1)) - .and_then(|()| cpu.push32(&mut mem, image.base)) - .and_then(|()| cpu.push32(&mut mem, process_return)) - } else { - cpu.push32(&mut mem, process_return) + let tls_return = env.tls_return(); + let image_end = image.base.saturating_add(image.size_of_image); + + // The loader runs TLS callbacks before the entry point. Set the static TLS + // block up and queue the callbacks; if there are none, start at the entry + // point straight away. `tls_cb_array` is the VA of the callback array, re- + // read before each call because a callback can grow it. + let tls_cb_array = setup_tls(&image, &mut env, &mut mem); + let mut tls_next: u32 = 0; + let mut tls_calls: u32 = 0; + let mut in_tls = false; + // Per-callback slice of the budget; a callback that outruns it is abandoned. + let tls_callback_budget = limits.max_ticks / TLS_CALLBACK_BUDGET_DIVISOR; + let mut tls_deadline: u64 = tls_callback_budget; + + let start_ok = match tls_cb_array.and_then(|a| next_tls_callback(&mut mem, a, 0, &image)) { + Some(cb) => { + in_tls = true; + tls_next = 1; + tls_calls = 1; + enter_tls_callback(&mut cpu, &mut mem, cb, image.base, tls_return).is_ok() + } + None => start_entry(&mut cpu, &mut mem, &image, process_return).is_ok(), }; - if pushed.is_err() { + if !start_ok { report.stop = "stack is not mapped".to_string(); return report; } let stub_section = image.section_of(image.entry_rva); - let image_end = image.base.saturating_add(image.size_of_image); let mut seh = SehState { seh_return, @@ -275,8 +317,49 @@ pub fn unpack(file: &[u8], limits: &EmuLimits) -> Report { } progress_deadline = ticks + PROGRESS_WINDOW; } + // A TLS callback that outran its slice of the budget. Abandon the rest + // of the TLS phase and start the entry point with what it produced. + if in_tls && ticks >= tls_deadline { + in_tls = false; + if start_entry(&mut cpu, &mut mem, &image, process_return).is_err() { + stop_reason = "stack is not mapped".to_string(); + break; + } + continue; + } let eip = cpu.eip; + if in_tls && eip == tls_return { + ticks += 1; + // A callback returned. The loader re-reads the array because a + // callback may have appended to it; take the next entry if there is + // one and the count cap allows, otherwise the TLS phase is done. + let next = if tls_calls < MAX_TLS_CALLBACKS { + tls_cb_array.and_then(|a| next_tls_callback(&mut mem, a, tls_next, &image)) + } else { + None + }; + match next { + Some(cb) => { + tls_next += 1; + tls_calls += 1; + tls_deadline = ticks + tls_callback_budget; + if enter_tls_callback(&mut cpu, &mut mem, cb, image.base, tls_return).is_err() { + stop_reason = "stack is not mapped".to_string(); + break; + } + } + None => { + in_tls = false; + if start_entry(&mut cpu, &mut mem, &image, process_return).is_err() { + stop_reason = "stack is not mapped".to_string(); + break; + } + } + } + continue; + } + if eip == process_return { stop_reason = "the stub returned to the loader".to_string(); break; @@ -509,6 +592,123 @@ pub fn unpack(file: &[u8], limits: &EmuLimits) -> Report { report } +/// Point the CPU at the entry point with the stack a thread starts with: EBX at +/// the PEB, and a return address (plus `DllMain`'s three arguments for a DLL). +fn start_entry( + cpu: &mut Cpu, + mem: &mut Mem, + image: &PeImage, + process_return: u32, +) -> Result<(), ()> { + cpu.eip = image.base.wrapping_add(image.entry_rva); + cpu.regs[ESP] = INITIAL_ESP; + cpu.regs[EBX] = crate::win::PEB_BASE; + // A DLL is entered as `DllMain(hinstDLL, DLL_PROCESS_ATTACH, NULL)`. Those + // three arguments have to be on the stack: a packed DLL's stub reads them, + // and without them it faults on its first instruction. + let pushed = if image.is_dll { + cpu.push32(mem, 0) + .and_then(|()| cpu.push32(mem, DLL_PROCESS_ATTACH)) + .and_then(|()| cpu.push32(mem, image.base)) + .and_then(|()| cpu.push32(mem, process_return)) + } else { + cpu.push32(mem, process_return) + }; + pushed.map_err(|_| ()) +} + +/// Enter a TLS callback as `cb(image_base, DLL_PROCESS_ATTACH, 0)`, returning to +/// `tls_return` so the driver can run the next one. +fn enter_tls_callback( + cpu: &mut Cpu, + mem: &mut Mem, + callback: u32, + image_base: u32, + tls_return: u32, +) -> Result<(), ()> { + cpu.regs[ESP] = INITIAL_ESP; + cpu.push32(mem, 0) // reserved + .and_then(|()| cpu.push32(mem, DLL_PROCESS_ATTACH)) + .and_then(|()| cpu.push32(mem, image_base)) + .and_then(|()| cpu.push32(mem, tls_return)) + .map_err(|_| ())?; + cpu.eip = callback; + Ok(()) +} + +/// Set up the static TLS block and return the VA of the callback array, or +/// `None` when the directory is absent, malformed, or points outside the image. +/// +/// The `IMAGE_TLS_DIRECTORY32` fields are virtual addresses (the image is mapped +/// at its preferred base, so a VA is `base + RVA`). Every one is bounds-checked +/// against the mapped image before it is dereferenced, since a packed file's TLS +/// directory is attacker-controlled. +fn setup_tls(image: &PeImage, env: &mut Env, mem: &mut Mem) -> Option { + let (rva, size) = image.tls_dir; + if rva == 0 || size < 0x18 { + return None; + } + let image_end = image.base.saturating_add(image.size_of_image); + let in_image = |va: u32| va >= image.base && va < image_end; + let td = image.base.wrapping_add(rva); + if !in_image(td) || !mem.is_mapped(td, 0x18) { + return None; + } + let raw_start = mem.read_u32(td).ok()?; + let raw_end = mem.read_u32(td + 4).ok()?; + let index_va = mem.read_u32(td + 8).ok()?; + let cb_array = mem.read_u32(td + 12).ok()?; + let zero_fill = mem.read_u32(td + 16).ok()?; + + // The static TLS template: the raw data, then the zero fill. A directory + // whose raw range is backwards or outside the image contributes no data; + // the block is still created so `AddressOfIndex` and the TEB are wired up. + let mut template = Vec::new(); + if raw_end > raw_start && in_image(raw_start) && in_image(raw_end) { + let len = (raw_end - raw_start).min(MAX_TLS_BLOCK); + if mem.is_mapped(raw_start, len) { + template = mem.snapshot(raw_start, len as usize); + } + } + let total = (template.len() as u32) + .saturating_add(zero_fill) + .min(MAX_TLS_BLOCK); + template.resize(total as usize, 0); + + // `AddressOfIndex` must be writable storage inside the image; a directory + // that points it elsewhere gets no index written, matching a loader that + // would fault and abandon the module rather than scribble out of bounds. + let index_va = if index_va != 0 && in_image(index_va) && mem.is_mapped(index_va, 4) { + index_va + } else { + 0 + }; + env.install_static_tls(mem, &template, 0, index_va).ok()??; + + if cb_array != 0 && in_image(cb_array) && mem.is_mapped(cb_array, 4) { + Some(cb_array) + } else { + None + } +} + +/// The `idx`-th callback in the array at `cb_array`, if it is present, non-null +/// and points into the image. Re-read every time because a callback can extend +/// the array. +fn next_tls_callback(mem: &mut Mem, cb_array: u32, idx: u32, image: &PeImage) -> Option { + let slot = cb_array.wrapping_add(idx.wrapping_mul(4)); + if !mem.is_mapped(slot, 4) { + return None; + } + let cb = mem.read_u32(slot).ok()?; + let image_end = image.base.saturating_add(image.size_of_image); + if cb != 0 && cb >= image.base && cb < image_end { + Some(cb) + } else { + None + } +} + /// Whether the section execution just entered was *rebuilt* rather than merely /// touched. /// diff --git a/crates/exav-pe-emu/src/win.rs b/crates/exav-pe-emu/src/win.rs index 4e68ee8..acb4935 100644 --- a/crates/exav-pe-emu/src/win.rs +++ b/crates/exav-pe-emu/src/win.rs @@ -60,15 +60,35 @@ const ENV_BASE: u32 = 0x0009_0000; const HEAP_BASE: u32 = 0x0100_0000; const HEAP_LIMIT: u32 = 0x6000_0000; -/// Address space each synthetic DLL occupies. The first 0x4000 bytes hold the -/// headers and export directory; the rest is the trap range its exports point -/// into. -const MODULE_SIZE: u32 = 0x0001_0000; -const TRAP_OFFSET: u32 = 0x4000; +/// Address space each synthetic DLL occupies. The headers and export directory +/// live below `TRAP_OFFSET`; the trap range its exports point into runs from +/// there. The region is large because a synthetic DLL advertises the real one's +/// documented export names (over a thousand for `kernel32`), so a stub that +/// resolves imports by hashing export names finds what it looks for. +const MODULE_SIZE: u32 = 0x0004_0000; +const TRAP_OFFSET: u32 = 0x0001_8000; const TRAP_STRIDE: u32 = 0x10; -/// Offset within `kernel32` that the emulator's two return sentinels sit at: -/// past the trap range, still inside the module image. -const RETURN_OFFSET: u32 = 0xc000; +/// Offset within `kernel32` that the emulator's return sentinels sit at: past +/// the trap range, still inside the module image. +const RETURN_OFFSET: u32 = 0x0003_f000; + +/// Largest export directory, in bytes, a synthetic module may build below its +/// trap range. Bounds the per-module name set so the directory cannot grow into +/// the traps. +const MAX_EXPORT_DIR: u32 = TRAP_OFFSET - 0x1000; + +/// The documented export-name set each synthetic system DLL advertises, beyond +/// the handful the emulator implements. Names only, extracted from Microsoft's +/// API documentation by `scripts/gen-exports.py` (provenance and licence in +/// this crate's NOTICE). Undocumented exports are not in it. A name here with +/// no implementation resolves to a trap that reports itself and returns +/// failure, never to undefined behaviour. +const EXTRA_EXPORTS: &[(&str, &str)] = &[ + ("kernel32.dll", include_str!("exports/kernel32.txt")), + ("ntdll.dll", include_str!("exports/ntdll.txt")), + ("user32.dll", include_str!("exports/user32.txt")), + ("advapi32.dll", include_str!("exports/advapi32.txt")), +]; /// Handle values with fixed meanings on Windows. const INVALID_HANDLE: u32 = 0xffff_ffff; @@ -162,6 +182,9 @@ enum Api { WriteFile, SetFilePointer, GetFileSize, + GetFileAttributesEx { + wide: bool, + }, CreateFileMapping, MapViewOfFile, GetStdHandle, @@ -484,6 +507,16 @@ const APIS: &[ApiSpec] = &[ k("CreateDirectoryW", 2, Api::Const(0)), k("GetFileAttributesA", 1, Api::Const(0xffff_ffff)), k("GetFileAttributesW", 1, Api::Const(0xffff_ffff)), + k( + "GetFileAttributesExA", + 3, + Api::GetFileAttributesEx { wide: false }, + ), + k( + "GetFileAttributesExW", + 3, + Api::GetFileAttributesEx { wide: true }, + ), k("SetFileAttributesA", 2, Api::Const(0)), k("CopyFileA", 3, Api::Const(0)), k("MoveFileA", 2, Api::Const(0)), @@ -846,6 +879,45 @@ impl<'a> Env<'a> { self.kernel32_base() + RETURN_OFFSET + 0x10 } + /// Return address a TLS callback returns to, so the driver runs the next + /// one. A distinct sentinel from `process_return` because a callback + /// returning is not the stub returning to the loader. + pub fn tls_return(&self) -> u32 { + self.kernel32_base() + RETURN_OFFSET + 0x20 + } + + /// Set up the static TLS block the way the loader does before a TLS + /// callback runs: a copy of the module's TLS template (raw data plus zero + /// fill), the TEB's `ThreadLocalStoragePointer` pointing at a one-slot array + /// that holds it, and the assigned index written to `*index_va`. + /// + /// Returns the block's base, or `None` when the allocation does not fit. + pub fn install_static_tls( + &mut self, + mem: &mut Mem, + template: &[u8], + index: u32, + index_va: u32, + ) -> Result, Stop> { + let block = self.alloc(mem, 0, (template.len() as u32).max(1)); + if block == 0 { + return Ok(None); + } + mem.write_bytes(block, template).map_err(Stop::Fault)?; + let array = self.alloc(mem, 0, (index + 1) * 4); + if array == 0 { + return Ok(None); + } + mem.write_u32(array + index * 4, block) + .map_err(Stop::Fault)?; + // TEB.ThreadLocalStoragePointer is at offset 0x2C on x86 [pe][dbg]. + mem.write_u32(TEB_BASE + 0x2c, array).map_err(Stop::Fault)?; + if index_va != 0 { + mem.write_u32(index_va, index).map_err(Stop::Fault)?; + } + Ok(Some(block)) + } + fn kernel32_base(&self) -> u32 { self.find_module_by_name("kernel32.dll") .map(|m| m.base) @@ -861,18 +933,67 @@ impl<'a> Env<'a> { self.modules.iter().find(|m| m.name == want) } - /// Create a synthetic DLL: PE headers, an export directory naming every API - /// the emulator implements for it, and one trap address per export. + /// The sorted export set a synthetic module advertises: every API the + /// emulator implements for it, plus the real DLL's other documented export + /// names, bounded so the directory stays below the trap range. + /// + /// The list is sorted by name, because Windows keeps the export name table + /// sorted and hand-written resolvers binary-search it. Implemented exports + /// are never dropped by the size bound; only extra names are. + fn module_exports(&self, name: &str) -> Vec<(&'static str, Option, u8)> { + let mut implemented: Vec<(&'static str, Option, u8)> = APIS + .iter() + .filter(|s| s.module == name) + .map(|s| (s.name, Some(s.api), s.argc)) + .collect(); + + // Running size of the export directory below RVA 0x1000: the three + // parallel arrays (10 bytes an entry) plus the name strings, plus the + // module's own name string. Kept under `MAX_EXPORT_DIR`. + let mut used = 40u32 + export_dir_name(name).len() as u32 + 1; + let entry_cost = |s: &str| 10 + s.len() as u32 + 1; + for (ename, ..) in &implemented { + used += entry_cost(ename); + } + + if let Some((_, list)) = EXTRA_EXPORTS.iter().find(|(m, _)| *m == name) { + let known: std::collections::HashSet<&str> = + implemented.iter().map(|(n, ..)| *n).collect(); + for ename in list.lines().map(str::trim).filter(|l| !l.is_empty()) { + if known.contains(ename) { + continue; + } + let cost = entry_cost(ename); + if used + cost > MAX_EXPORT_DIR { + break; + } + used += cost; + // A name this module exports but the emulator implements under + // another module (a forwarder) resolves to that implementation, + // as `export_addr` does by name across modules. Only a name no + // module implements becomes an unimplemented trap. + let spec = APIS.iter().find(|s| s.name == ename); + implemented.push(( + ename, + spec.map(|s| s.api), + spec.map(|s| s.argc).unwrap_or(0), + )); + } + } + implemented.sort_by(|a, b| a.0.cmp(b.0)); + implemented + } + + /// Create a synthetic DLL: PE headers and an export directory. The directory + /// names every API the emulator implements for the module *and* the real + /// DLL's other documented exports (names only), so a stub that resolves imports by + /// hashing export names finds what it looks for. Each export points at a + /// trap; an unimplemented one's trap reports itself and returns failure when + /// called, never undefined behaviour. fn create_module(&mut self, mem: &mut Mem, name: &str, base: u32) -> Result<(), Stop> { map(mem, base, MODULE_SIZE)?; let name = normalize_module(name); - let exports: Vec<&ApiSpec> = { - let mut v: Vec<&ApiSpec> = APIS.iter().filter(|s| s.module == name).collect(); - // Windows keeps the export name table sorted, and hand-written - // resolvers binary-search it. - v.sort_by_key(|s| s.name); - v - }; + let exports = self.module_exports(&name); let mut trap = base + TRAP_OFFSET; // Export directory at RVA 0x1000, arrays and strings after it. @@ -887,11 +1008,7 @@ impl<'a> Env<'a> { ed.extend_from_slice(&0u32.to_le_bytes()); // Characteristics ed.extend_from_slice(&0u32.to_le_bytes()); // TimeDateStamp ed.extend_from_slice(&0u32.to_le_bytes()); // Major/Minor version - ed.extend_from_slice(&strings_rva.to_le_bytes()); // Name (points at the - // first string, which - // is close enough for - // a resolver that - // only reads exports) + ed.extend_from_slice(&0u32.to_le_bytes()); // Name, patched below ed.extend_from_slice(&1u32.to_le_bytes()); // Base ordinal ed.extend_from_slice(&n.to_le_bytes()); // NumberOfFunctions ed.extend_from_slice(&n.to_le_bytes()); // NumberOfNames @@ -901,7 +1018,7 @@ impl<'a> Env<'a> { mem.write_bytes(base + ed_rva, &ed).map_err(Stop::Fault)?; let mut str_rva = strings_rva; - for (i, spec) in exports.iter().enumerate() { + for (i, (ename, api, argc)) in exports.iter().enumerate() { let i = i as u32; mem.write_u32(base + funcs_rva + i * 4, trap - base) .map_err(Stop::Fault)?; @@ -909,19 +1026,27 @@ impl<'a> Env<'a> { .map_err(Stop::Fault)?; mem.write_u16(base + ords_rva + i * 2, i as u16) .map_err(Stop::Fault)?; - write_c_str(mem, base + str_rva, spec.name)?; - str_rva += spec.name.len() as u32 + 1; + write_c_str(mem, base + str_rva, ename)?; + str_rva += ename.len() as u32 + 1; self.traps.insert( trap, Trap { - api: Some(spec.api), - argc: spec.argc, + api: *api, + argc: *argc, module: name.clone(), - name: spec.name.to_string(), + name: ename.to_string(), }, ); trap += TRAP_STRIDE; } + // The DLL's own name, spelled as the real export directory spells it. + // tElock reads it to check that the module it found is `KERNEL32` and + // spins forever when it is not. + let export_name = export_dir_name(&name); + write_c_str(mem, base + str_rva, &export_name)?; + mem.write_u32(base + ed_rva + 12, str_rva) + .map_err(Stop::Fault)?; + str_rva += export_name.len() as u32 + 1; write_module_headers(mem, base, MODULE_SIZE, ed_rva, str_rva - ed_rva)?; self.modules.push(Module { @@ -933,23 +1058,34 @@ impl<'a> Env<'a> { Ok(()) } + // Where each layout fact below comes from (pinned sources in the NOTICE): + // [pe] Microsoft PE Format specification, "The .tls Section". + // [dbg] Microsoft debugger documentation: `dt` output on 32-bit Windows. + // [wtl] Microsoft winternl.h reference; x86 offsets follow from the + // documented member types. + // [kus] Microsoft WDK reference for KUSER_SHARED_DATA, which gives the + // field order but not every type, so not the offsets. + // [obs] Not given by those sources. Each is observable on 32-bit Windows + // by comparing the field with the API that reports the same value + // (listed in the NOTICE); kept on that basis. + /// Fill `KUSER_SHARED_DATA` with the handful of fields a stub reads: the /// interrupt/tick counters (used for timing checks) and the OS version. fn build_shared_user_data(&self, mem: &mut Mem) -> Result<(), Stop> { map(mem, SHARED_USER_DATA, PAGE_SIZE as u32)?; mem.write_u32(SHARED_USER_DATA, 0x0010_0000) - .map_err(Stop::Fault)?; // TickCountLow + .map_err(Stop::Fault)?; // TickCountLowDeprecated [kus][obs] mem.write_u32(SHARED_USER_DATA + 0x04, 0x000f_a000) - .map_err(Stop::Fault)?; // TickCountMultiplier + .map_err(Stop::Fault)?; // TickCountMultiplier [kus][obs] mem.write_u32(SHARED_USER_DATA + 0x08, 0x0010_0000) - .map_err(Stop::Fault)?; // InterruptTime.LowPart + .map_err(Stop::Fault)?; // InterruptTime, low part [kus][obs] mem.write_u32(SHARED_USER_DATA + 0x14, 0x1000_0000) - .map_err(Stop::Fault)?; // SystemTime.LowPart - write_utf16(mem, SHARED_USER_DATA + 0x30, "C:\\WINDOWS")?; // NtSystemRoot + .map_err(Stop::Fault)?; // SystemTime, low part [kus][obs] + write_utf16(mem, SHARED_USER_DATA + 0x30, "C:\\WINDOWS")?; // NtSystemRoot [kus][obs] mem.write_u32(SHARED_USER_DATA + 0x26c, 5) - .map_err(Stop::Fault)?; // NtMajorVersion + .map_err(Stop::Fault)?; // NtMajorVersion [kus][obs] mem.write_u32(SHARED_USER_DATA + 0x270, 1) - .map_err(Stop::Fault)?; // NtMinorVersion + .map_err(Stop::Fault)?; // NtMinorVersion [kus][obs] Ok(()) } @@ -963,35 +1099,36 @@ impl<'a> Env<'a> { map(mem, TEB_BASE, PAGE_SIZE as u32)?; map(mem, PEB_BASE, PAGE_SIZE as u32)?; - // TEB. - mem.write_u32(TEB_BASE, 0xffff_ffff).map_err(Stop::Fault)?; // ExceptionList: chain end + // TEB. NtTib (an NT_TIB) is at 0x00 [dbg]; its members' order is + // consistent with the [dbg] `dd` example, and is [obs]. + mem.write_u32(TEB_BASE, 0xffff_ffff).map_err(Stop::Fault)?; // NtTib.ExceptionList: chain end [obs] mem.write_u32(TEB_BASE + 0x04, STACK_TOP) - .map_err(Stop::Fault)?; // StackBase + .map_err(Stop::Fault)?; // NtTib.StackBase [obs] mem.write_u32(TEB_BASE + 0x08, STACK_BASE) - .map_err(Stop::Fault)?; // StackLimit + .map_err(Stop::Fault)?; // NtTib.StackLimit [obs] mem.write_u32(TEB_BASE + 0x18, TEB_BASE) - .map_err(Stop::Fault)?; // Self + .map_err(Stop::Fault)?; // NtTib.Self [obs] mem.write_u32(TEB_BASE + 0x20, 0x0000_0abc) - .map_err(Stop::Fault)?; // ProcessId + .map_err(Stop::Fault)?; // ClientId.UniqueProcess: ClientId at 0x20 [dbg], member order [obs] mem.write_u32(TEB_BASE + 0x24, 0x0000_0def) - .map_err(Stop::Fault)?; // ThreadId + .map_err(Stop::Fault)?; // ClientId.UniqueThread [obs] mem.write_u32(TEB_BASE + 0x30, PEB_BASE) - .map_err(Stop::Fault)?; // PEB + .map_err(Stop::Fault)?; // ProcessEnvironmentBlock [dbg] // PEB. `BeingDebugged` and `NtGlobalFlag` are the two fields anti-debug // stubs read directly, and both say "no debugger". mem.write_u32(PEB_BASE + 0x08, image_base) - .map_err(Stop::Fault)?; // ImageBaseAddress + .map_err(Stop::Fault)?; // ImageBaseAddress [obs] mem.write_u32(PEB_BASE + 0x0c, LDR_BASE) - .map_err(Stop::Fault)?; // Ldr + .map_err(Stop::Fault)?; // Ldr [dbg] mem.write_u32(PEB_BASE + 0x18, PROCESS_HEAP) - .map_err(Stop::Fault)?; // ProcessHeap - mem.write_u32(PEB_BASE + 0x64, 1).map_err(Stop::Fault)?; // NumberOfProcessors - mem.write_u32(PEB_BASE + 0x68, 0).map_err(Stop::Fault)?; // NtGlobalFlag - mem.write_u32(PEB_BASE + 0xa4, 5).map_err(Stop::Fault)?; // OSMajorVersion - mem.write_u32(PEB_BASE + 0xa8, 1).map_err(Stop::Fault)?; // OSMinorVersion - mem.write_u32(PEB_BASE + 0xac, 2600).map_err(Stop::Fault)?; // OSBuildNumber - mem.write_u32(PEB_BASE + 0xb0, 2).map_err(Stop::Fault)?; // PlatformId + .map_err(Stop::Fault)?; // ProcessHeap [obs] + mem.write_u32(PEB_BASE + 0x64, 1).map_err(Stop::Fault)?; // NumberOfProcessors [obs] + mem.write_u32(PEB_BASE + 0x68, 0).map_err(Stop::Fault)?; // NtGlobalFlag [obs] + mem.write_u32(PEB_BASE + 0xa4, 5).map_err(Stop::Fault)?; // OSMajorVersion [obs] + mem.write_u32(PEB_BASE + 0xa8, 1).map_err(Stop::Fault)?; // OSMinorVersion [obs] + mem.write_u32(PEB_BASE + 0xac, 2600).map_err(Stop::Fault)?; // OSBuildNumber [obs] + mem.write_u32(PEB_BASE + 0xb0, 2).map_err(Stop::Fault)?; // OSPlatformId [obs] // PEB_LDR_DATA followed by one LDR_DATA_TABLE_ENTRY per module. The // executable itself comes first in load and memory order, but not in @@ -1027,8 +1164,10 @@ impl<'a> Env<'a> { let mem_head = ldr + 0x14; let init_head = ldr + 0x1c; - mem.write_u32(ldr, 0x28).map_err(Stop::Fault)?; // Length - mem.write_u32(ldr + 4, 1).map_err(Stop::Fault)?; // Initialized + // PEB_LDR_DATA: Length (0x28), Initialized, and the three list heads at + // 0x0c / 0x14 / 0x1c [dbg]. + mem.write_u32(ldr, 0x28).map_err(Stop::Fault)?; // Length [dbg] + mem.write_u32(ldr + 4, 1).map_err(Stop::Fault)?; // Initialized [dbg] let init_idx: Vec = (0..list.len()).filter(|&i| list[i].in_init).collect(); let all_idx: Vec = (0..list.len()).collect(); @@ -1053,17 +1192,23 @@ impl<'a> Env<'a> { mem.write_u32(head + 4, last).map_err(Stop::Fault)?; Ok(()) }; + // LDR_DATA_TABLE_ENTRY links. InMemoryOrderLinks at 0x08 [wtl]. The + // load-order links at 0x00 follow from the [dbg] example, where the two + // lists' heads point 8 bytes apart into the same entry. The + // initialisation-order links fill the documented reserved slot at 0x10 + // [wtl], their meaning [obs]. link(mem, load_head, 0x00, &all_idx)?; link(mem, mem_head, 0x08, &all_idx)?; link(mem, init_head, 0x10, &init_idx)?; for (i, l) in list.iter().enumerate() { let e = addr_of(i); - mem.write_u32(e + 0x18, l.base).map_err(Stop::Fault)?; // DllBase - mem.write_u32(e + 0x1c, l.base).map_err(Stop::Fault)?; // EntryPoint - mem.write_u32(e + 0x20, l.size).map_err(Stop::Fault)?; // SizeOfImage - // FullDllName / BaseDllName as UNICODE_STRINGs pointing into the - // string area after the entries. + mem.write_u32(e + 0x18, l.base).map_err(Stop::Fault)?; // DllBase [wtl] + mem.write_u32(e + 0x1c, l.base).map_err(Stop::Fault)?; // EntryPoint: reserved slot [wtl], meaning [obs] + mem.write_u32(e + 0x20, l.size).map_err(Stop::Fault)?; // SizeOfImage: reserved slot [wtl], meaning [obs] + // FullDllName at 0x24 [wtl] and BaseDllName at 0x2c (reserved slot + // [wtl], meaning [obs]), as UNICODE_STRINGs into the string area + // after the entries. let str_addr = entry0 + list.len() as u32 * ENTRY_SIZE + i as u32 * 0x80; let wide: Vec = l .name @@ -1518,6 +1663,28 @@ impl<'a> Env<'a> { } self.file.len() as u32 } + Api::GetFileAttributesEx { wide } => { + // The program's own file exists and reports a size; every other + // path is absent, as with `CreateFile`. A stub that stats itself + // before unpacking (Alienyze does) needs the success answer, not + // the "file not found" that stops it. + let p = arg(cpu, mem, 0)?; + let path = read_str(mem, p, wide)?; + let out = arg(cpu, mem, 2)?; + if self.is_own_path(&path) && out != 0 { + // WIN32_FILE_ATTRIBUTE_DATA: attributes, three FILETIMEs, + // then the 64-bit size. Only the attributes and size carry + // information a stub acts on. + let mut data = [0u8; 0x24]; + data[0..4].copy_from_slice(&0x80u32.to_le_bytes()); // FILE_ATTRIBUTE_NORMAL + data[0x20..0x24].copy_from_slice(&(self.file.len() as u32).to_le_bytes()); + mem.write_bytes(out, &data).map_err(Stop::Fault)?; + 1 + } else { + self.last_error = 2; // ERROR_FILE_NOT_FOUND + 0 + } + } Api::CreateFileMapping => { let h = arg(cpu, mem, 0)?; if self.open_files.contains_key(&h) { @@ -1930,6 +2097,18 @@ fn normalize_module(name: &str) -> String { } } +/// The name a DLL's export directory gives itself: `KERNEL32.dll`, `ntdll.dll`. +/// Most system DLLs spell the stem in capitals; `ntdll` and `msvcrt` do not. +fn export_dir_name(normalized: &str) -> String { + if matches!(normalized, "ntdll.dll" | "msvcrt.dll") { + return normalized.to_string(); + } + match normalized.rsplit_once('.') { + Some((stem, ext)) => format!("{}.{ext}", stem.to_ascii_uppercase()), + None => normalized.to_ascii_uppercase(), + } +} + fn write_utf16(mem: &mut Mem, addr: u32, s: &str) -> Result<(), Stop> { let b: Vec = s .encode_utf16() @@ -2092,6 +2271,23 @@ mod tests { ); } + #[test] + fn a_synthetic_dll_names_itself_as_the_real_one_does() { + // The export directory's Name field is `KERNEL32.dll`, not the lower- + // case internal name. A stub that walks the loader list and checks the + // export name (tElock does, and spins when it does not match) has to see + // what a real module shows. + let (mut mem, _env) = env(); + for (base, want) in [(0x7c80_0000u32, "KERNEL32.dll"), (0x7c90_0000, "ntdll.dll")] { + let e_lfanew = mem.read_u32(base + 0x3c).unwrap(); + let dd = base + e_lfanew + 24 + 96; + let ed = base + mem.read_u32(dd).unwrap(); + let name_va = base + mem.read_u32(ed + 12).unwrap(); + let name = read_str(&mut mem, name_va, false).unwrap(); + assert_eq!(name, want); + } + } + /// A block copy reports the volume it moved, so the driver can charge for it. /// /// The tick budget bounds instructions executed, which stands in for work @@ -2264,6 +2460,84 @@ mod tests { assert!(env.is_trap(cpu.regs[EAX])); } + #[test] + fn the_export_directory_advertises_the_real_dll_names_not_just_implemented() { + // A stub that resolves imports by hashing export names walks the name + // table itself. The synthetic kernel32 therefore advertises the real + // DLL's exports, not only the handful the emulator implements, so such a + // walk finds its function. Alienyze fails exactly this way otherwise. + let (mut mem, mut env) = env(); + let base = 0x7c80_0000u32; + let e_lfanew = mem.read_u32(base + 0x3c).unwrap(); + let ed = base + mem.read_u32(base + e_lfanew + 24 + 96).unwrap(); + let n_names = mem.read_u32(ed + 24).unwrap(); + let names = base + mem.read_u32(ed + 32).unwrap(); + let funcs = base + mem.read_u32(ed + 28).unwrap(); + // `GetVolumeInformationA` is a real kernel32 export the emulator does not + // implement. It must still be in the directory, and resolve to a trap. + let mut found = None; + for i in 0..n_names { + let p = base + mem.read_u32(names + i * 4).unwrap(); + if read_str(&mut mem, p, false).unwrap() == "GetVolumeInformationA" { + found = Some(base + mem.read_u32(funcs + i * 4).unwrap()); + } + } + let addr = found.expect("an unimplemented real export is still advertised"); + assert!( + env.is_trap(addr), + "it resolves to a trap the driver services" + ); + + // Calling it is safe and observable: it returns failure and is recorded, + // never undefined behaviour. + let mut cpu = Cpu::new(); + cpu.regs[ESP] = INITIAL_ESP; + cpu.push32(&mut mem, 0xdead_0000).unwrap(); // return address + cpu.eip = addr; + let effect = env.call(&mut cpu, &mut mem).unwrap(); + assert_eq!(effect, ApiEffect::Unimplemented); + assert_eq!(cpu.regs[EAX], 0); + assert!(env + .missing_apis + .iter() + .any(|m| m.ends_with("GetVolumeInformationA"))); + } + + #[test] + fn get_file_attributes_ex_reports_the_program_exists_with_its_size() { + // The program's own file exists and reports its size; a stub that stats + // itself before unpacking (Alienyze does) needs the success answer, not + // the "not found" that stops it. Every other path is absent. + let mut mem = Mem::new(4096); + mem.map(0x0040_0000, 0x1000).unwrap(); + let file: &[u8] = &vec![0u8; 0x1234]; + let mut env = Env::new(&mut mem, 0x0040_0000, 0x1_0000, file).unwrap(); + + let name = 0x0050_0000u32; + let out = 0x0051_0000u32; + mem.map(name, 0x1000).unwrap(); + mem.map(out, 0x1000).unwrap(); + mem.write_bytes(name, b"C:\\sample.exe\0").unwrap(); + // GetFileAttributesExA(lpFileName, GetFileExInfoStandard, out). + assert_eq!( + call_k32(&mut env, &mut mem, "GetFileAttributesExA", &[name, 0, out]), + 1 + ); + assert_eq!(mem.read_u32(out).unwrap(), 0x80, "FILE_ATTRIBUTE_NORMAL"); + assert_eq!( + mem.read_u32(out + 0x20).unwrap(), + 0x1234, + "nFileSizeLow is the file length" + ); + + mem.write_bytes(name, b"C:\\other.exe\0").unwrap(); + assert_eq!( + call_k32(&mut env, &mut mem, "GetFileAttributesExA", &[name, 0, out]), + 0, + "a path that is not the program does not exist" + ); + } + #[test] fn loadlibrary_of_an_unknown_dll_creates_a_module() { let (mut mem, mut env) = env(); diff --git a/crates/exav-pe-emu/tests/end_to_end.rs b/crates/exav-pe-emu/tests/end_to_end.rs new file mode 100644 index 0000000..79fdcd9 --- /dev/null +++ b/crates/exav-pe-emu/tests/end_to_end.rs @@ -0,0 +1,249 @@ +//! End-to-end tests a consumer of the crate can run: build a PE in memory, run +//! its stub through [`exav_pe_emu::unpack`], and check the image that comes out. +//! +//! The fixtures are assembled here rather than committed as binaries, so nothing +//! third-party or packed is checked in. [`pe`] is the generator: a minimal but +//! real PE32 with the sections, entry point and (optionally) TLS directory each +//! test needs. + +use exav_pe_emu::{unpack, EmuLimits}; + +/// A minimal PE32 builder. The image has two sections, `.text` (the empty +/// destination a stub fills) at RVA 0x1000 and `.pack` (the stub's own bytes) +/// at RVA 0x2000, mapped at base 0x400000. The caller supplies `.pack`'s raw +/// bytes, the entry RVA, and an optional TLS directory RVA. +mod pe { + pub const BASE: u32 = 0x0040_0000; + pub const TEXT_RVA: u32 = 0x1000; + pub const PACK_RVA: u32 = 0x2000; + + pub struct Pe { + pub entry_rva: u32, + pub pack: Vec, + pub tls_dir_rva: Option, + } + + impl Pe { + pub fn build(&self) -> Vec { + let pe_off = 0x80usize; + let opt_size = 0xe0usize; + let sec_table = pe_off + 24 + opt_size; + let num = 2usize; + let headers = 0x400usize; + let pack_raw = headers; + let pack_len = self.pack.len().max(0x200); + let mut d = vec![0u8; pack_raw + pack_len]; + + d[..2].copy_from_slice(b"MZ"); + d[0x3c..0x40].copy_from_slice(&(pe_off as u32).to_le_bytes()); + d[pe_off..pe_off + 4].copy_from_slice(b"PE\0\0"); + let coff = pe_off + 4; + d[coff..coff + 2].copy_from_slice(&0x14cu16.to_le_bytes()); // i386 + d[coff + 2..coff + 4].copy_from_slice(&(num as u16).to_le_bytes()); + d[coff + 16..coff + 18].copy_from_slice(&(opt_size as u16).to_le_bytes()); + + let opt = coff + 20; + d[opt..opt + 2].copy_from_slice(&0x10bu16.to_le_bytes()); // PE32 + d[opt + 16..opt + 20].copy_from_slice(&self.entry_rva.to_le_bytes()); + d[opt + 28..opt + 32].copy_from_slice(&BASE.to_le_bytes()); + d[opt + 32..opt + 36].copy_from_slice(&0x1000u32.to_le_bytes()); // SectionAlignment + d[opt + 36..opt + 40].copy_from_slice(&0x200u32.to_le_bytes()); // FileAlignment + d[opt + 56..opt + 60].copy_from_slice(&0x3000u32.to_le_bytes()); // SizeOfImage + d[opt + 60..opt + 64].copy_from_slice(&0x400u32.to_le_bytes()); // SizeOfHeaders + // 16 data directories, so the TLS slot (9) is present. + d[opt + 92..opt + 96].copy_from_slice(&16u32.to_le_bytes()); + if let Some(tls_rva) = self.tls_dir_rva { + let dd = opt + 96 + 9 * 8; // data directory 9 + d[dd..dd + 4].copy_from_slice(&tls_rva.to_le_bytes()); + d[dd + 4..dd + 8].copy_from_slice(&0x18u32.to_le_bytes()); + } + + // Section 0: `.text`, the empty destination (no raw data). + let s0 = sec_table; + d[s0..s0 + 5].copy_from_slice(b".text"); + d[s0 + 8..s0 + 12].copy_from_slice(&0x1000u32.to_le_bytes()); // VirtualSize + d[s0 + 12..s0 + 16].copy_from_slice(&TEXT_RVA.to_le_bytes()); + d[s0 + 36..s0 + 40].copy_from_slice(&0xe000_0020u32.to_le_bytes()); + + // Section 1: `.pack`, holding the stub. + let s1 = sec_table + 40; + d[s1..s1 + 5].copy_from_slice(b".pack"); + d[s1 + 8..s1 + 12].copy_from_slice(&0x1000u32.to_le_bytes()); + d[s1 + 12..s1 + 16].copy_from_slice(&PACK_RVA.to_le_bytes()); + d[s1 + 16..s1 + 20].copy_from_slice(&(self.pack.len() as u32).to_le_bytes()); + d[s1 + 20..s1 + 24].copy_from_slice(&(pack_raw as u32).to_le_bytes()); + d[s1 + 36..s1 + 40].copy_from_slice(&0xe000_0020u32.to_le_bytes()); + + d[pack_raw..pack_raw + self.pack.len()].copy_from_slice(&self.pack); + d + } + } + + /// `.text`'s raw offset inside a dump, so a test can read the bytes a stub + /// wrote there. A memory-layout dump puts `PointerToRawData == VirtualAddress`. + pub fn text_offset_in_dump(dump: &[u8]) -> usize { + let pe = u32::from_le_bytes(dump[0x3c..0x40].try_into().unwrap()) as usize; + let opt = pe + 24; + let sec = opt + u16::from_le_bytes(dump[pe + 20..pe + 22].try_into().unwrap()) as usize; + u32::from_le_bytes(dump[sec + 20..sec + 24].try_into().unwrap()) as usize + } +} + +/// Limits with the dirty-byte floors dropped, because these hand-written stubs +/// rebuild a page, not the tens of kilobytes a real unpack produces. +fn limits() -> EmuLimits { + EmuLimits { + min_unpack_dirty: 0, + min_partial_dirty: 0, + ..Default::default() + } +} + +#[test] +fn a_decrypt_and_jump_stub_unpacks() { + // The archetype: XOR-decrypt a body from `.pack` into `.text`, then jump to + // it. Mirrors a runtime packer's tail transfer. + let plain: &[u8] = b"ORIGINAL PROGRAM"; + let cipher: Vec = plain.iter().map(|b| b ^ 0x5a).collect(); + + // src = PACK_RVA + 0x40 (the cipher text, below the stub). + let src = pe::BASE + pe::PACK_RVA + 0x40; + let dst = pe::BASE + pe::TEXT_RVA; + let mut stub = vec![ + 0xbe, 0, 0, 0, 0, // mov esi, src + 0xbf, 0, 0, 0, 0, // mov edi, dst + 0xb9, 0, 0, 0, 0, // mov ecx, len + 0xac, 0x34, 0x5a, 0xaa, 0xe2, 0xfa, // lodsb; xor al,0x5a; stosb; loop -6 + ]; + stub[1..5].copy_from_slice(&src.to_le_bytes()); + stub[6..10].copy_from_slice(&dst.to_le_bytes()); + stub[11..15].copy_from_slice(&(plain.len() as u32).to_le_bytes()); + // jmp dst (rel32 from the end of this instruction). + let jmp_at = pe::BASE + pe::PACK_RVA + stub.len() as u32; + let rel = dst.wrapping_sub(jmp_at + 5) as i32; + stub.push(0xe9); + stub.extend_from_slice(&rel.to_le_bytes()); + + let mut pack = vec![0u8; 0x40]; + pack[..stub.len()].copy_from_slice(&stub); + pack.extend_from_slice(&cipher); + + let file = pe::Pe { + entry_rva: pe::PACK_RVA, + pack, + tls_dir_rva: None, + } + .build(); + + let r = unpack(&file, &limits()); + let u = r.unpacked.expect("an image was reconstructed"); + assert!(u.reached_oep, "stopped at the tail jump: {}", r.stop); + assert_eq!(u.oep_rva, pe::TEXT_RVA); + let at = pe::text_offset_in_dump(&u.data); + assert_eq!(&u.data[at..at + plain.len()], plain); +} + +#[test] +fn a_stub_that_writes_nothing_yields_no_image() { + // `ret`: a stub that does nothing produces no reconstruction, and the crate + // reports that rather than handing back the input under an "unpacked" name. + let file = pe::Pe { + entry_rva: pe::PACK_RVA, + pack: vec![0xc3], + tls_dir_rva: None, + } + .build(); + let r = unpack(&file, &limits()); + assert!(r.unpacked.is_none(), "{}", r.stop); + assert_eq!(r.dirty_bytes, 0); +} + +/// Build the TLS fixture: a file whose entry point only jumps into `.text`, +/// which is empty until a TLS callback copies the payload there. Reaching the +/// original entry point therefore proves the callback ran before the entry +/// point, as the Windows loader runs it. +fn tls_fixture() -> Vec { + let payload: &[u8] = b"OEP-CODE-PLACED-BY-TLS-CALLBACK"; + + // Layout inside `.pack` (RVA 0x2000): + // 0x00 entry: jmp .text (nothing of its own to unpack) + // 0x10 callback: rep movsb payload -> .text; ret + // 0x60 payload bytes + // 0xA0 callback array: [callback_va, 0] + // 0xC0 TLS directory (IMAGE_TLS_DIRECTORY32) + // 0xE0 AddressOfIndex storage + let entry_off = 0x00u32; + let cb_off = 0x10u32; + let payload_off = 0x60u32; + let cb_array_off = 0xa0u32; + let tls_dir_off = 0xc0u32; + let index_off = 0xe0u32; + let va = |off: u32| pe::BASE + pe::PACK_RVA + off; + let dst = pe::BASE + pe::TEXT_RVA; + + let mut pack = vec![0u8; 0x100]; + + // entry: jmp .text + let rel = dst.wrapping_sub(va(entry_off) + 5) as i32; + pack[entry_off as usize] = 0xe9; + pack[entry_off as usize + 1..entry_off as usize + 5].copy_from_slice(&rel.to_le_bytes()); + + // callback(image_base, reason, reserved): copy the payload into .text, ret. + let cb = [ + vec![0xbf], + dst.to_le_bytes().to_vec(), // mov edi, .text + vec![0xbe], + va(payload_off).to_le_bytes().to_vec(), // mov esi, payload + vec![0xb9], + (payload.len() as u32).to_le_bytes().to_vec(), // mov ecx, len + vec![0xf3, 0xa4], // rep movsb + vec![0xc3], // ret + ] + .concat(); + pack[cb_off as usize..cb_off as usize + cb.len()].copy_from_slice(&cb); + + pack[payload_off as usize..payload_off as usize + payload.len()].copy_from_slice(payload); + + // Callback array: one callback, null-terminated. + pack[cb_array_off as usize..cb_array_off as usize + 4] + .copy_from_slice(&va(cb_off).to_le_bytes()); + + // IMAGE_TLS_DIRECTORY32: no raw data, AddressOfIndex -> our storage, + // AddressOfCallBacks -> the array. + let td = tls_dir_off as usize; + pack[td + 8..td + 12].copy_from_slice(&va(index_off).to_le_bytes()); // AddressOfIndex + pack[td + 12..td + 16].copy_from_slice(&va(cb_array_off).to_le_bytes()); // AddressOfCallBacks + + let _ = index_off; + pe::Pe { + entry_rva: pe::PACK_RVA, + pack, + tls_dir_rva: Some(pe::PACK_RVA + tls_dir_off), + } + .build() +} + +#[test] +fn a_tls_callback_runs_before_the_entry_point() { + // PE-1 coverage. The entry point only jumps into `.text`; `.text` holds the + // original code only because the TLS callback put it there first. If the + // callback did not run, `.text` is empty and nothing is recovered. + let payload: &[u8] = b"OEP-CODE-PLACED-BY-TLS-CALLBACK"; + let file = tls_fixture(); + let r = unpack(&file, &limits()); + let u = r + .unpacked + .expect("the TLS callback filled .text and the entry point reached it"); + assert!( + u.reached_oep, + "did not reach the original entry point: {}", + r.stop + ); + assert_eq!(u.oep_rva, pe::TEXT_RVA); + let at = pe::text_offset_in_dump(&u.data); + assert_eq!( + &u.data[at..at + payload.len()], + payload, + "the dump holds the code the TLS callback wrote" + ); +} diff --git a/crates/exav-render/Cargo.toml b/crates/exav-render/Cargo.toml index 7c574e9..ad3cf5a 100644 --- a/crates/exav-render/Cargo.toml +++ b/crates/exav-render/Cargo.toml @@ -1,17 +1,17 @@ [package] name = "exav-render" -description = "Memory-safe decoders that turn files into something to draw: images into pixels, DWG and DXF drawings and IFC and STL models into GPU-ready geometry." +description = "Memory-safe decoders that turn files into something to draw: images into pixels, DWG and DXF drawings and IFC, STL, OBJ, PLY and glTF models into GPU-ready geometry, and office documents (spreadsheets, presentations, text documents) into XLSX, PPTX and DOCX." version.workspace = true edition.workspace = true license.workspace = true rust-version.workspace = true repository.workspace = true homepage.workspace = true -keywords = ["image", "dwg", "ifc", "decoder", "cad"] +keywords = ["image", "dwg", "ifc", "office", "cad"] categories = ["multimedia::images", "rendering::data-formats"] readme = "README.md" -# The stroke font the build script compiles in, and its licence. -include = ["src/**/*", "fonts/**/*", "build.rs", "README.md", "LICENSE"] +# The stroke font the build script compiles in, and the licences and notices. +include = ["src/**/*", "fonts/**/*", "build.rs", "README.md", "LICENSE", "NOTICE"] # The vendored `tiff` decoder's doc examples are written for it as a crate of # its own: here their `use tiff::...` cannot resolve. @@ -31,12 +31,11 @@ gif = { version = "=0.14.1", optional = true } zune-jpeg = { version = "=0.5.8", default-features = false, features = ["std"], optional = true } zune-core = { version = "=0.5.0", optional = true } zune-core-04 = { package = "zune-core", version = "=0.4.12", optional = true } -# The vendored `tiff` decoder's dependencies, as tiff 0.10.3 has them, and -# `bytemuck` for its byte casts. +# The vendored `tiff` decoder's dependencies, as tiff 0.10.3 has them less +# `half`, and `bytemuck` for its byte casts. zune-jpeg-04 = { package = "zune-jpeg", version = "=0.4.21", default-features = false, features = ["std"], optional = true } fax34 = { package = "fax", version = "=0.2.6", optional = true } -flate2 = { version = "1.0.20", optional = true } -half = { version = "2.4.1", optional = true } +flate2 = { version = "1.1.10", optional = true } quick-error = { version = "2.0.1", optional = true } # Also `gif`'s LZW. 0.1.12, not libclamav's 0.1.10: it only corrects a debug # assertion that a TIFF stream without a clear code tripped; it decodes the same. @@ -49,17 +48,37 @@ bytemuck = { version = "1", optional = true } hayro-jpeg2000 = { version = "=0.4.1", default-features = false, features = ["std"], optional = true } hayro-jbig2 = { version = "=0.3.1", default-features = false, features = ["std"], optional = true } hayro-ccitt = { version = "=0.4.0", optional = true } +# JPEG XL: zenjxl-decoder, a fork of the libjxl team's jxl-rs that is +# `#![forbid(unsafe_code)]` unless its `allow-unsafe` feature is on, with +# limits on pixels and memory. Built without its SIMD (the default +# `all-simd`) and without `threads`; no CMS, no JPEG reconstruction. +zenjxl-decoder = { version = "=0.3.10", default-features = false, optional = true } # DXF's pairs, records and code pages, and DWG's bit codes, sections and # objects, which the drawing model (`cad`) is read from; those formats # alone, none of the scanner's others. exav-unpack = { path = "../exav-unpack", version = "0.0.2", default-features = false, features = ["dxf", "dwg"], optional = true } +# Markdown: CommonMark with tables and strikethrough, read as events. Without +# its `html` and `simd` features: no markup is made, and its only `unsafe` +# (the SIMD scanners) is off. +pulldown-cmark = { version = "=0.13.4", default-features = false, optional = true } +# The office converters (`office-*`): packages are read, and the XLSX, PPTX +# and DOCX written, as ZIP. +zip = { version = "9", default-features = false, features = ["deflate-flate2"], optional = true } +# ODF's XML. 0.41 bounds namespace declarations per element and checks +# duplicate attributes in linear time (RUSTSEC-2026-0194, -0195). +quick-xml = { version = "0.42", optional = true } +# Text in Windows and Mac code pages; the DOS ones are tables of our own. +encoding_rs = { version = "0.8.42", default-features = false, features = ["alloc"], optional = true } +# XLS decryption with the default password ([MS-OFFCRYPTO] RC4, RC4 CryptoAPI). +md-5 = { version = "0.11", optional = true } +sha1 = { version = "0.11", optional = true } [features] -default = ["image", "webp", "ico", "pnm", "qoi", "dds", "ff", "hdr", "jp2", "jbig2", "ccitt"] +default = ["image", "webp", "ico", "pnm", "qoi", "dds", "ff", "hdr", "jp2", "jbig2", "ccitt", "raw"] # Raster images: PNG, GIF, JPEG, TIFF and BMP, plus the formats below. image = [ "dep:image", "dep:png", "dep:gif", "dep:zune-jpeg", "dep:zune-core", "dep:zune-core-04", - "dep:zune-jpeg-04", "dep:fax34", "dep:flate2", "dep:half", "dep:quick-error", "dep:weezl", + "dep:zune-jpeg-04", "dep:fax34", "dep:flate2", "dep:quick-error", "dep:weezl", "dep:bytemuck", ] # Each is a decoder without `unsafe`: image-webp and qoi are @@ -80,6 +99,14 @@ jp2 = ["dep:hayro-jpeg2000"] jbig2 = ["dep:hayro-jbig2"] # A PDF's CCITTFaxDecode streams (`pdf_image`). ccitt = ["dep:hayro-ccitt"] +# Camera RAW files (DNG, CR2, CR3, NEF, ARW, ORF, RW2, RAF, PEF, SRW, 3FR, +# IIQ, X3F...), shown by the largest JPEG or RGB preview they embed +# (`image::raw`). The sensor data is not decoded. +raw = ["image"] +# JPEG XL, first frame, as 8-bit grey or RGBA. Not a default: libclamav does +# not decode it, so the scanner's image hash has no use for it, and it is the +# largest decoder here. +jxl = ["image", "dep:zenjxl-decoder"] # DXF (R12 to 2018) and DWG (R13 to 2018) into the drawing model # (`cad`), tessellated for a GPU. dwg = ["dep:exav-unpack"] @@ -89,11 +116,49 @@ vp-debug = ["dwg"] ifc = [] # Binary and ASCII STL into the same meshes (`stl`, `mesh`). stl = [] +# Wavefront OBJ (`obj`), Stanford PLY (`ply`) and glTF 2.0 with GLB (`gltf`, +# with a JSON reader of its own) into the same meshes. +obj = [] +ply = [] +gltf = [] +# Markdown into a tree of blocks and inlines (`markdown`), as data: no HTML +# is ever made. +markdown = ["dep:pulldown-cmark"] +# The text of a document as a reader sees it, one entry for every kind that has +# some (`text`): Markdown, HTML, Office and OpenDocument XML parts, PDF (hayro) +# and drawings. For a scanner or a search; the viewer does not use it. +text = ["markdown", "dwg", "exav-unpack/text"] +# And PDF's, read by hayro: the largest part of `text`. +text-pdf = ["text", "exav-unpack/text-pdf"] +# And the office formats' the XML parts do not reach, from what `office` +# reads: DOC, XLS, PPT, RTF, Word 2003 XML, SpreadsheetML 2003, SYLK, flat +# OpenDocument, Numbers, Pages, Keynote. +text-office = ["text", "office"] +# What every office converter shares (`formats::office::common`): ZIP +# packages, ODF's XML, code pages. Not a feature to ask for. +_office = ["dep:zip", "dep:flate2", "dep:quick-xml", "dep:encoding_rs"] +# Spreadsheets into XLSX (`sheets`): Excel 2 to 2003, ODS, FODS, OTS, Apple +# Numbers, XML Spreadsheet 2003, HTML tables and SYLK. Also the chart writer +# the presentations use for ODF charts. +office-sheets = ["_office", "dep:md-5", "dep:sha1"] +# Presentations into PPTX (`slides`): PowerPoint 95 and 97 to 2003, ODP, FODP, +# OTP and Apple Keynote; drawings: ODG, OTG, FODG and Visio (VSDX). +office-slides = ["_office", "dep:md-5", "dep:sha1"] +# Text documents into DOCX (`docs`): Word 6 to 2003, ODT, FODT, OTT, RTF, +# Word 2003 XML and Apple Pages. +office-docs = ["_office"] +# Every office converter. +office = ["office-sheets", "office-slides", "office-docs"] +# Writers of compound files, protobuf messages and Snappy blocks, for the +# tests that build the documents they read. Not a feature to ask for. +office-test-util = ["_office"] [dev-dependencies] serde_json = "1" +# The strict compound file reader the lenient one of `office` is checked against. +cfb = "0.15.0" # The drawing fixtures are gzipped. -flate2 = "1.0.20" +flate2 = "1.1.10" # The DWG oracle test names local corpus files by hash. sha2 = "0.11" @@ -129,6 +194,14 @@ required-features = ["dwg"] name = "ifc" required-features = ["ifc"] +[[test]] +name = "ifc_geometry" +required-features = ["ifc"] + +[[test]] +name = "ifc_hostile" +required-features = ["ifc"] + [[test]] name = "ifc_oracle" required-features = ["ifc"] @@ -137,6 +210,102 @@ required-features = ["ifc"] name = "stl" required-features = ["stl"] +[[test]] +name = "obj" +required-features = ["obj"] + +[[test]] +name = "ply" +required-features = ["ply"] + +[[test]] +name = "gltf" +required-features = ["gltf"] + +[[test]] +name = "jxl" +required-features = ["jxl"] + +[[test]] +name = "raw" +required-features = ["raw"] + +[[test]] +name = "markdown" +required-features = ["markdown"] + +[[test]] +name = "markdown_text" +required-features = ["markdown"] + +[[test]] +name = "text" +required-features = ["text"] + +[[test]] +name = "office_sheets" +required-features = ["office-sheets"] + +[[test]] +name = "office_text_sheets" +required-features = ["office-sheets"] + +[[test]] +name = "office_slides" +required-features = ["office-slides", "office-test-util"] + +[[test]] +name = "office_slides_draw" +required-features = ["office-slides"] + +[[test]] +name = "office_text_slides" +required-features = ["office-slides"] + +[[test]] +name = "office_slides_vsdx" +required-features = ["office-slides", "office-test-util"] + +[[test]] +name = "office_slides_keynote_xml" +required-features = ["office-slides"] + +[[test]] +name = "office_slides_odp_features" +required-features = ["office-slides"] + +[[test]] +name = "office_slides_old_ppt" +required-features = ["office-slides", "office-test-util"] + +[[test]] +name = "office_docs_doc" +required-features = ["office-docs", "office-test-util"] + +[[test]] +name = "office_docs_formats" +required-features = ["office-docs", "office-test-util"] + +[[test]] +name = "office_text_docs" +required-features = ["office-docs", "office-test-util"] + +[[example]] +name = "office_docs_convert" +required-features = ["office-docs"] + +[[example]] +name = "office_slides_convert" +required-features = ["office-slides"] + +[[example]] +name = "office_text" +required-features = ["text-office"] + +[[example]] +name = "raw_preview" +required-features = ["raw"] + [[example]] name = "cad_dump" required-features = ["dwg"] diff --git a/crates/exav-render/NOTICE b/crates/exav-render/NOTICE new file mode 100644 index 0000000..65741e1 --- /dev/null +++ b/crates/exav-render/NOTICE @@ -0,0 +1,231 @@ +exav-render: notices +==================== + +exav-render is licensed under the MIT License (see LICENSE). This file +covers the office converters (the `office-sheets`, `office-slides` and +`office-docs` features, `src/formats/office/`). The vendored image decoders +keep their licences in `src/image_codecs/` and the stroke font its +dedication in `fonts/`; the notices of the rest of the workspace are in the +repository's NOTICE. + +The office converters are written from published specifications and from the +black-box behaviour of other converters (their output on sample files). No +code of another office application is in them: + +- no code of another spreadsheet application is in `sheets`; +- no code of another presentation application is in `slides`; +- no code of another word-processing application is in `docs`, and none was + read while writing it; +- `common` is written from published specifications alone. + +Specifications and documentation they follow +-------------------------------------------- + +Shared (`common`): + +- Microsoft's Open Specifications: [MS-CFB], under the Microsoft Open + Specifications Promise. +- OASIS OpenDocument 1.2 and 1.3, for the namespaces. +- Google's Snappy format description, and the protocol buffers encoding + documented at protobuf.dev. + +Spreadsheets (`sheets`): + +- Microsoft's Open Specifications: [MS-XLS], [MS-OFFCRYPTO], [MS-ODRAW], + [MS-CFB], under the Microsoft Open Specifications Promise. +- OpenOffice.org's "Microsoft Excel File Format" documentation (Daniel + Rentz, Public Documentation License 1.0), for the BIFF2 to BIFF5 record + layouts [MS-XLS] does not give, and the argument counts of + src/formats/office/sheets/xls/ftab.rs (generated by + scripts/office/sheets/ftab.py). +- OASIS OpenDocument 1.2 and 1.3, and OpenFormula. +- ECMA-376 (Office Open XML), for the XLSX written. +- Google's Snappy format description, and the protocol buffers encoding + documented at protobuf.dev. +- Microsoft's "XML Spreadsheet Reference" (Office XP, 2001) for + SpreadsheetML; the "Microsoft Office HTML and XML Reference" (Office + 2000) for Excel's HTML; Microsoft's "SYLK summary" (sylksum.doc, 1986) + and knowledge base article Q64338 for SYLK, with ISO 6937 for its + escapes; ECMA-376 for column widths in pixels; the W3C's CSS Color + Module Level 4 for colour names. + +Presentations (`slides`): + +- Microsoft's Open Specifications: [MS-PPT], [MS-ODRAW], [MS-CFB], + [MS-OSHARED] and [MS-OFFCRYPTO] (to recognise encrypted files), under the + Microsoft Open Specifications Promise. +- ECMA-376 (Office Open XML) Part 1, for the PPTX written, and its + presetShapeDefinitions.xml, for the preset shapes' names and adjustments. +- OASIS OpenDocument 1.2 (Part 1) and 1.3 (Part 3, schema). +- Google's Snappy format description, and the protocol buffers encoding + documented at protobuf.dev. +- Apple's Technical Notes TN2067 (About the Keynote XML File Format, APXL + Schema) and TN2073 (Deconstructing a Keynote Document: Part One, + Slides), for Keynote 1 (src/formats/office/slides/keynote/apxl1.rs). +- RFC 1741 (BinHex 4.0), to find the data fork of a Macintosh PowerPoint + file saved as text. +- Visio drawings (src/formats/office/slides/vsdx): Microsoft's Visio file + format reference (Visio 2013 file format, learn.microsoft.com/office/ + client-developer/visio: the Introduction, Schema map, Elements and Types + pages and the ShapeSheet reference pages of the geometry rows, the fill, + line, character, paragraph, text block, layer and hyperlink cells, and the + THEMEVAL and NURBS functions); [MS-VSDX] (Visio Graphics Service VSDX File + Format), under the Microsoft Open Specifications Promise, for the package + parts, the coordinate transforms, the inheritance of masters and styles, + the colour table, the fill patterns, line patterns and line ends (pictures + in it), the formula grammar and the theme; ECMA-376 Part 2 (Open + Packaging Conventions), for the package and its relationships. + +Text documents (`docs`): + +- Microsoft's Open Specifications: [MS-DOC], [MS-ODRAW], [MS-OSHARED] and + [MS-CFB], under the Microsoft Open Specifications Promise. +- ECMA-376 (Office Open XML) Part 1, for the DOCX written. +- OASIS OpenDocument 1.2 (Part 1) and 1.3 (Part 3, schema). +- Microsoft's Rich Text Format specification, version 1.9.1, and the + WordprocessingML 2003 element and attribute names, for + src/formats/office/docs/rtf and src/formats/office/docs/wml. +- Google's Snappy format description, and the protocol buffers encoding + documented at protobuf.dev, for Pages (through `common`). + +Formats nobody published +------------------------ + +- Microsoft published no description of PowerPoint 95 and of PowerPoint 2 to + 4, and Apple none of Keynote 2 to 5. src/formats/office/slides/ppt/ppt95.rs + and src/formats/office/slides/keynote/apxl.rs were written from sample + documents, the output of another office application on them used as a black + box (rendered pages, never its source code), and the structure of the files themselves. + src/formats/office/slides/ppt/old.rs reads only the headers of PowerPoint + 2 to 4, to report them as unsupported. + +- Apple published none of Pages. src/formats/office/docs/pages was written + from sample documents and the structure of the files themselves (the + document message's page size and margin fields were found there), with the + text messages' layout below. + +- The Numbers '08 and '09 reader (src/formats/office/sheets/numbers/v09.rs) + follows no description of the format: Apple published none. It was written + from sample documents (Apache Tika's, below, and others used only as + inputs), with another application's conversions of them as a black-box + reference. + +- The Visio reader (src/formats/office/slides/vsdx) is written from the + specifications listed above and from sample drawings used only as inputs: + 48 VSDX files collected from public bug trackers and test suites of other + projects (not part of this repository), to see what real files hold. Where + the specifications are silent, the reader follows what those files and + another application's rendering of them show, used as a black box (its PDF + of each file compared with ours, never any source code, which was not + read): the quick + style colours 200 to 206 (read as 100 to 106), the side an arc's bow + bulges to, and that a `Themed` value with no quick style of its own is the + root style sheet's. The gradient patterns 25 to 40 are approximations of + the pictures in [MS-VSDX]. Formats the reader leaves out: the binary .vsd + and the Visio 2003 XML .vdx. + +Data from other projects +------------------------ + +- src/formats/office/sheets/numbers/functions.rs (Numbers' function indexes) + and src/formats/office/sheets/numbers/fonts.rs (PostScript names of macOS + fonts and their families) are generated, by + scripts/office/sheets/numbers_functions.py and + scripts/office/sheets/numbers_fonts.py, from numbers-parser + (https://github.com/masaccio/numbers-parser), MIT: + + Copyright 2021 Jon Connell + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS 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. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + The layout of the Numbers messages read (their field numbers) follows the + message definitions numbers-parser publishes under the same licence; the + older cell records follow SheetJS's reader (Apache-2.0). The record layout + of Keynote tables (src/formats/office/slides/keynote/table.rs) follows, for + the cell records, numbers-parser too, under the same licence. + +- The layout of the Keynote and Pages messages read (their type and field + numbers, named where src/formats/office/slides/keynote and + src/formats/office/docs/pages read them) follows the message definitions + keynote-parser (https://github.com/psobot/keynote-parser) publishes, MIT: + + Copyright 2019-2025 Peter Sobot + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS 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. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +- The code page tables (src/formats/office/common/codepage_tables.rs and + src/formats/office/sheets/codepage_tables.rs: the DOS and Mac code pages + encoding_rs does not have) are generated by + scripts/office/common/codepages.py and scripts/office/sheets/codepages.py + from the mapping tables of Python's codecs, which are the Unicode + Consortium's published mappings. + +Test fixtures +------------- + +The fixtures are in tests/fixtures/office and are not packaged with the +crate. + +- sheets/sample.numbers is numbers-parser's tests/data/issue-18.numbers (MIT, + as above); see sheets/README.md. + +- sheets/numbers09.numbers is Apache Tika's test document + testNumbers.numbers, unmodified, under the Apache License 2.0 + (https://www.apache.org/licenses/LICENSE-2.0). Apache Tika, Copyright The + Apache Software Foundation; it includes software developed at The Apache + Software Foundation (http://www.apache.org/). It is a test input only. + +- slides/sample.key is keynote-parser's tests/data/simple-oneslide.key, under + the same licence as keynote-parser above. + +- draw/make_vsdx.py writes sample.vsdx and themed.vsdx, Visio drawings of + shapes written for this crate (CC0); see draw/README.md. + +- docs/make.py writes sample.odt, sample.fodt and sample.rtf from text + written for this crate (CC0). docs/sample.doc is a conversion + of sample.odt by an office application, and docs/sample-2003.xml was written by hand; none holds + anyone else's content. + +- tests/fixtures/dwg/make.py writes the drawings as DXF with ezdxf (MIT, + not distributed). The `.dwg.gz` files are those drawings converted to DWG by + the Open Design Alliance's ODA File Converter, run as a black box on the + maintainers' machines: no code or data of the converter is in the repository, + and the drawings are this crate's own, written for the tests and under its + licence. The converter's own terms + (https://www.opendesign.com) apply to the converter, not to the files it was + given. The DWG files carry the file-format markers ODA's writer puts in every + file. diff --git a/crates/exav-render/README.md b/crates/exav-render/README.md index 9ec8a07..25c1eac 100644 --- a/crates/exav-render/README.md +++ b/crates/exav-render/README.md @@ -13,11 +13,40 @@ behind [`@exav/viewer`](../exav-viewer) and the decoding half of code (its only `unsafe`); see the README there. JPEG 2000 and JBIG2 files too (`jp2`, `jbig2`, default), through hayro-jpeg2000 and hayro-jbig2 built without their SIMD code (libclamav decodes neither; - exav-imagehash hashes them like the others). + exav-imagehash hashes them like the others). JPEG XL (`jxl`, not a + default) goes through zenjxl-decoder, a fork of the libjxl team's jxl-rs + that is `forbid(unsafe_code)`, built without its SIMD: the first frame, as + 8-bit grey or RGBA, within the caller's memory budget. +- **Camera RAW files** (`raw`, default): the largest JPEG or RGB preview a + RAW file embeds, as 8-bit pixels with the Exif orientation applied + (`image::raw`, and `decode_any`). DNG, Canon CR2, CR3 and CRW, Nikon NEF and + NRW, Sony ARW, SR2 and SRF, Olympus ORF, Panasonic RW2, RAW and Leica RWL, + Fujifilm RAF, Pentax PEF, Samsung SRW, Hasselblad 3FR, Phase One IIQ, Sigma + X3F, Kodak, Epson, Mamiya and Leaf, told by their bytes, not their names: a + plain TIFF is not one of them and the TIFF decoder reads it. The sensor + data is not decoded, so a file that holds no preview (most phones' DNG) is + `Error::NoPreview`. The previews come from the tags TIFF 6.0 and DNG + define, and from a search of the file for the JPEG streams the makers keep + elsewhere. Written from the public specifications, see `raw.rs`. - **PDF images** (`jp2`, `jbig2`, `ccitt`): the JPXDecode, JBIG2Decode and CCITTFaxDecode images of PDF streams, into the buffers pdf.js reads from its own decoders (`src/formats/pdf_image/`), which @exav/viewer gives pdf.js in place of its OpenJPEG and PDFium builds. +- **Markdown** (`markdown`, not a default): CommonMark with tables and + strikethrough, read by pulldown-cmark (without its `html` and `simd` + features: `forbid(unsafe_code)`) into a tree of blocks and inlines, as + data. Raw HTML is text, an image is its alt text, a link is kept only for + an absolute http, https or mailto address; nesting, columns and nodes are + bounded. `Document::to_json` is the shape @exav/viewer's page reads. +- **The text of a document** (`text`, `text-pdf`, `text-office`; not used by + the viewer): `text::visible_text` gives the words a reader sees, for a + scanner or a search: Markdown, HTML, the XML parts of Office and + OpenDocument files, DWG and DXF, PDF (`text-pdf`, through hayro) and, with + `text-office`, DOC, XLS, PPT, RTF, Word 2003 XML, SpreadsheetML 2003, SYLK, + the flat OpenDocument files, Numbers, Pages and Keynote, taken from what + the office converters read, in sections (a sheet, a slide). A link is its + label then its target. A document of those kinds whose text could not be + taken out says so in `Visible::unreadable`. - **DWG and DXF** (`dwg`): read into the drawing model below and tessellated into GPU-ready buffers: strokes with their lineweight, fills, text runs, layers and layouts. See `src/formats/dwg/`. @@ -37,10 +66,50 @@ behind [`@exav/viewer`](../exav-viewer) and the decoding half of (`mesh::Scene`): batches by class and colour, each element's ranges, its GlobalId, class, name and storey, the spatial tree. Written from buildingSMART's schemas and documentation, booleans and openings - included. See `src/formats/ifc/`. Not drawn faithfully yet: conical, - spherical and toroidal B-rep faces (flat), B-spline face trimming, IFC4X3 - alignments, sweep start/end parameters, grid placements (listed on - exav.org's exav-render page). + included. See `src/formats/ifc/`. Wavefront OBJ (`obj`), Stanford PLY + (`ply`) and glTF 2.0 with GLB (`gltf`, with a JSON reader of its own and + no dependency) read into the same meshes, never opening another file. + B-rep faces on spheres, tori, cylinders and B-spline surfaces are meshed + in their surface's parameters (B-spline faces cut by their bounds), + sweeps honour their start and end parameters, IFC4X3 curve segments + (lines, circles, clothoids and the other spirals, polynomials), gradient + curves and linear placements along them are evaluated, grid placements + sit at their grid intersections, and each element and batch has its + building storey (`Element::storey`, `Batch::storey`, `Node::elevation`). + What is not drawn yet is on exav.org's exav-render page. + +- **Office documents** (`office-sheets`, `office-slides`, `office-docs`, or + `office` for the three; not defaults): converted to the Open XML package an + Open XML renderer draws, in the browser behind `@exav/viewer`. A + conversion is bounded by `Limits` (input, cells, slides, paragraphs, + memory, each decompressed part) and fails with `Error::TooLarge` rather + than convert part of a document. Nothing is recalculated and no macro runs. + - **Spreadsheets** (`sheets`, to XLSX): Excel 2.x to 2003 (BIFF2 to BIFF8, + a lenient compound file reader, default-password encryption), ODS, FODS + and OTS, Apple Numbers (Numbers 3 and later, and '08 and '09), and the + text formats Excel opens from a .xls file: XML Spreadsheet 2003, HTML + tables and SYLK. Values, formulas as text with their cached results, + styles and number formats, merges, conditional formats, validations, + comments, links, pictures, shapes and charts. + - **Presentations** (`slides`, to PPTX): PowerPoint 95 and 97 to 2003 + (2 to 4 are recognised and reported as unsupported), ODP, FODP and OTP, + and Apple Keynote in every version. Masters, placeholders, styles, + shapes and their geometry, text, pictures, tables and charts, every + property resolved in the PPTX written. `convert_first` converts the first + slides only, `convert_first_from` reads a package part by part from any + `Read + Seek` source. Drawings too, a page to a slide in the pages' + largest size: ODG, OTG and FODG, and Visio 2013 and later (VSDX, VSDM, + VSTX, VSTM: shapes through their masters and styles, formulas where a + master's depend on the instance, geometry rows, splines, text, pictures, + layers, hyperlinks, the theme's colours; the binary .vsd and the Visio + 2003 XML .vdx are not read). + - **Text documents** (`docs`, to DOCX): Word 6 to 2003, ODT, OTT and FODT, + RTF, Word 2003 XML and Apple Pages (text, page size and margins). Styles + stay styles; sections with headers and footers, lists, tables, notes, + comments, fields, pictures and OfficeArt shapes. + - Encrypted files are refused. Fuzzed by the `sheets_*`, `slides_*` and + `docs_*` targets (docs/FUZZING.md). See NOTICE for the specifications + and data they are written from. The crate is `forbid(unsafe_code)`. @@ -53,5 +122,12 @@ let rgba = pixels.to_rgba8(); # Ok::<(), Box>(()) ``` +```rust,ignore +// With the `office-sheets` feature. +use exav_render::sheets::{convert, Format, Limits}; + +let xlsx = convert(&bytes, Format::Ods, &Limits::default())?.xlsx; +``` + MIT. The vendored decoders keep their licences (`src/image_codecs/`), and the NewStroke stroke font its CC0-1.0 dedication (`fonts/`). diff --git a/crates/exav-render/examples/office_docs_convert.rs b/crates/exav-render/examples/office_docs_convert.rs new file mode 100644 index 0000000..a72984d --- /dev/null +++ b/crates/exav-render/examples/office_docs_convert.rs @@ -0,0 +1,108 @@ +//! Converts files from the command line: +//! `cargo run -p exav-render --features office-docs --example office_docs_convert -- `. +//! +//! `convert one ` converts one file. +//! `convert batch ` converts every file of a +//! directory, writing `/.docx` and printing one JSON line +//! per file: its name, the outcome, the time in microseconds, the size of +//! the DOCX, and the warnings. + +use exav_render::docs::{convert, Format, Limits}; +use std::time::Instant; + +fn format(name: &str) -> Format { + Format::from_extension(name).unwrap_or_else(|| { + eprintln!("unknown format {name}"); + std::process::exit(2); + }) +} + +fn json(s: &str) -> String { + let mut o = String::from("\""); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + c if (c as u32) < 0x20 => o.push(' '), + c => o.push(c), + } + } + o.push('"'); + o +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let limits = Limits::default(); + match args.iter().map(String::as_str).collect::>()[..] { + ["one", fmt, input, output] => { + let bytes = std::fs::read(input).expect("read the input"); + match convert(&bytes, format(fmt), &limits) { + Ok(c) => { + std::fs::write(output, &c.docx).expect("write the output"); + for w in &c.warnings { + eprintln!("warning: {w}"); + } + } + Err(e) => { + eprintln!("{}: {e}", e.kind()); + std::process::exit(1); + } + } + } + ["batch", fmt, dir, out] => { + let fmt = format(fmt); + std::fs::create_dir_all(out).expect("create the output directory"); + let mut names: Vec<_> = std::fs::read_dir(dir) + .expect("read the directory") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .collect(); + names.sort(); + for p in names { + let name = p + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let Ok(bytes) = std::fs::read(&p) else { + continue; + }; + let t = Instant::now(); + let r = std::panic::catch_unwind(|| convert(&bytes, fmt, &Limits::default())); + let us = t.elapsed().as_micros(); + match r { + Ok(Ok(c)) => { + let stem = name.rsplit_once('.').map(|s| s.0).unwrap_or(&name); + std::fs::write(format!("{out}/{stem}.docx"), &c.docx).expect("write"); + let w: Vec = c.warnings.iter().map(|w| json(w)).collect(); + println!( + "{{\"name\":{},\"ok\":true,\"us\":{us},\"size\":{},\"paragraphs\":{},\"warnings\":[{}]}}", + json(&name), + c.docx.len(), + c.paragraphs, + w.join(",") + ); + } + Ok(Err(e)) => println!( + "{{\"name\":{},\"ok\":false,\"kind\":\"{}\",\"error\":{},\"us\":{us}}}", + json(&name), + e.kind(), + json(&e.to_string()) + ), + Err(_) => println!( + "{{\"name\":{},\"ok\":false,\"kind\":\"panic\",\"error\":\"panic\",\"us\":{us}}}", + json(&name) + ), + } + } + } + _ => { + eprintln!( + "usage: convert one | convert batch " + ); + std::process::exit(2); + } + } +} diff --git a/crates/exav-render/examples/office_slides_convert.rs b/crates/exav-render/examples/office_slides_convert.rs new file mode 100644 index 0000000..864563e --- /dev/null +++ b/crates/exav-render/examples/office_slides_convert.rs @@ -0,0 +1,106 @@ +//! Converts presentations and drawings from the command line: +//! `cargo run -p exav-render --features office-slides --example office_slides_convert -- `. +//! +//! `one ` converts one file. +//! `batch ` converts every file of a directory, +//! writing `/.pptx` and printing one JSON line per file: its +//! name, the outcome, the time in microseconds, the size of the PPTX, the +//! slides, and the warnings. + +use exav_render::slides::{convert, Format, Limits}; +use std::time::Instant; + +fn format(name: &str) -> Format { + Format::from_extension(name).unwrap_or_else(|| { + eprintln!("unknown format {name}"); + std::process::exit(2); + }) +} + +fn json(s: &str) -> String { + let mut o = String::from("\""); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + c if (c as u32) < 0x20 => o.push(' '), + c => o.push(c), + } + } + o.push('"'); + o +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let limits = Limits::default(); + match args.iter().map(String::as_str).collect::>()[..] { + ["one", fmt, input, output] => { + let bytes = std::fs::read(input).expect("read the input"); + match convert(&bytes, format(fmt), &limits) { + Ok(c) => { + std::fs::write(output, &c.pptx).expect("write the output"); + for w in &c.warnings { + eprintln!("warning: {w}"); + } + } + Err(e) => { + eprintln!("{}: {e}", e.kind()); + std::process::exit(1); + } + } + } + ["batch", fmt, dir, out] => { + let fmt = format(fmt); + std::fs::create_dir_all(out).expect("create the output directory"); + let mut names: Vec<_> = std::fs::read_dir(dir) + .expect("read the directory") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .collect(); + names.sort(); + for p in names { + let name = p + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let Ok(bytes) = std::fs::read(&p) else { + continue; + }; + let t = Instant::now(); + let r = std::panic::catch_unwind(|| convert(&bytes, fmt, &Limits::default())); + let us = t.elapsed().as_micros(); + match r { + Ok(Ok(c)) => { + let stem = name.rsplit_once('.').map(|s| s.0).unwrap_or(&name); + std::fs::write(format!("{out}/{stem}.pptx"), &c.pptx).expect("write"); + let w: Vec = c.warnings.iter().map(|w| json(w)).collect(); + println!( + "{{\"name\":{},\"ok\":true,\"us\":{us},\"size\":{},\"slides\":{},\"warnings\":[{}]}}", + json(&name), + c.pptx.len(), + c.slides, + w.join(",") + ); + } + Ok(Err(e)) => println!( + "{{\"name\":{},\"ok\":false,\"kind\":\"{}\",\"error\":{},\"us\":{us}}}", + json(&name), + e.kind(), + json(&e.to_string()) + ), + Err(_) => println!( + "{{\"name\":{},\"ok\":false,\"kind\":\"panic\",\"error\":\"panic\",\"us\":{us}}}", + json(&name) + ), + } + } + } + _ => { + eprintln!("usage: one | batch "); + std::process::exit(2); + } + } +} diff --git a/crates/exav-render/examples/office_text.rs b/crates/exav-render/examples/office_text.rs new file mode 100644 index 0000000..5493b8f --- /dev/null +++ b/crates/exav-render/examples/office_text.rs @@ -0,0 +1,87 @@ +//! The text of office documents, as the scanner and `exav-grep --text` read it: +//! `cargo run --release -p exav-render --features text-office --example office_text -- `. +//! +//! `one ` prints the file's sections and its text, and the links. +//! `bench ` reads every file of a directory (not recursively) and prints +//! one JSON line each, the name, the outcome, the microseconds and the text's +//! bytes, then the totals. + +use exav_render::text::visible_text; +use std::time::Instant; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let limits = exav_unpack::Limits::default(); + match args.iter().map(String::as_str).collect::>()[..] { + ["one", file] => { + let data = std::fs::read(file).expect("read the file"); + let name = file.rsplit('/').next().unwrap_or(file); + match visible_text(name, &data, 1 << 24, &limits) { + None => println!("no text view"), + Some(v) => { + if let Some(why) = &v.unreadable { + println!("unreadable: {why}"); + } + for (label, range) in &v.sections { + println!("--- {label} ({} bytes)", range.len()); + } + print!("{}", v.text); + for (label, target) in &v.links { + println!("link: {label} -> {target}"); + } + } + } + } + ["bench", dir] => { + let mut names: Vec<_> = std::fs::read_dir(dir) + .expect("read the directory") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file()) + .collect(); + names.sort(); + let (mut bytes, mut micros, mut read) = (0u64, 0u128, 0u32); + for p in names { + let Ok(data) = std::fs::read(&p) else { + continue; + }; + let name = p + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let t = Instant::now(); + let r = std::panic::catch_unwind(|| { + visible_text(&name, &data, 1 << 24, &exav_unpack::Limits::default()) + }); + let us = t.elapsed().as_micros(); + let outcome = match &r { + Ok(Some(v)) if v.unreadable.is_some() => "unreadable", + Ok(Some(_)) => "text", + Ok(None) => "none", + Err(_) => "panic", + }; + let text = match &r { + Ok(Some(v)) => v.text.len(), + _ => 0, + }; + if outcome == "text" { + bytes += data.len() as u64; + micros += us; + read += 1; + } + println!( + "{{\"name\":{name:?},\"outcome\":\"{outcome}\",\"us\":{us},\"text\":{text}}}" + ); + } + println!( + "{{\"documents\":{read},\"input_bytes\":{bytes},\"us\":{micros},\"mb_per_s\":{:.1}}}", + bytes as f64 / micros.max(1) as f64 + ); + } + _ => { + eprintln!("usage: one | bench "); + std::process::exit(2); + } + } +} diff --git a/crates/exav-render/examples/raw_preview.rs b/crates/exav-render/examples/raw_preview.rs new file mode 100644 index 0000000..a1385fb --- /dev/null +++ b/crates/exav-render/examples/raw_preview.rs @@ -0,0 +1,73 @@ +//! Print what the RAW reader makes of each file given: its family, the +//! preview chosen, and how long it took, one tab-separated line a file. With +//! `--png DIR`, the preview is also saved there as `.png`. +//! +//! cargo run --release -p exav-render --example raw_preview -- \ +//! [--png DIR] [--budget BYTES] ... + +use std::time::Instant; + +use exav_render::image::raw; +use exav_render::image::DECODE_MAX; + +fn main() { + let mut args = std::env::args().skip(1).peekable(); + let (mut png_dir, mut budget) = (None, DECODE_MAX); + let mut files = Vec::new(); + while let Some(a) = args.next() { + match a.as_str() { + "--png" => png_dir = args.next(), + "--budget" => budget = args.next().and_then(|v| v.parse().ok()).unwrap_or(budget), + _ => files.push(a), + } + } + for f in files { + let data = match std::fs::read(&f) { + Ok(d) => d, + Err(e) => { + println!("{f}\tERR\tread: {e}"); + continue; + } + }; + let name = std::path::Path::new(&f) + .file_name() + .map_or_else(String::new, |n| n.to_string_lossy().into_owned()); + let started = Instant::now(); + let result = raw::preview(&data, budget); + let ms = started.elapsed().as_millis(); + match result { + Ok((pixels, info)) => { + println!( + "{name}\t{}\t{}x{}\to{}\t{}\tfound {}\tout {}x{}\t{ms} ms", + info.kind.name(), + info.width, + info.height, + info.orientation, + if info.jpeg { "jpeg" } else { "samples" }, + info.found, + pixels.width, + pixels.height, + ); + if let Some(dir) = &png_dir { + let rgba = pixels.to_rgba8(); + if let Some(img) = image::RgbaImage::from_raw(pixels.width, pixels.height, rgba) + { + let _ = img.save(format!("{dir}/{name}.png")); + } + } + } + // Not a RAW file for the reader: `decode_any` reads it as the + // image it may be (a plain TIFF). + Err(exav_render::image::Error::Unsupported) => { + match exav_render::image::decode_any(&data, budget) { + Ok(p) => println!("{name}\tOTHER\t{}x{}\t{ms} ms", p.width, p.height), + Err(e) => println!("{name}\t-\tERR\t{e}\t{ms} ms"), + } + } + Err(e) => match raw::detect(&data) { + Some(kind) => println!("{name}\t{}\tERR\t{e}\t{ms} ms", kind.name()), + None => println!("{name}\t-\tERR\t{e}\t{ms} ms"), + }, + } + } +} diff --git a/crates/exav-render/examples/scene_stats.rs b/crates/exav-render/examples/scene_stats.rs index 61d471b..4f07970 100644 --- a/crates/exav-render/examples/scene_stats.rs +++ b/crates/exav-render/examples/scene_stats.rs @@ -222,9 +222,11 @@ fn summarise(out: &mut String, d: &Drawing) { .map(|b| String::from_utf8_lossy(b).into_owned()) .unwrap_or_default(); texts.push(format!( - "{s}|h={}|rot={}|align={},{}|face={}|rgba={:08x}", + "{s}|u={},{}|v={},{}|align={},{}|face={}|rgba={:08x}", sig(f32_at(r, 8)), sig(f32_at(r, 12)), + sig(f32_at(r, 16)), + sig(f32_at(r, 20)), r[38], r[39], r[44], diff --git a/crates/exav-render/scripts/font-tables.py b/crates/exav-render/scripts/font-tables.py index f42c1f8..ff9b503 100644 --- a/crates/exav-render/scripts/font-tables.py +++ b/crates/exav-render/scripts/font-tables.py @@ -44,8 +44,14 @@ 0x2206, 0x2300, # increment, diameter 0x2264, 0x2265, 0x2260, # <=, >=, != 0x00B0, 0x00B1, 0x00B2, 0x00B3, # already dense, listed to assert - 0x03A9, 0x03BC, # ohm, micro + 0x2200, 0x2203, 0x220B, 0x2217, # Symbol's for all, exists, contains, star + 0x2212, 0x2234, 0x223C, 0x2245, # minus, therefore, tilde, congruent + 0x22A5, # perpendicular } + # Greek, with Symbol's theta, phi and pi symbols, and basic Cyrillic: + # what Greek and Russian notes on a sheet are written in. + | set(range(0x0384, 0x03D7)) + | set(range(0x0400, 0x0460)) ) # Fixed-point divisor for a stored advance, in em units. An advance is under diff --git a/crates/exav-render/scripts/office/common/codepages.py b/crates/exav-render/scripts/office/common/codepages.py new file mode 100644 index 0000000..668206c --- /dev/null +++ b/crates/exav-render/scripts/office/common/codepages.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Writes src/formats/office/common/codepage_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-render: +python3 -I scripts/office/common/codepages.py > src/formats/office/common/codepage_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/office/common/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-render/scripts/office/sheets/ftab.json b/crates/exav-render/scripts/office/sheets/ftab.json new file mode 100644 index 0000000..0de7e7f --- /dev/null +++ b/crates/exav-render/scripts/office/sheets/ftab.json @@ -0,0 +1,375 @@ +[ +[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] +] diff --git a/crates/exav-render/scripts/office/sheets/ftab.py b/crates/exav-render/scripts/office/sheets/ftab.py new file mode 100644 index 0000000..a9875a4 --- /dev/null +++ b/crates/exav-render/scripts/office/sheets/ftab.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Writes src/formats/office/sheets/xls/ftab.rs, the table of Excel's built-in +functions by their BIFF index, from scripts/office/sheets/ftab.json: rows of +[index, name, fixed argument count or -1], with the names of [MS-XLS] +2.5.198.17 Ftab and the argument counts of OpenOffice.org's Excel file format +documentation, 3.11. + +usage, from crates/exav-render: +python3 -I scripts/office/sheets/ftab.py scripts/office/sheets/ftab.json > src/formats/office/sheets/xls/ftab.rs +""" +import json +import sys + +rows = sorted(json.load(open(sys.argv[1])), key=lambda r: r[0]) +print("// Generated by scripts/office/sheets/ftab.py from ftab.json. Do not edit.") +print() +print("/// (BIFF function index, name, fixed argument count or -1), by index.") +print("#[rustfmt::skip]") +print("pub(crate) const FTAB: &[(u16, &str, i8)] = &[") +for i, name, n in rows: + print(f' ({i}, "{name}", {n}),') +print("];") diff --git a/crates/exav-render/scripts/office/sheets/numbers_fonts.py b/crates/exav-render/scripts/office/sheets/numbers_fonts.py new file mode 100644 index 0000000..476e46b --- /dev/null +++ b/crates/exav-render/scripts/office/sheets/numbers_fonts.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Writes src/formats/office/sheets/numbers/fonts.rs: the PostScript names of +macOS fonts whose family the rule in style::family (split at '-', drop an MT +or PSMT suffix, a space before each capital that follows a lower-case letter) +gets wrong, from numbers-parser's src/numbers_parser/generated/fontmap.py +(MIT, Jon Connell). + +usage, from crates/exav-render: +python3 -I scripts/office/sheets/numbers_fonts.py fontmap.py > src/formats/office/sheets/numbers/fonts.rs +""" +import ast +import sys + +text = open(sys.argv[1]).read() +fonts = ast.literal_eval(text[text.index("{"):]) + + +def rule(ps): + base = ps.split("-", 1)[0] + for suffix in ("PSMT", "MT"): + if base.endswith(suffix): + base = base[: -len(suffix)] + break + out = "" + prev_lower = False + for c in base: + if c.isupper() and prev_lower: + out += " " + prev_lower = c.islower() + out += c + return out + + +rows = sorted((ps, v["family"]) for ps, v in fonts.items() if rule(ps) != v["family"]) +print("// Generated by scripts/office/sheets/numbers_fonts.py from numbers-parser's") +print("// fontmap.py (MIT, Jon Connell). Do not edit.") +print() +print("/// (PostScript name, family), by name.") +print("#[rustfmt::skip]") +print("pub(crate) const FAMILIES: &[(&str, &str)] = &[") +for ps, fam in rows: + print(f' ({ps!r}, {fam!r}),'.replace("'", '"')) +print("];") diff --git a/crates/exav-render/scripts/office/sheets/numbers_functions.py b/crates/exav-render/scripts/office/sheets/numbers_functions.py new file mode 100644 index 0000000..25d91af --- /dev/null +++ b/crates/exav-render/scripts/office/sheets/numbers_functions.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Writes src/formats/office/sheets/numbers/functions.rs, Numbers' function +indexes (the AST_function_node_index of TSCE formula nodes) to their names, +from numbers-parser's src/numbers_parser/generated/functionmap.py (MIT, Jon +Connell), which maps them from the iWork applications. + +usage, from crates/exav-render: +python3 -I scripts/office/sheets/numbers_functions.py functionmap.py > src/formats/office/sheets/numbers/functions.rs +""" +import re +import sys + +text = open(sys.argv[1]).read() +rows = sorted((int(k), v) for k, v in re.findall(r'^\s*(\d+):\s*"([A-Z0-9.]+)"', text, re.M)) +print("// Generated by scripts/office/sheets/numbers_functions.py from numbers-parser's") +print("// functionmap.py (MIT, Jon Connell). Do not edit.") +print() +print("/// (function index, name), by index.") +print("#[rustfmt::skip]") +print("pub(crate) const FUNCTIONS: &[(u32, &str)] = &[") +for k, v in rows: + print(f' ({k}, "{v}"),') +print("];") diff --git a/crates/exav-render/src/formats/cad/dwg/blocks.rs b/crates/exav-render/src/formats/cad/dwg/blocks.rs index 2b3da90..c66d6a1 100644 --- a/crates/exav-render/src/formats/cad/dwg/blocks.rs +++ b/crates/exav-render/src/formats/cad/dwg/blocks.rs @@ -74,12 +74,19 @@ pub(crate) fn read(ctx: &mut Ctx<'_, '_>, vars: &Vars) -> Vec { } } } - ctx.names.insert(h, block.name.clone()); if block.end_handle.0 != 0 { let _ = typed(ctx, block.end_handle.0, ENDBLK, "ENDBLK"); } - records.push(rec); + records.push((h, rec)); } + number_anonymous(&mut records); + let records: Vec = records + .into_iter() + .map(|(h, rec)| { + ctx.names.insert(h, rec.block.name.clone()); + rec + }) + .collect(); let mut out = Vec::new(); for rec in records { let mut block = rec.block; @@ -112,6 +119,33 @@ pub(crate) fn read(ctx: &mut Ctx<'_, '_>, vars: &Vars) -> Vec { out } +/// AutoCAD's files can keep an anonymous block's name without its number in +/// both its record and its BLOCK entity: several `*U` blocks, each named by +/// its inserts, and found by that name. Each gets a number past the highest +/// one the drawing has, as AutoCAD numbers them when it loads the file. +fn number_anonymous(records: &mut [(u64, Record)]) { + let bare = |n: &str| matches!(n.as_bytes(), [b'*', c] if c.is_ascii_alphabetic()); + let mut next = records + .iter() + .filter_map(|(_, r)| { + let n = &r.block.name; + let digits = n + .trim_start_matches('*') + .trim_start_matches(|c: char| c.is_ascii_alphabetic()); + (n.starts_with('*') && !digits.is_empty()) + .then(|| digits.parse::().ok()) + .flatten() + }) + .max() + .unwrap_or(0); + for (_, r) in records.iter_mut() { + if bare(&r.block.name) { + next = next.saturating_add(1); + r.block.name = format!("{}{next}", r.block.name); + } + } +} + /// A layout's viewports have no number or status in DWG (spec 20.4.38), /// which DXF has (69, 68). As the converter writes them: an on viewport's /// number is its place among the layout's viewports from 1 and its status @@ -556,6 +590,52 @@ fn common(ctx: &mut Ctx<'_, '_>, vars: &Vars, o: &Object<'_>, record: Handle) -> mod tests { use super::*; + /// Inserts find their block by name, so two blocks named `*U` drew the + /// first one for both (AutoCAD's blocks_and_tables sample). + #[test] + fn bare_anonymous_names_are_numbered_past_the_highest() { + let mut records: Vec<(u64, Record)> = [ + "*Model_Space", + "*Paper_Space0", + "*U2", + "*U", + "*U", + "*D", + "*B12", + ] + .iter() + .map(|n| { + let block = Block { + name: (*n).to_string(), + ..Block::default() + }; + ( + 0, + Record { + block, + first: 0, + last: 0, + owned: Vec::new(), + }, + ) + }) + .collect(); + number_anonymous(&mut records); + let names: Vec<&str> = records.iter().map(|(_, r)| r.block.name.as_str()).collect(); + assert_eq!( + names, + [ + "*Model_Space", + "*Paper_Space0", + "*U2", + "*U13", + "*U14", + "*D15", + "*B12" + ] + ); + } + #[test] fn the_spaces_have_their_names_whatever_the_release_wrote() { for (written, read) in [ diff --git a/crates/exav-render/src/formats/cad/dwg/entities.rs b/crates/exav-render/src/formats/cad/dwg/entities.rs index 4ea60c1..d325efd 100644 --- a/crates/exav-render/src/formats/cad/dwg/entities.rs +++ b/crates/exav-render/src/formats/cad/dwg/entities.rs @@ -153,6 +153,7 @@ pub(crate) fn read( 0x29 => return none(EntityKind::XLine(ray(o)?)), 0x2C => return none(EntityKind::MText(Box::new(mtext(ctx, o)?))), 0x2D => return none(EntityKind::Leader(Box::new(leader(ctx, o)?))), + 0x2E => return none(EntityKind::Tolerance(Box::new(tolerance(ctx, o)?))), 0x2F => return none(EntityKind::MLine(Box::new(mline(ctx, o)?))), _ => {} } @@ -169,6 +170,7 @@ pub(crate) fn read( } "MULTILEADER" => none(EntityKind::MultiLeader(Box::new(multileader(ctx, o)?))), "HELIX" => none(EntityKind::Helix(Box::new(helix(ctx, o)?))), + "MESH" => none(EntityKind::Mesh(Box::new(mesh(ctx, o)?))), "ACAD_TABLE" => none(EntityKind::Table(Box::new(table(ctx, o)?))), "PDFUNDERLAY" => none(EntityKind::Underlay(Box::new(underlay( ctx, @@ -401,6 +403,60 @@ fn quad(o: &mut Object<'_>, v: Version) -> BitResult { }) } +/// TOLERANCE: R13 and R14 a BS, the height and the gap (BD each), not +/// kept; then the +/// insertion, direction and extrusion (3BD each), the text (TV), and the +/// dimension style in the handle stream. +fn tolerance(ctx: &mut Ctx<'_, '_>, o: &mut Object<'_>) -> BitResult { + let b = &mut o.data; + if ctx.version < Version::R2000 { + b.bs()?; + b.bd()?; + b.bd()?; + } + let mut t = Tolerance { + insertion: v3(b.bd3()?), + x_direction: v3(b.bd3()?), + extrusion: v3(b.bd3()?), + ..Tolerance::default() + }; + t.text = string(ctx, o)?; + t.dimstyle = entry(ctx, o, "dimension style")?; + Ok(t) +} + +/// MESH: BS version, B blend crease, BL subdivision level, BL vertex count +/// and the vertices (3BD), BL face list length and the list (BL each), each +/// face its count then its corners; the edges and creases after it are not +/// read. +fn mesh(ctx: &mut Ctx<'_, '_>, o: &mut Object<'_>) -> BitResult { + let b = &mut o.data; + b.bs()?; + b.b()?; + b.bl()?; + let n = i64::from(b.bl()?); + let n = count(b, n, 6)?; + let mut m = Mesh::default(); + for _ in 0..n { + let p = v3(o.data.bd3()?); + if ctx.room(m.vertices.len()) { + m.vertices.push(p); + } + } + let b = &mut o.data; + let n = i64::from(b.bl()?); + let n = count(b, n, 2)?; + let mut list = Vec::new(); + for _ in 0..n { + let k = i64::from(o.data.bl()?); + if ctx.room(list.len()) { + list.push(k); + } + } + m.faces = Mesh::faces_from_list(&list, m.vertices.len()); + Ok(m) +} + fn face3d(o: &mut Object<'_>, v: Version) -> BitResult { let b = &mut o.data; let mut f = Face3D::default(); @@ -599,6 +655,9 @@ fn insert( let block = o.handle_ref()?; i.block_name = ctx.name(block).unwrap_or_default().to_string(); let f = followers(o, v, has_attribs, n)?; + if o.xdictionary != 0 { + i.clip = super::objects::insert_clip(ctx, o.xdictionary); + } Ok((i, f)) } @@ -1662,8 +1721,12 @@ fn viewport(ctx: &mut Ctx<'_, '_>, o: &mut Object<'_>) -> BitResult { } let frozen = count(&o.handles, frozen, 8)?; for _ in 0..frozen { + // AutoCAD's files can hold null references here, which name no + // layer and which DXF leaves out. let h = o.handle_ref()?; - push(ctx, &mut p.frozen_layers, Handle(h)); + if h != 0 { + push(ctx, &mut p.frozen_layers, Handle(h)); + } } p.clip_boundary = Handle(o.handle_ref()?); Ok(p) diff --git a/crates/exav-render/src/formats/cad/dwg/header.rs b/crates/exav-render/src/formats/cad/dwg/header.rs index 7bb0fdf..28283a9 100644 --- a/crates/exav-render/src/formats/cad/dwg/header.rs +++ b/crates/exav-render/src/formats/cad/dwg/header.rs @@ -438,8 +438,11 @@ fn variables(s: &mut HeaderStreams<'_>, v: Version, vars: &mut Vars) -> BitResul } if r2004 { // SORTENTS, INDEXCTL, HIDETEXT, XCLIPFRAME, DIMASSOC, HALOGAP - for _ in 0..6 { - s.data.rc()?; + for i in 0..6 { + let x = s.data.rc()?; + if i == 3 { + vars.header.xclipframe = i16::from(x); + } } // OBSCUREDCOLOR, INTERSECTIONCOLOR skip_bs(s, 2)?; diff --git a/crates/exav-render/src/formats/cad/dwg/objects.rs b/crates/exav-render/src/formats/cad/dwg/objects.rs index bdcce72..de47821 100644 --- a/crates/exav-render/src/formats/cad/dwg/objects.rs +++ b/crates/exav-render/src/formats/cad/dwg/objects.rs @@ -243,6 +243,67 @@ pub(crate) fn annotative_columns(ctx: &mut Ctx<'_, '_>, xdictionary: u64) -> Opt first.flatten() } +/// An insert's XCLIP boundary: its extension dictionary's ACAD_FILTER +/// dictionary, whose SPATIAL entry is a SPATIAL_FILTER: BS +/// count (70), 2RD points (10), 3BD extrusion (210), 3BD origin (11), BS +/// display boundary (71), BS front clip on (72) and BD distance (40) when +/// set, the same for the back (73, 41), then the inverse insert matrix and +/// the clip boundary's, 12BD each (40). +pub(crate) fn insert_clip(ctx: &mut Ctx<'_, '_>, xdictionary: u64) -> Option { + let entry = |entries: Vec<(String, Handle)>, name: &str| { + entries + .into_iter() + .find(|(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, h)| h) + }; + let acad = entry(entries_of(ctx, xdictionary), "ACAD_FILTER")?; + let spatial = entry(entries_of(ctx, acad.0), "SPATIAL")?; + let mut o = ctx.object(spatial.0, "spatial filter")?; + if ctx.dwg.type_name(o.type_code).as_deref() != Some("SPATIAL_FILTER") { + return None; + } + match spatial_filter(ctx, &mut o) { + Ok(c) => c, + Err(e) => { + ctx.warn( + WarningKind::Malformed, + format!("spatial filter {:X} could not be read: {e}", spatial.0), + ); + None + } + } +} + +fn spatial_filter(ctx: &mut Ctx<'_, '_>, o: &mut Object<'_>) -> BitResult> { + let b = &mut o.data; + let n = i64::from(b.bs()?); + let n = count(n, b.remaining(), 128)?; + let mut points = Vec::new(); + for _ in 0..n { + let p = v2(b.rd2()?); + if ctx.room(points.len()) { + points.push(p); + } + } + b.bd3()?; // extrusion + b.bd3()?; // origin + let enabled = b.bs()? != 0; + if b.bs()? != 0 { + b.bd()?; // front distance + } + if b.bs()? != 0 { + b.bd()?; // back distance + } + let mut inverse = [0.0; 12]; + for m in &mut inverse { + *m = b.bd()?; + } + if points.len() < 2 { + return Ok(None); + } + Ok(Some(InsertClip::from_filter(&points, &inverse, enabled))) +} + /// An ACDB_MTEXTOBJECTCONTEXTDATA_CLASS object: whether it is the default /// context, and its columns. fn mtext_context( diff --git a/crates/exav-render/src/formats/cad/dwg/tables.rs b/crates/exav-render/src/formats/cad/dwg/tables.rs index 2c46cff..aeb3bcf 100644 --- a/crates/exav-render/src/formats/cad/dwg/tables.rs +++ b/crates/exav-render/src/formats/cad/dwg/tables.rs @@ -421,9 +421,8 @@ fn dim_style(ctx: &mut Ctx<'_, '_>, o: &mut Object<'_>) -> BitResult { for _ in 0..11 { b.b()?; } - // DIMALTD, DIMZIN - b.rc()?; - b.rc()?; + b.rc()?; // DIMALTD + s.dimzin = i16::from(b.rc()?); // DIMSD1, DIMSD2 b.b()?; b.b()?; @@ -432,12 +431,17 @@ fn dim_style(ctx: &mut Ctx<'_, '_>, o: &mut Object<'_>) -> BitResult { b.rc()?; } b.b()?; // DIMUPT - // DIMTZIN, DIMALTZ, DIMALTTZ, DIMTAD - for _ in 0..4 { + // DIMTZIN, DIMALTZ, DIMALTTZ + for _ in 0..3 { b.rc()?; } - // DIMUNIT, DIMAUNIT, DIMDEC, DIMTDEC, DIMALTU, DIMALTTD - for _ in 0..6 { + s.dimtad = i16::from(b.rc()?); + // DIMUNIT, DIMAUNIT + b.bs()?; + b.bs()?; + s.dimdec = b.bs()?; + // DIMTDEC, DIMALTU, DIMALTTD + for _ in 0..3 { b.bs()?; } s.dimscale = b.bd()?; @@ -453,10 +457,11 @@ fn dim_style(ctx: &mut Ctx<'_, '_>, o: &mut Object<'_>) -> BitResult { s.dimtxt = b.bd()?; b.bd()?; // DIMCEN s.dimtsz = b.bd()?; - // DIMALTF, DIMLFAC, DIMTVP, DIMTFAC - for _ in 0..4 { - b.bd()?; - } + b.bd()?; // DIMALTF + s.dimlfac = b.bd()?; + // DIMTVP, DIMTFAC + b.bd()?; + b.bd()?; s.dimgap = b.bd()?; // DIMPOST, DIMAPOST, DIMBLK, DIMBLK1, DIMBLK2 for _ in 0..5 { @@ -497,20 +502,20 @@ fn dim_style(ctx: &mut Ctx<'_, '_>, o: &mut Object<'_>) -> BitResult { for _ in 0..6 { b.b()?; } - // DIMTAD, DIMZIN, DIMAZIN - for _ in 0..3 { - b.bs()?; - } + s.dimtad = b.bs()?; + s.dimzin = b.bs()?; + b.bs()?; // DIMAZIN if ctx.version >= Version::R2007 { b.bs()?; // DIMARCSYM } s.dimtxt = b.bd()?; b.bd()?; // DIMCEN s.dimtsz = b.bd()?; - // DIMALTF, DIMLFAC, DIMTVP, DIMTFAC - for _ in 0..4 { - b.bd()?; - } + b.bd()?; // DIMALTF + s.dimlfac = b.bd()?; + // DIMTVP, DIMTFAC + b.bd()?; + b.bd()?; s.dimgap = b.bd()?; b.bd()?; // DIMALTRND b.b()?; // DIMALT @@ -523,11 +528,16 @@ fn dim_style(ctx: &mut Ctx<'_, '_>, o: &mut Object<'_>) -> BitResult { s.dimclre = cmc(o, ctx.version)?.0; s.dimclrt = cmc(o, ctx.version)?.0; let b = &mut o.data; - // DIMADEC, DIMDEC, DIMTDEC, DIMALTU, DIMALTTD, DIMAUNIT, DIMFRAC, - // DIMLUNIT, DIMDSEP, DIMTMOVE, DIMJUST - for _ in 0..11 { + s.dimadec = b.bs()?; + s.dimdec = b.bs()?; + // DIMTDEC, DIMALTU, DIMALTTD, DIMAUNIT, DIMFRAC, DIMLUNIT + for _ in 0..6 { b.bs()?; } + s.dimdsep = b.bs()?; + // DIMTMOVE, DIMJUST + b.bs()?; + b.bs()?; // DIMSD1, DIMSD2 b.b()?; b.b()?; diff --git a/crates/exav-render/src/formats/cad/dxf/build.rs b/crates/exav-render/src/formats/cad/dxf/build.rs index 69a8fe8..0c58263 100644 --- a/crates/exav-render/src/formats/cad/dxf/build.rs +++ b/crates/exav-render/src/formats/cad/dxf/build.rs @@ -317,6 +317,27 @@ pub(crate) fn build(mut raw: Raw, ctx: Ctx<'_>) -> Drawing { } } + // An insert's clipping boundary: its extension dictionary's ACAD_FILTER + // dictionary, whose SPATIAL entry is the filter. + if !ctx.insert_dictionaries.is_empty() { + let dictionaries: HashMap = + raw.dictionaries.iter().map(|d| (d.handle, d)).collect(); + let filter_of = |xdict: u64| -> Option { + let acad = dictionaries.get(&Handle(xdict))?.get("ACAD_FILTER")?; + let spatial = dictionaries.get(&acad)?.get("SPATIAL")?; + ctx.spatial_filters.get(&spatial.0).cloned() + }; + for b in &mut blocks { + for e in &mut b.entities { + if let EntityKind::Insert(ins) = &mut e.kind { + if let Some(&x) = ctx.insert_dictionaries.get(&e.handle.0) { + ins.clip = filter_of(x); + } + } + } + } + } + Drawing { header: raw.header, layers: raw.layers, diff --git a/crates/exav-render/src/formats/cad/dxf/entities.rs b/crates/exav-render/src/formats/cad/dxf/entities.rs index 2950ef6..3c9cef0 100644 --- a/crates/exav-render/src/formats/cad/dxf/entities.rs +++ b/crates/exav-render/src/formats/cad/dxf/entities.rs @@ -92,6 +92,20 @@ fn plane(p: &mut Plane, t: &Tag<'_>) -> bool { p3(&mut p.extrusion, t, 210) } +/// A record's extension dictionary: group 360 of its `{ACAD_XDICTIONARY` +/// application group, which `Parts` sets aside. +fn xdictionary(tags: &[Tag<'_>]) -> Option { + let start = tags + .iter() + .position(|t| t.code == 102 && t.is("{ACAD_XDICTIONARY"))?; + tags.get(start + 1..)? + .iter() + .take_while(|t| t.code != 102) + .find(|t| t.code == 360) + .map(|t| t.handle()) + .filter(|h| *h != 0) +} + pub(crate) fn entity(rec: &Record<'_>, ctx: &mut Ctx<'_>) -> Entity { let parts = Parts::new(&rec.tags, false); let mut e = Entity { @@ -114,10 +128,17 @@ pub(crate) fn entity(rec: &Record<'_>, ctx: &mut Ctx<'_>) -> Entity { "SOLID" => EntityKind::Solid(quad(data)), "TRACE" => EntityKind::Trace(quad(data)), "3DFACE" => EntityKind::Face3D(face3d(data)), + "MESH" => EntityKind::Mesh(Box::new(mesh(data, ctx))), + "TOLERANCE" => EntityKind::Tolerance(Box::new(tolerance(data, ctx))), "TEXT" => EntityKind::Text(Box::new(text(data, false, ctx).0)), "ATTRIB" => EntityKind::Attribute(Box::new(attribute(&parts, data, ctx))), "ATTDEF" => EntityKind::AttributeDefinition(Box::new(attribute(&parts, data, ctx))), - "INSERT" => EntityKind::Insert(Box::new(insert(data, ctx))), + "INSERT" => { + if let Some(d) = xdictionary(&rec.tags) { + ctx.insert_dictionaries.insert(e.handle.0, d); + } + EntityKind::Insert(Box::new(insert(data, ctx))) + } "MTEXT" => { let mut m = mtext(data, ctx); if m.columns.is_none() { @@ -436,6 +457,81 @@ fn quad(data: &[Tag<'_>]) -> Quad { q } +/// MESH: its level 0 cage, 92 vertices (10, 20, 30) and a face list of 93 +/// values (90), each face its corner count then its corners; the edges +/// (94), creases (95) and overrides after it are not read. +fn mesh(data: &[Tag<'_>], ctx: &mut Ctx<'_>) -> Mesh { + #[derive(PartialEq)] + enum At { + Head, + Vertices, + Faces(usize), + Tail, + } + let mut m = Mesh::default(); + let mut list: Vec = Vec::new(); + let mut at = At::Head; + for t in data { + match (&at, t.code) { + (At::Head, 92) => at = At::Vertices, + (At::Vertices, 10) => { + if ctx.room(m.vertices.len()) { + m.vertices.push(Vec3::new(t.f64(), 0.0, 0.0)); + } + } + (At::Vertices, 20) => { + if let Some(v) = m.vertices.last_mut() { + v.y = t.f64(); + } + } + (At::Vertices, 30) => { + if let Some(v) = m.vertices.last_mut() { + v.z = t.f64(); + } + } + (At::Head | At::Vertices, 93) => { + let n = usize::try_from(t.int()).unwrap_or(0); + at = if n > 0 { At::Faces(n) } else { At::Tail }; + } + (At::Faces(left), 90) => { + let left = *left; + if ctx.room(list.len()) { + list.push(t.int()); + } + at = if left > 1 { + At::Faces(left - 1) + } else { + At::Tail + }; + } + (_, 94) => at = At::Tail, + _ => {} + } + } + m.faces = Mesh::faces_from_list(&list, m.vertices.len()); + m +} + +/// TOLERANCE: insertion (10), direction (11), extrusion (210), text (1), +/// dimension style (3). +fn tolerance(data: &[Tag<'_>], ctx: &mut Ctx<'_>) -> Tolerance { + let mut x = Tolerance::default(); + for t in data { + if p3(&mut x.insertion, t, 10) + || p3(&mut x.x_direction, t, 11) + || p3(&mut x.extrusion, t, 210) + { + continue; + } + match t.code { + 1 => x.text = ctx.text(t), + 3 => x.dimstyle = ctx.text(t), + _ => {} + } + } + x +} + fn face3d(data: &[Tag<'_>]) -> Face3D { let mut f = Face3D::default(); let mut fourth = false; diff --git a/crates/exav-render/src/formats/cad/dxf/header.rs b/crates/exav-render/src/formats/cad/dxf/header.rs index 18aa11d..ceabc72 100644 --- a/crates/exav-render/src/formats/cad/dxf/header.rs +++ b/crates/exav-render/src/formats/cad/dxf/header.rs @@ -106,6 +106,7 @@ pub(crate) fn read(tags: &[Tag<'_>], raw: &mut Raw, ctx: &mut Ctx<'_>) { "$LWDISPLAY" => h.lwdisplay = v.bool(), "$FILLMODE" => h.fillmode = v.bool(), "$MIRRTEXT" => h.mirrtext = v.bool(), + "$XCLIPFRAME" => h.xclipframe = v.i16(), _ => {} } } diff --git a/crates/exav-render/src/formats/cad/dxf/mod.rs b/crates/exav-render/src/formats/cad/dxf/mod.rs index 9f750e6..87a1f04 100644 --- a/crates/exav-render/src/formats/cad/dxf/mod.rs +++ b/crates/exav-render/src/formats/cad/dxf/mod.rs @@ -64,6 +64,11 @@ pub(crate) struct Ctx<'l> { /// Layer names and handles, once the tables are read: R12 viewports /// name their frozen layers. pub layer_handles: Vec<(String, Handle)>, + /// Each INSERT's extension dictionary, by the insert's handle: where its + /// clipping boundary is found once the objects are read. + pub insert_dictionaries: std::collections::HashMap, + /// SPATIAL_FILTER objects, by handle. + pub spatial_filters: std::collections::HashMap, } impl<'l> Ctx<'l> { @@ -79,6 +84,8 @@ impl<'l> Ctx<'l> { entity_limit_hit: false, item_limit_hit: false, layer_handles: Vec::new(), + insert_dictionaries: Default::default(), + spatial_filters: Default::default(), } } diff --git a/crates/exav-render/src/formats/cad/dxf/objects.rs b/crates/exav-render/src/formats/cad/dxf/objects.rs index 26c1083..3f83164 100644 --- a/crates/exav-render/src/formats/cad/dxf/objects.rs +++ b/crates/exav-render/src/formats/cad/dxf/objects.rs @@ -46,6 +46,42 @@ pub(crate) fn object(rec: &Record<'_>, raw: &mut Raw, ctx: &mut Ctx<'_>) { raw.dictionaries.push(d); } "LAYOUT" => raw.layouts.push(layout(&parts, handle, ctx)), + // An XCLIP boundary: its points (10), clipping on (71), then two + // matrices of 12 (40), the inverse insert matrix and the clip + // boundary's; the front and back distances are 40 and 41 before + // them, so the matrices are the last 24 of the 40s. + "SPATIAL_FILTER" => { + let mut points: Vec = Vec::new(); + let mut forties: Vec = Vec::new(); + let mut enabled = true; + for t in class(&parts, "AcDbSpatialFilter") { + match t.code { + 10 => { + if ctx.room(points.len()) { + points.push(Vec2::new(t.f64(), 0.0)); + } + } + 20 => { + if let Some(p) = points.last_mut() { + p.y = t.f64(); + } + } + 71 => enabled = t.bool(), + 40 if ctx.room(forties.len()) => forties.push(t.f64()), + _ => {} + } + } + let at = forties.len().saturating_sub(24); + let inverse = forties + .get(at..at + 12) + .and_then(|m| <[f64; 12]>::try_from(m).ok()); + if let (Some(inverse), true) = (inverse, forties.len() >= 24 && points.len() >= 2) { + ctx.spatial_filters.insert( + handle.0, + InsertClip::from_filter(&points, &inverse, enabled), + ); + } + } "SORTENTSTABLE" => { let mut s = SortEntsTable { handle, diff --git a/crates/exav-render/src/formats/cad/dxf/tables.rs b/crates/exav-render/src/formats/cad/dxf/tables.rs index b6cbca8..fc26f30 100644 --- a/crates/exav-render/src/formats/cad/dxf/tables.rs +++ b/crates/exav-render/src/formats/cad/dxf/tables.rs @@ -195,6 +195,12 @@ fn dim_style(parts: &Parts<'_>, handle: Handle, ctx: &mut Ctx<'_>) -> DimStyle { 140 => d.dimtxt = t.f64(), 142 => d.dimtsz = t.f64(), 147 => d.dimgap = t.f64(), + 77 => d.dimtad = t.i16(), + 78 => d.dimzin = t.i16(), + 144 => d.dimlfac = t.f64(), + 271 => d.dimdec = t.i16(), + 179 => d.dimadec = t.i16(), + 278 => d.dimdsep = t.i16(), 176 => d.dimclrd = Color::from_aci(t.int()), 177 => d.dimclre = Color::from_aci(t.int()), 178 => d.dimclrt = Color::from_aci(t.int()), diff --git a/crates/exav-render/src/formats/cad/json.rs b/crates/exav-render/src/formats/cad/json.rs index f29aad5..ed42bb0 100644 --- a/crates/exav-render/src/formats/cad/json.rs +++ b/crates/exav-render/src/formats/cad/json.rs @@ -340,6 +340,7 @@ fn header(o: &mut Obj<'_>, h: &Header) { o.b("lwdisplay", h.lwdisplay); o.b("fillmode", h.fillmode); o.b("mirrtext", h.mirrtext); + o.i("xclipframe", h.xclipframe); } fn layer(o: &mut Obj<'_>, l: &Layer) { @@ -401,6 +402,12 @@ fn dim_style(o: &mut Obj<'_>, d: &DimStyle) { o.f("dimtsz", d.dimtsz); o.f("dimtxt", d.dimtxt); o.f("dimgap", d.dimgap); + o.i("dimtad", d.dimtad); + o.i("dimzin", d.dimzin); + o.f("dimlfac", d.dimlfac); + o.i("dimdec", d.dimdec); + o.i("dimadec", d.dimadec); + o.i("dimdsep", d.dimdsep); o.color("dimclrd", d.dimclrd); o.color("dimclre", d.dimclre); o.color("dimclrt", d.dimclrt); @@ -947,6 +954,26 @@ fn kind(o: &mut Obj<'_>, k: &EntityKind) { o.v3s("corners", &f.corners); o.i("invisible_edges", f.invisible_edges); } + EntityKind::Mesh(m) => { + o.v3s("vertices", &m.vertices); + o.list("faces", &m.faces, |s, f| { + s.push('['); + for (n, i) in f.iter().enumerate() { + if n > 0 { + s.push(','); + } + let _ = write!(s, "{i}"); + } + s.push(']'); + }); + } + EntityKind::Tolerance(t) => { + o.v3("insertion", t.insertion); + o.v3("x_direction", t.x_direction); + o.v3("extrusion", t.extrusion); + o.str("text", &t.text); + o.str("dimstyle", &t.dimstyle); + } EntityKind::Text(t) => text(o, t), EntityKind::Attribute(a) | EntityKind::AttributeDefinition(a) => attribute(o, a), EntityKind::Insert(i) => { @@ -960,6 +987,13 @@ fn kind(o: &mut Obj<'_>, k: &EntityKind) { o.f("row_spacing", i.row_spacing); o.v3("extrusion", i.extrusion); o.objects("attributes", &i.attributes, entity); + match &i.clip { + Some(c) => o.object("clip", |o| { + o.v2s("boundary", &c.boundary); + o.b("enabled", c.enabled); + }), + None => o.null("clip"), + } } EntityKind::MText(m) => mtext(o, m), EntityKind::Dimension(d) => { diff --git a/crates/exav-render/src/formats/cad/model.rs b/crates/exav-render/src/formats/cad/model.rs index 136633a..c2b8d24 100644 --- a/crates/exav-render/src/formats/cad/model.rs +++ b/crates/exav-render/src/formats/cad/model.rs @@ -244,6 +244,8 @@ pub struct Header { pub lwdisplay: bool, pub fillmode: bool, pub mirrtext: bool, + /// `$XCLIPFRAME`: 0 hidden, 1 shown and plotted, 2 shown only. + pub xclipframe: i16, } impl Default for Header { @@ -286,6 +288,7 @@ impl Default for Header { lwdisplay: false, fillmode: true, mirrtext: false, + xclipframe: 2, } } } diff --git a/crates/exav-render/src/formats/cad/model/entity.rs b/crates/exav-render/src/formats/cad/model/entity.rs index 60ed8f8..5fd3d84 100644 --- a/crates/exav-render/src/formats/cad/model/entity.rs +++ b/crates/exav-render/src/formats/cad/model/entity.rs @@ -68,6 +68,7 @@ pub enum EntityKind { Leader(Box), Line(Line), LwPolyline(LwPolyline), + Mesh(Box), MLine(Box), MText(Box), MultiLeader(Box), @@ -80,6 +81,7 @@ pub enum EntityKind { Spline(Box), Table(Box), Text(Box), + Tolerance(Box), Trace(Quad), Underlay(Box), Viewport(Box), @@ -106,6 +108,7 @@ impl EntityKind { EntityKind::Leader(_) => "LEADER", EntityKind::Line(_) => "LINE", EntityKind::LwPolyline(_) => "LWPOLYLINE", + EntityKind::Mesh(_) => "MESH", EntityKind::MLine(_) => "MLINE", EntityKind::MText(_) => "MTEXT", EntityKind::MultiLeader(_) => "MULTILEADER", @@ -118,6 +121,7 @@ impl EntityKind { EntityKind::Spline(_) => "SPLINE", EntityKind::Table(_) => "ACAD_TABLE", EntityKind::Text(_) => "TEXT", + EntityKind::Tolerance(_) => "TOLERANCE", EntityKind::Trace(_) => "TRACE", EntityKind::Underlay(u) => match u.kind { UnderlayKind::Pdf => "PDFUNDERLAY", @@ -412,6 +416,63 @@ pub struct Quad { pub plane: Plane, } +/// MESH (2010 on): a subdivision mesh's control cage, in world +/// coordinates; its smoothed levels are not stored. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Mesh { + pub vertices: Vec, + /// Each face's vertex indices, from 0. + pub faces: Vec>, +} + +impl Mesh { + /// The faces of a stored face list (each its corner count, then its + /// corners), those naming a vertex the mesh does not have left out. + pub fn faces_from_list(list: &[i64], vertices: usize) -> Vec> { + let mut faces = Vec::new(); + let mut i = 0; + while let Some(&n) = list.get(i) { + let Ok(n) = usize::try_from(n) else { break }; + let Some(ids) = list.get(i + 1..i + 1 + n) else { + break; + }; + let face: Option> = ids + .iter() + .map(|&k| u32::try_from(k).ok().filter(|&k| (k as usize) < vertices)) + .collect(); + if let Some(face) = face.filter(|f| f.len() >= 2) { + faces.push(face); + } + i += n + 1; + } + faces + } +} + +/// TOLERANCE: a feature control frame, its text in the GD&T font's codes +/// (`{\Fgdt;j}`), `%%v` between its boxes and `^J` between its rows. +#[derive(Clone, Debug, PartialEq)] +pub struct Tolerance { + pub insertion: Vec3, + pub x_direction: Vec3, + pub extrusion: Vec3, + pub text: String, + /// The dimension style: its text height, gap and colours. + pub dimstyle: String, +} + +impl Default for Tolerance { + fn default() -> Self { + Tolerance { + insertion: Vec3::default(), + x_direction: Vec3::new(1.0, 0.0, 0.0), + extrusion: Vec3::Z, + text: String::new(), + dimstyle: String::new(), + } + } +} + /// 3DFACE, in world coordinates. #[derive(Clone, Debug, Default, PartialEq)] pub struct Face3D { @@ -548,6 +609,39 @@ pub struct Insert { pub extrusion: Vec3, /// The ATTRIB entities that follow it. pub attributes: Vec, + /// Its clipping boundary (XCLIP), when it has one. + pub clip: Option, +} + +/// An insert's clipping boundary: the SPATIAL_FILTER its extension +/// dictionary names under ACAD_FILTER, then SPATIAL. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct InsertClip { + /// In the block's coordinates: the filter's points through its inverse + /// insert matrix. Two points are a rectangle's opposite corners. + pub boundary: Vec, + /// Group 71: clipping is on. + pub enabled: bool, +} + +impl InsertClip { + /// The boundary from a filter's points and its inverse insert matrix, + /// 12 numbers by rows of 4 (x' = m0 x + m1 y + m2 z + m3, ...). + pub fn from_filter(points: &[Vec2], inverse: &[f64; 12], enabled: bool) -> InsertClip { + let m = inverse; + InsertClip { + boundary: points + .iter() + .map(|p| { + Vec2::new( + m[0] * p.x + m[1] * p.y + m[3], + m[4] * p.x + m[5] * p.y + m[7], + ) + }) + .collect(), + enabled, + } + } } impl Default for Insert { @@ -563,6 +657,7 @@ impl Default for Insert { row_spacing: 0.0, extrusion: Vec3::Z, attributes: Vec::new(), + clip: None, } } } diff --git a/crates/exav-render/src/formats/cad/model/tables.rs b/crates/exav-render/src/formats/cad/model/tables.rs index bdafbfa..f0bb4da 100644 --- a/crates/exav-render/src/formats/cad/model/tables.rs +++ b/crates/exav-render/src/formats/cad/model/tables.rs @@ -162,6 +162,17 @@ pub struct DimStyle { pub dimtsz: f64, pub dimtxt: f64, pub dimgap: f64, + /// Text above the dimension line (1 and up) or centred on it (0). + pub dimtad: i16, + /// Zero suppression: 4 leading, 8 trailing. + pub dimzin: i16, + /// Scale applied to linear measurements. + pub dimlfac: f64, + /// Decimal places of linear and of angular measurements. + pub dimdec: i16, + pub dimadec: i16, + /// The decimal separator's character code. + pub dimdsep: i16, pub dimclrd: Color, pub dimclre: Color, pub dimclrt: Color, @@ -190,6 +201,12 @@ impl Default for DimStyle { dimtsz: 0.0, dimtxt: 0.18, dimgap: 0.09, + dimtad: 0, + dimzin: 0, + dimlfac: 1.0, + dimdec: 4, + dimadec: 0, + dimdsep: 46, dimclrd: Color::ByBlock, dimclre: Color::ByBlock, dimclrt: Color::ByBlock, diff --git a/crates/exav-render/src/formats/dwg/DESIGN.md b/crates/exav-render/src/formats/dwg/DESIGN.md index b23e304..5fa28d8 100644 --- a/crates/exav-render/src/formats/dwg/DESIGN.md +++ b/crates/exav-render/src/formats/dwg/DESIGN.md @@ -71,11 +71,26 @@ entities. | Fills | Hatch, Solid, Face3D, gradients | triangles: colour, layer id | | Glyphs | Text, MText, Attribute in an outline face | instanced quads into a Canvas-built atlas | -Curves are flattened at a tolerance chosen from the drawing's extents. Both +Circles, arcs and bulged polyline segments are strokes too, when nothing +dashes them and the transform keeps circles round: each carries a bulge, at +most a quarter turn's, and the fragment shader measures the distance to the +arc itself, so a curve is exact at any zoom. Other curves (ellipses, +splines, dashed arcs, arcs in a block scaled unevenly) are flattened at a +fixed sagitta of a thousandth of their radius, and an arc crossing a clip +boundary is cut as its flattened pieces. Both the stroke and fill buffers are sorted into spatial buckets recording the true -box of their contents, so a view draws only the buckets it touches. Buckets -are built within the opaque and the translucent halves, which are two passes -(only the first writes depth). +box of their contents, so a view draws only the buckets it touches. Only the +opaque half is bucketed. The translucent half follows it in draw order, cut +into tiles of consecutive entities, and is drawn after all opaque geometry, +fills and strokes interleaved by draw order (`translucent_runs`). Both passes +write depth, so an entity's overlaps with itself blend once. Text under +translucent geometry is drawn in two parts: the glyphs' solid cores before +the translucent pass, their edges after it. + +The context has no multisampling: strokes measure their own coverage. An +opaque fill of one colour is outlined by strokes of no width (`FILL_EDGE`), +whose fringe is the coverage of the pixels just outside it; drawn at the +fill's depth, they are hidden wherever the fill is. ### 3.3 Layer show and hide without re-uploading @@ -125,19 +140,28 @@ it into the view matrix. |---|---| | Lines, arcs, circles, ellipses, polylines, splines | Drawn | | Blocks and inserts, nested, scaled, rotated, MINSERT arrays | Drawn, with a cycle guard | -| Dimensions | Drawn from the anonymous block AutoCAD bakes | +| Dimensions | Drawn from the anonymous block AutoCAD bakes; one saved without it from its definition points and style (`dimension.rs`: no DIMTIH, feet and inches, tolerances or alternate units) | | MLINE, HELIX | Drawn | | Layers: colour, on/off, freeze, transparency | From the file | | Lineweight | Instanced quads, zoom-aware | | Solid and gradient hatch, islands | Even-odd, holes bridged into the outer ring | | Pattern hatch | Line families clipped to the region, with dash phase | | Linetypes | Dashed in drawing units, phase carried along a polyline; LTSCALE, per-entity and block scale | +| Complex linetypes | Their text; the shapes of ltypeshp.shx (AutoCAD's own TRACKS, ZIGZAG, FENCELINE1 and 2, BATTING), redrawn; other shape files' shapes left out | | Extrusion (OCS) | Arbitrary Axis Algorithm, for the entity types stored in object coordinates | -| TrueType text | Bundled faces, glyph atlas | +| TrueType text | Bundled faces, glyph atlas sized to the view; distance fields when the character set is too large for that size | | SHX text | Substituted, see 5.3 | -| MTEXT formatting | Colour, height, font, the three rules, indents and tabs kept; stacked fractions as `a/b` | +| MTEXT formatting | Colour, height, font, width factor, oblique, tracking, the three rules, indents, tabs, justification, stacks, background masks and frames | +| Text placement | Mirrored and stretched with its block, TEXT's backward and upside-down flags, Bottom and Middle on the face's descender | +| Symbol fonts | symbol.ttf, greeks/greekc.shx and gdt.shx as the characters they draw | +| Characters the stroke font lacks (CJK) | The run drawn in the outline face, completed from the browser's fonts | | Paper-space layouts and viewports | Drawn | -| External references, images, OLE | Frame only; counted in the warnings | +| External references, images, OLE | Xrefs: their path at the insertion point; images, underlays, OLE: frame only; all counted, with the files they name, in the warnings | +| XCLIP | The block cut to its boundary, the frame as `$XCLIPFRAME` says | +| Points | `$PDMODE` shapes, `$PDSIZE` (a share of the drawing's height when not in units) | +| 3DFACE, polyface and polygon meshes, MESH | Their visible edges, in plan | +| TOLERANCE | Feature control frame, GD&T symbols | +| Leader arrowheads | The style's arrow block, else AutoCAD's built-in shape of that name | | Custom objects (proxy graphics) | Drawn from the graphics saved with them, see 5.4; those saved without are counted in the warnings | | Plot styles (CTB/STB) | Not planned | diff --git a/crates/exav-render/src/formats/dwg/clip.rs b/crates/exav-render/src/formats/dwg/clip.rs index 9d42dea..a7beb78 100644 --- a/crates/exav-render/src/formats/dwg/clip.rs +++ b/crates/exav-render/src/formats/dwg/clip.rs @@ -65,6 +65,17 @@ impl ClipShape { }) } + /// The region as triangles. + pub fn triangles(&self) -> Vec<[[f64; 2]; 3]> { + match self { + ClipShape::Rect { min, max } => vec![ + [[min[0], min[1]], [max[0], min[1]], [max[0], max[1]]], + [[min[0], min[1]], [max[0], max[1]], [min[0], max[1]]], + ], + ClipShape::Poly { pieces, .. } => pieces.clone(), + } + } + pub fn bounds(&self) -> ([f64; 2], [f64; 2]) { match self { ClipShape::Rect { min, max } => (*min, *max), diff --git a/crates/exav-render/src/formats/dwg/curves.rs b/crates/exav-render/src/formats/dwg/curves.rs index e43b56e..ba09271 100644 --- a/crates/exav-render/src/formats/dwg/curves.rs +++ b/crates/exav-render/src/formats/dwg/curves.rs @@ -4,8 +4,9 @@ use std::f64::consts::TAU; /// Sagitta error target as a fraction of radius. 0.001 gives ~70 segments for a -/// full circle, which holds up past the zoom levels a viewer reaches before -/// re-tessellation would kick in. +/// full circle. The curves that reach here are flattened once and never +/// refined at a closer zoom; a circle, arc or bulge that nothing prevents is +/// not flattened at all (see DESIGN.md). const SAGITTA_RATIO: f64 = 0.001; const MIN_SEGMENTS: usize = 8; const MAX_SEGMENTS: usize = 512; @@ -31,6 +32,58 @@ fn ccw_sweep(sweep: f64) -> Option { Some(if s > 0.0 { s } else { TAU }) } +/// The centre, radius and angles of the arc from `a` to `b` with this +/// bulge: (cx, cy, r, start, signed sweep). None for a straight one. +pub fn bulge_arc(a: [f64; 2], b: [f64; 2], bulge: f64) -> Option<(f64, f64, f64, f64, f64)> { + let chord = (b[0] - a[0]).hypot(b[1] - a[1]); + if bulge == 0.0 || !bulge.is_finite() || chord < 1e-12 { + return None; + } + let sweep = 4.0 * bulge.atan(); + let r = chord / (2.0 * (sweep / 2.0).sin().abs()); + // The centre lies off the chord's middle, to its left for a + // counter-clockwise arc under half a turn. + let (mx, my) = ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0); + let (ux, uy) = ((b[0] - a[0]) / chord, (b[1] - a[1]) / chord); + let d = chord / 2.0 / (sweep / 2.0).tan(); + let (cx, cy) = (mx - uy * d, my + ux * d); + let start = (a[1] - cy).atan2(a[0] - cx); + Some((cx, cy, r, start, sweep)) +} + +/// `n` + 1 points along the arc from `a` to `b` with this bulge. +pub fn bulge_points(a: [f64; 2], b: [f64; 2], bulge: f64, n: usize) -> Vec<[f64; 2]> { + let Some((cx, cy, r, start, sweep)) = bulge_arc(a, b, bulge) else { + return vec![a, b]; + }; + let n = n.max(1); + let mut out: Vec<[f64; 2]> = (0..=n) + .map(|i| { + let t = start + sweep * i as f64 / n as f64; + [cx + r * t.cos(), cy + r * t.sin()] + }) + .collect(); + // The ends exactly where they were. + out[0] = a; + out[n] = b; + out +} + +/// Sample a circular arc from `start` over a signed `sweep` (radians, +/// positive counter-clockwise). +pub fn arc_points(cx: f64, cy: f64, r: f64, start: f64, sweep: f64) -> Vec<[f64; 2]> { + if !r.is_finite() || r <= 0.0 || !sweep.is_finite() || !start.is_finite() { + return Vec::new(); + } + let n = segments_for_sweep(sweep); + (0..=n) + .map(|i| { + let a = start + sweep * (i as f64 / n as f64); + [cx + r * a.cos(), cy + r * a.sin()] + }) + .collect() +} + /// Sample a circular arc counter-clockwise from `start` to `end` (radians). pub fn flatten_arc(cx: f64, cy: f64, r: f64, start: f64, end: f64) -> Vec<[f64; 2]> { if !r.is_finite() || r <= 0.0 { @@ -529,6 +582,25 @@ mod tests { ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt() } + #[test] + fn a_bulge_is_its_arc() { + let quarter = (PI / 8.0).tan(); + // Counter-clockwise from (1,0) to (0,1): about the origin, radius 1. + let (cx, cy, r, start, sweep) = bulge_arc([1.0, 0.0], [0.0, 1.0], quarter).unwrap(); + assert!(cx.abs() < 1e-12 && cy.abs() < 1e-12 && (r - 1.0).abs() < 1e-12); + assert!(start.abs() < 1e-12 && (sweep - PI / 2.0).abs() < 1e-12); + // Clockwise the other way round: about (1,1). + let (cx, cy, _, _, sweep) = bulge_arc([1.0, 0.0], [0.0, 1.0], -quarter).unwrap(); + assert!((cx - 1.0).abs() < 1e-12 && (cy - 1.0).abs() < 1e-12 && sweep < 0.0); + // Three quarters of a turn: its middle point on the far side. + let pts = bulge_points([1.0, 0.0], [0.0, -1.0], (3.0 * PI / 8.0).tan(), 2); + assert!( + dist(pts[1], [-(0.5f64.sqrt()), 0.5f64.sqrt()]) < 1e-12, + "{pts:?}" + ); + assert!(bulge_arc([1.0, 0.0], [0.0, 1.0], 0.0).is_none()); + } + /// `f`'s result, or a failure if it has not returned within five seconds /// (the thread is left spinning; the test binary exits anyway). fn finishes(f: impl FnOnce() -> T + Send + 'static) -> T { diff --git a/crates/exav-render/src/formats/dwg/dimension.rs b/crates/exav-render/src/formats/dwg/dimension.rs new file mode 100644 index 0000000..6801a41 --- /dev/null +++ b/crates/exav-render/src/formats/dwg/dimension.rs @@ -0,0 +1,471 @@ +//! A dimension's graphics, for one saved without the anonymous block AutoCAD +//! draws it from: generators often leave the block out. The geometry follows +//! the definition points and the style's sizes; what this leaves to the +//! block (DIMTIH, DIMLUNIT's feet and inches, tolerances, alternate units, +//! arrows flipped outside a short dimension) is not drawn. + +use crate::cad::{DimStyle, Dimension, DimensionKind, Vec3}; + +/// A style's sizes in drawing units (DIMSCALE applied) and how it writes +/// numbers. +pub struct Style { + pub asz: f64, + pub exo: f64, + pub exe: f64, + pub dle: f64, + pub tsz: f64, + pub txt: f64, + pub gap: f64, + /// Text above the dimension line, else centred on it, the line broken. + pub above: bool, + pub zin: i16, + pub lfac: f64, + pub dec: usize, + pub adec: usize, + pub dsep: char, +} + +impl Style { + pub fn new(s: &DimStyle) -> Style { + let scale = if s.dimscale > 0.0 && s.dimscale.is_finite() { + s.dimscale + } else { + 1.0 + }; + let size = |v: f64| if v.is_finite() { v.abs() * scale } else { 0.0 }; + let places = |v: i16| usize::from(v.clamp(0, 8) as u8); + Style { + asz: size(s.dimasz), + exo: size(s.dimexo), + exe: size(s.dimexe), + dle: size(s.dimdle), + tsz: size(s.dimtsz), + txt: size(s.dimtxt), + gap: size(s.dimgap), + above: s.dimtad != 0, + zin: s.dimzin, + lfac: if s.dimlfac.is_finite() && s.dimlfac != 0.0 { + s.dimlfac.abs() + } else { + 1.0 + }, + dec: places(s.dimdec), + adec: places(s.dimadec), + dsep: char::from_u32(u32::from(s.dimdsep.max(0) as u16)) + .filter(|c| !c.is_control()) + .unwrap_or('.'), + } + } +} + +/// The text, its middle at `at`, turned by `rotation` (radians). +pub struct Label { + pub text: String, + pub at: [f64; 2], + pub rotation: f64, +} + +#[derive(Default)] +pub struct Graphics { + pub lines: Vec<[[f64; 2]; 2]>, + /// Centre, radius, start angle and signed sweep (radians). + pub arcs: Vec<([f64; 2], f64, f64, f64)>, + /// An arrowhead's tip, and a point it points away from. + pub arrows: Vec<([f64; 2], [f64; 2])>, + pub label: Option, +} + +type P = [f64; 2]; + +fn p(v: Vec3) -> P { + [v.x, v.y] +} +fn sub(a: P, b: P) -> P { + [a[0] - b[0], a[1] - b[1]] +} +fn add(a: P, b: P) -> P { + [a[0] + b[0], a[1] + b[1]] +} +fn mul(a: P, k: f64) -> P { + [a[0] * k, a[1] * k] +} +fn dot(a: P, b: P) -> f64 { + a[0] * b[0] + a[1] * b[1] +} +fn len(a: P) -> f64 { + a[0].hypot(a[1]) +} +fn unit(a: P) -> Option { + let l = len(a); + (l > 1e-12 && l.is_finite()).then(|| mul(a, 1.0 / l)) +} +fn perp(a: P) -> P { + [-a[1], a[0]] +} +fn polar(a: f64) -> P { + [a.cos(), a.sin()] +} + +/// An angle turned to read left to right: in (-90, 90] degrees. +fn readable(a: f64) -> f64 { + use std::f64::consts::{FRAC_PI_2, PI, TAU}; + let a = a.rem_euclid(TAU); + if a > FRAC_PI_2 + 1e-9 && a <= 3.0 * FRAC_PI_2 + 1e-9 { + a - PI + } else if a > 3.0 * FRAC_PI_2 { + a - TAU + } else { + a + } +} + +/// A number as the style writes it: its decimal places, its separator, and +/// leading (4) or trailing (8) zeros suppressed as DIMZIN says. +pub fn number(v: f64, places: usize, zin: i16, sep: char) -> String { + // Halves away from zero, as AutoCAD rounds: formatting alone takes them + // to even, 100.5 to 100. + let k = 10f64.powi(places as i32); + let rounded = (v * k).round() / k; + let v = if rounded.is_finite() { rounded } else { v }; + let mut s = format!("{v:.places$}"); + if s.starts_with('-') && s[1..].chars().all(|c| c == '0' || c == '.') { + s.remove(0); + } + if zin & 8 != 0 && s.contains('.') { + s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + } + if zin & 4 != 0 { + if let Some(rest) = s.strip_prefix("0.") { + s = format!(".{rest}"); + } else if let Some(rest) = s.strip_prefix("-0.") { + s = format!("-.{rest}"); + } + } + if sep != '.' { + s = s.replace('.', &sep.to_string()); + } + s +} + +/// What the dimension says: its own text, where `<>` stands for the +/// measurement (empty is the measurement alone, a single space nothing). +fn content(d: &Dimension, measured: String) -> Option { + match d.text.as_str() { + " " => None, + "" => Some(measured), + t => Some(t.replace("<>", &measured)), + } +} + +/// The text's middle the dimension stores (group 11), when it stores one: +/// a generator that leaves out the block leaves it at the origin too. +fn stored_text(d: &Dimension) -> Option { + let t = d.text_midpoint; + (d.flags & 128 != 0 || t.x != 0.0 || t.y != 0.0).then(|| p(t)) +} + +pub fn graphics(d: &Dimension, s: &Style, measure: &dyn Fn(&str) -> f64) -> Option { + match d.kind { + DimensionKind::Linear | DimensionKind::Aligned => linear(d, s, measure), + DimensionKind::Radius | DimensionKind::Diameter => radial(d, s), + DimensionKind::Angular | DimensionKind::Angular3Point => angular(d, s), + DimensionKind::Ordinate => ordinate(d, s), + } +} + +/// An extension line from `from` to `to` on the dimension line: `exo` short +/// of the feature, `exe` past the line. +fn extension(g: &mut Graphics, from: P, to: P, s: &Style) { + if let Some(e) = unit(sub(to, from)) { + if len(sub(to, from)) > s.exo { + g.lines + .push([add(from, mul(e, s.exo)), add(to, mul(e, s.exe))]); + } + } +} + +/// The ends of a dimension line or arc: arrowheads, or with DIMTSZ ticks +/// at 45 degrees and the line run on by DIMDLE. +fn ends(g: &mut Graphics, a: P, b: P, s: &Style) { + let Some(u) = unit(sub(b, a)) else { + return; + }; + if s.tsz > 0.0 { + let t = mul(add(u, perp(u)), 0.5 * s.tsz); + for c in [a, b] { + g.lines.push([sub(c, t), add(c, t)]); + } + } else { + g.arrows.push((a, b)); + g.arrows.push((b, a)); + } +} + +fn linear(d: &Dimension, s: &Style, measure: &dyn Fn(&str) -> f64) -> Option { + let (p1, p2, on) = (p(d.point13), p(d.point14), p(d.definition_point)); + let u = match d.kind { + DimensionKind::Aligned => unit(sub(p2, p1)).unwrap_or([1.0, 0.0]), + _ => polar(d.angle), + }; + let a = add(on, mul(u, dot(sub(p1, on), u))); + let b = add(on, mul(u, dot(sub(p2, on), u))); + let mut g = Graphics::default(); + extension(&mut g, p1, a, s); + extension(&mut g, p2, b, s); + let measured = dot(sub(p2, p1), u).abs() * s.lfac; + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(u[1].atan2(u[0])) + }; + let up = perp(polar(rotation)); + let mid = mul(add(a, b), 0.5); + let label = content(d, number(measured, s.dec, s.zin, s.dsep)).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| { + if s.above { + add(mid, mul(up, s.gap + s.txt / 2.0)) + } else { + mid + } + }), + text, + rotation, + }); + let du = unit(sub(b, a)).unwrap_or(u); + let run_on = if s.tsz > 0.0 { + mul(du, s.dle) + } else { + [0.0, 0.0] + }; + let (from, to) = (sub(a, run_on), add(b, run_on)); + // Text on the line breaks it, a gap either side. + let cut = label.as_ref().filter(|l| { + let off = sub(l.at, a); + let along = dot(off, du); + (dot(off, perp(du))).abs() < s.txt / 2.0 && along > 0.0 && along < len(sub(b, a)) + }); + match cut { + Some(l) => { + let half = measure(&l.text) * s.txt / 2.0 + s.gap; + let along = dot(sub(l.at, a), du); + let (c0, c1) = (add(a, mul(du, along - half)), add(a, mul(du, along + half))); + if dot(sub(c0, from), du) > 0.0 { + g.lines.push([from, c0]); + } + if dot(sub(to, c1), du) > 0.0 { + g.lines.push([c1, to]); + } + } + None => g.lines.push([from, to]), + } + ends(&mut g, a, b, s); + g.label = label; + Some(g) +} + +fn radial(d: &Dimension, s: &Style) -> Option { + let (far, near) = (p(d.definition_point), p(d.point15)); + let mut g = Graphics::default(); + let label_at = stored_text(d); + let (measured, prefix) = match d.kind { + DimensionKind::Radius => (len(sub(near, far)), "R"), + _ => (len(sub(near, far)), "%%c"), + }; + let u = unit(sub(near, far))?; + match d.kind { + DimensionKind::Radius => { + // From the centre to the curve, or from the curve out to text + // placed outside it. + let outside = label_at.filter(|t| len(sub(*t, far)) > measured); + match outside { + Some(t) => g.lines.push([near, t]), + None => g.lines.push([far, near]), + } + g.arrows.push((near, far)); + } + _ => { + g.lines.push([far, near]); + g.arrows.push((near, far)); + g.arrows.push((far, near)); + } + } + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(u[1].atan2(u[0])) + }; + let mid = mul(add(far, near), 0.5); + // The prefix is part of the measurement, `<>` included. + let measured = format!( + "{prefix}{}", + number(measured * s.lfac, s.dec, s.zin, s.dsep) + ); + g.label = content(d, measured).map(|text| Label { + at: label_at.unwrap_or_else(|| add(mid, mul(perp(polar(rotation)), s.gap + s.txt / 2.0))), + text, + rotation, + }); + Some(g) +} + +/// Where two lines cross, each through two points. +fn crossing(a0: P, a1: P, b0: P, b1: P) -> Option { + let (r, q) = (sub(a1, a0), sub(b1, b0)); + let den = r[0] * q[1] - r[1] * q[0]; + if den.abs() < 1e-12 * len(r) * len(q) || !den.is_finite() { + return None; + } + let t = ((b0[0] - a0[0]) * q[1] - (b0[1] - a0[1]) * q[0]) / den; + Some(add(a0, mul(r, t))) +} + +fn angular(d: &Dimension, s: &Style) -> Option { + use std::f64::consts::{PI, TAU}; + // The two legs, as the rays out of the centre and the feature points + // along each. + let (centre, legs, on) = match d.kind { + DimensionKind::Angular3Point => { + let c = p(d.point15); + ( + c, + [[c, p(d.point13)], [c, p(d.point14)]], + p(d.definition_point), + ) + } + _ => { + let l1 = [p(d.point13), p(d.point14)]; + let l2 = [p(d.point15), p(d.definition_point)]; + ( + crossing(l1[0], l1[1], l2[0], l2[1])?, + [l1, l2], + p(d.point16), + ) + } + }; + let radius = len(sub(on, centre)); + if !(radius > 1e-12) { + return None; + } + let theta = (on[1] - centre[1]).atan2(on[0] - centre[0]); + let (start, sweep) = match d.kind { + DimensionKind::Angular3Point => { + let a1 = angle_of(sub(legs[0][1], centre))?; + let a2 = angle_of(sub(legs[1][1], centre))?; + let ccw = (a2 - a1).rem_euclid(TAU); + if (theta - a1).rem_euclid(TAU) <= ccw { + (a1, ccw) + } else { + (a2, TAU - ccw) + } + } + _ => { + // The sector of the four the two lines make that holds the arc's + // point. + let a1 = angle_of(sub(legs[0][1], legs[0][0]))?; + let a2 = angle_of(sub(legs[1][1], legs[1][0]))?; + let mut rays = [a1, a1 + PI, a2, a2 + PI].map(|a| a.rem_euclid(TAU)); + rays.sort_by(f64::total_cmp); + let t = theta.rem_euclid(TAU); + let i = rays.iter().rposition(|r| *r <= t).unwrap_or(3); + let (lo, hi) = (rays[i], if i == 3 { rays[0] + TAU } else { rays[i + 1] }); + (lo, hi - lo) + } + }; + let mut g = Graphics::default(); + g.arcs.push((centre, radius, start, sweep)); + let e1 = add(centre, mul(polar(start), radius)); + let e2 = add(centre, mul(polar(start + sweep), radius)); + // An extension line from the leg's nearer feature point out to the arc, + // when the arc lies beyond the leg. + for (end, ray) in [(e1, polar(start)), (e2, polar(start + sweep))] { + let leg = legs.iter().find(|l| { + let dir = sub(l[1], l[0]); + (dir[0] * ray[1] - dir[1] * ray[0]).abs() < 1e-9 * len(dir) + }); + if let Some(leg) = leg { + let ts: Vec<(f64, P)> = leg + .iter() + .map(|q| (dot(sub(*q, centre), ray), *q)) + .collect(); + let near = ts + .iter() + .min_by(|a, b| (a.0 - radius).abs().total_cmp(&(b.0 - radius).abs()))?; + let (lo, hi) = (ts[0].0.min(ts[1].0), ts[0].0.max(ts[1].0)); + if radius > hi + 1e-9 || radius < lo - 1e-9 { + extension(&mut g, near.1, end, s); + } + } + } + if s.tsz > 0.0 { + ends(&mut g, e1, e2, s); + } else { + let t1 = perp(polar(start)); + let t2 = perp(polar(start + sweep)); + g.arrows.push((e1, add(e1, t1))); + g.arrows.push((e2, sub(e2, t2))); + } + let mid = start + sweep / 2.0; + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(mid + PI / 2.0) + }; + let lift = if s.above { s.gap + s.txt / 2.0 } else { 0.0 }; + let degrees = number(sweep.to_degrees(), s.adec, 0, s.dsep) + "%%d"; + g.label = content(d, degrees).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| add(centre, mul(polar(mid), radius + lift))), + text, + rotation, + }); + Some(g) +} + +fn angle_of(v: P) -> Option { + unit(v).map(|u| u[1].atan2(u[0])) +} + +fn ordinate(d: &Dimension, s: &Style) -> Option { + use std::f64::consts::FRAC_PI_2; + let (origin, feature, end) = (p(d.definition_point), p(d.point13), p(d.point14)); + let x_type = d.flags & 64 != 0; + let mut g = Graphics::default(); + extension(&mut g, feature, end, s); + let measured = if x_type { + feature[0] - origin[0] + } else { + feature[1] - origin[1] + }; + let rotation = if x_type { FRAC_PI_2 } else { 0.0 }; + let dir = unit(sub(end, feature)).unwrap_or(polar(rotation)); + g.label = content(d, number(measured.abs() * s.lfac, s.dec, s.zin, s.dsep)).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| add(end, mul(dir, s.gap + s.txt))), + text, + rotation, + }); + Some(g) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_follow_the_style() { + assert_eq!(number(12.5, 4, 0, '.'), "12.5000"); + assert_eq!(number(12.5, 4, 8, '.'), "12.5"); + assert_eq!(number(12.0, 2, 8, ','), "12"); + assert_eq!(number(0.25, 2, 4, ','), ",25"); + assert_eq!(number(-0.0001, 2, 0, '.'), "0.00"); + assert_eq!(number(100.5, 0, 0, '.'), "101"); + assert_eq!(number(2.25, 1, 0, '.'), "2.3"); + } + + #[test] + fn readable_text_never_runs_upside_down() { + use std::f64::consts::PI; + assert!((readable(PI) - 0.0).abs() < 1e-12); + assert!((readable(-PI / 2.0) - PI / 2.0).abs() < 1e-12); + assert!((readable(PI / 2.0) - PI / 2.0).abs() < 1e-12); + assert!((readable(0.3) - 0.3).abs() < 1e-12); + } +} diff --git a/crates/exav-render/src/formats/dwg/font.rs b/crates/exav-render/src/formats/dwg/font.rs index 9b8a8a3..d2a85e2 100644 --- a/crates/exav-render/src/formats/dwg/font.rs +++ b/crates/exav-render/src/formats/dwg/font.rs @@ -409,6 +409,73 @@ fn table_for(f: TrueTypeFace) -> (&'static [u16], &'static [(u32, u16)], f32) { } } +/// A font whose letters stand for other characters. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Symbols { + /// Symbol's Greek and mathematical signs; the SHX Greek fonts' letters. + Symbol, + /// greeks.shx, greekc.shx: Greek letters only. + Greek, + /// gdt.shx: the geometric tolerancing signs on lowercase letters. + Gdt, +} + +/// The symbol set a font file draws, by name. +pub fn symbols_of(file: &str) -> Option { + let lower = file.trim().to_ascii_lowercase(); + let base = lower.rsplit(['/', '\\']).next().unwrap_or(&lower); + let stem = base.rsplit_once('.').map_or(base, |(s, _)| s); + match stem { + "symbol" => Some(Symbols::Symbol), + "greeks" | "greekc" => Some(Symbols::Greek), + "gdt" | "amgdt" => Some(Symbols::Gdt), + _ => None, + } +} + +/// Greek for the Latin letters, in the order Symbol puts them: A Alpha, C +/// Chi, F Phi, J theta symbol, Q Theta, V final sigma, W Omega... +const GREEK_UPPER: [char; 26] = [ + 'Α', 'Î’', 'Χ', 'Δ', 'Ε', 'Φ', 'Γ', 'Η', 'Ι', 'Ï‘', 'Κ', 'Λ', 'Μ', 'Î�', 'Ο', 'Î ', 'Θ', 'Ρ', 'Σ', + 'Τ', 'Î¥', 'Ï‚', 'Ω', 'Ξ', 'Ψ', 'Ζ', +]; +const GREEK_LOWER: [char; 26] = [ + 'α', 'β', 'χ', 'δ', 'ε', 'φ', 'γ', 'η', 'ι', 'Ï•', 'κ', 'λ', 'μ', 'ν', 'ο', 'Ï€', 'θ', 'Ï�', 'σ', + 'Ï„', 'Ï…', 'Ï–', 'ω', 'ξ', 'ψ', 'ζ', +]; +/// gdt.shx's lowercase letters: a angularity, b perpendicularity, c +/// flatness, d surface profile, e circularity, f parallelism, g +/// cylindricity, h circular runout, i symmetry, j position, k line profile, +/// l LMC, m MMC, n diameter, o square, p projected zone, q centre line, r +/// concentricity, s RFS, t total runout, u straightness, v counterbore, w +/// countersink, x depth, y conical taper, z slope. +const GDT: [char; 26] = [ + '∠', '⊥', 'â�¥', '⌓', 'â—‹', '∥', 'âŒ', '↗', '⌯', '⌖', '⌒', 'â“�', 'â“‚', '⌀', 'â–¡', 'â“…', 'â„„', 'â—Ž', 'Ⓢ', + '⌰', 'â�¤', '⌴', '⌵', '↧', '⌲', '⌳', +]; + +/// What a string in a symbol font shows. +pub fn map_symbols(s: &str, set: Symbols) -> String { + let letter = |c: char, table: &[char; 26]| table[(c as u8 - b'a') as usize]; + s.chars() + .map(|c| match (set, c) { + (Symbols::Symbol | Symbols::Greek, 'A'..='Z') => GREEK_UPPER[(c as u8 - b'A') as usize], + (Symbols::Symbol | Symbols::Greek, 'a'..='z') => letter(c, &GREEK_LOWER), + (Symbols::Gdt, 'a'..='z') => letter(c, &GDT), + (Symbols::Symbol, '"') => '∀', + (Symbols::Symbol, '$') => '∃', + (Symbols::Symbol, '\'') => '∋', + (Symbols::Symbol, '*') => '∗', + (Symbols::Symbol, '-') => '−', + (Symbols::Symbol, '@') => '≅', + (Symbols::Symbol, '\\') => '∴', + (Symbols::Symbol, '^') => '⊥', + (Symbols::Symbol, '~') => '∼', + _ => c, + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -420,6 +487,28 @@ mod tests { } } + /// Greek and Cyrillic have their own widths, not the fallback's. + #[test] + fn greek_and_cyrillic_are_measured() { + let m = Metrics::for_face(Face::default()); + assert!(m.advance('Ж') > m.advance('г') * 1.5); + assert!(m.advance('Ω') > m.advance('ι') * 1.5); + assert_eq!(m.advance('Ω'), m.advance('\u{03A9}')); + } + + #[test] + fn symbol_fonts_draw_what_their_letters_stand_for() { + assert_eq!(symbols_of("SYMBOL.TTF"), Some(Symbols::Symbol)); + assert_eq!(symbols_of("fonts\\greeks.shx"), Some(Symbols::Greek)); + assert_eq!(symbols_of("gdt"), Some(Symbols::Gdt)); + assert_eq!(symbols_of("arial.ttf"), None); + assert_eq!(map_symbols("abg D=@", Symbols::Symbol), "αβγ Δ=≅"); + // The SHX Greek fonts change letters only. + assert_eq!(map_symbols("abg D=@", Symbols::Greek), "αβγ Δ=@"); + // GD&T: diameter 0.05, position, MMC; capitals stay. + assert_eq!(map_symbols("n0.05 jm A", Symbols::Gdt), "⌀0.05 ⌖Ⓜ A"); + } + /// The case the corpus is full of: a stock stroke font, stored with no /// extension at all. Testing for ".shx" would miss every one of these. #[test] diff --git a/crates/exav-render/src/formats/dwg/linetype.rs b/crates/exav-render/src/formats/dwg/linetype.rs index 6e61370..14e720f 100644 --- a/crates/exav-render/src/formats/dwg/linetype.rs +++ b/crates/exav-render/src/formats/dwg/linetype.rs @@ -10,27 +10,74 @@ /// segments for something that reads as solid anyway. const MAX_DASHES: usize = 20_000; -/// Text carried by one element of a complex linetype. +/// Text or a shape carried by one element of a complex linetype. /// /// A linetype like GAZ draws its dashes and stamps "GAS" into the gaps at every /// repetition. Without this the line is drawn as plain dashes and the label /// that says what the pipe carries is missing. #[derive(Clone, Debug)] pub struct Label { - /// Index into `runs` of the element that carries the text. + /// Index into `runs` of the element that carries the label, which is + /// placed at that element's end. pub run: usize, - pub text: String, - /// Text height in drawing units. + pub mark: Mark, + /// Text height, or the shape's unit, in drawing units. pub height: f64, - /// Offset from the element's position: along the line, then across it. + /// Offset from the element's end: along the line, then across it. pub offset: [f64; 2], pub rotation: f64, /// Rotation is world-absolute rather than relative to the line. pub absolute_rotation: bool, - /// The face the label's text style resolves to. These are usually stroke - /// fonts, since a linetype that stamps a symbol along a line is drawn from - /// an SHX shape file. - pub face: super::font::Face, +} + +#[derive(Clone, Debug)] +pub enum Mark { + /// `face` is what the label's text style resolves to. + Text { + text: String, + face: super::font::Face, + }, + /// A shape of ltypeshp.shx, as `ltypeshp_shape` draws it. + Shape(&'static [ShapePart]), +} + +/// A piece of a shape, in shape units from its insertion point, the line +/// running along +x. +#[derive(Debug, PartialEq)] +pub enum ShapePart { + Line(&'static [[f64; 2]]), + /// Centre, radius, start angle and signed sweep (radians). + Arc([f64; 2], f64, f64, f64), +} + +/// The shapes of ltypeshp.shx, which AutoCAD's own complex linetypes +/// (TRACKS, ZIGZAG, FENCELINE1 and 2, BATTING) stamp, redrawn here: the file +/// itself cannot be shipped, any more than an SHX font. Others are left out. +pub fn ltypeshp_shape(number: i16) -> Option<&'static [ShapePart]> { + use std::f64::consts::PI; + const TRACK1: &[ShapePart] = &[ShapePart::Line(&[[0.0, 1.0], [0.0, -1.0]])]; + const ZIG: &[ShapePart] = &[ShapePart::Line(&[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]])]; + const BOX: &[ShapePart] = &[ShapePart::Line(&[ + [0.0, -1.0], + [2.0, -1.0], + [2.0, 1.0], + [0.0, 1.0], + [0.0, -1.0], + ])]; + const CIRC1: &[ShapePart] = &[ShapePart::Arc([1.0, 0.0], 1.0, PI, -2.0 * PI)]; + const BAT: &[ShapePart] = &[ + ShapePart::Line(&[[0.0, 0.0], [-1.0, 2.0]]), + ShapePart::Arc([1.0, 2.0], 2.0, PI, -PI), + ShapePart::Line(&[[3.0, 2.0], [2.0, 0.0]]), + ]; + match number { + 130 => Some(TRACK1), + 131 => Some(ZIG), + 132 => Some(BOX), + 133 => Some(CIRC1), + 134 => Some(BAT), + _ => None, + } } /// One placed copy of a label, in drawing coordinates. @@ -215,7 +262,8 @@ pub fn dash_polyline( Some(out) } -/// Where each copy of the pattern's embedded text lands along a polyline. +/// Where each copy of the pattern's embedded text and shapes lands along a +/// polyline: the end of the element carrying it, shifted by its offset. /// /// The phase is the same walk the dashes use, so a label sits in the gap its /// definition puts it in rather than drifting against the dashes. @@ -241,7 +289,7 @@ pub fn dash_labels(pts: &[[f64; 2]], closed: bool, pattern: &Pattern) -> Vec len { continue; } @@ -323,12 +371,14 @@ mod embedded_text { &[10.0, -5.0], &[Label { run: 1, - text: "GAS".to_string(), + mark: Mark::Text { + text: "GAS".to_string(), + face: super::super::font::Face::Stroke, + }, height: 2.54, offset: [-2.54, -1.27], rotation: 0.0, absolute_rotation: false, - face: super::super::font::Face::Stroke, }], scale, ) @@ -361,9 +411,9 @@ mod embedded_text { fn the_offsets_place_it_along_and_across_the_line() { let places = dash_labels(&[[0.0, 0.0], [100.0, 0.0]], false, &gaz(1.0)); let first = &places[0]; - // The dash runs 0..10, so the gap starts at 10 and the label sits - // 2.54 back along it and 1.27 below it. - assert!((first.x - (10.0 - 2.54)).abs() < 1e-6, "x {}", first.x); + // The gap carrying it runs 10..15, and the label sits 2.54 back from + // its end and 1.27 below the line. + assert!((first.x - (15.0 - 2.54)).abs() < 1e-6, "x {}", first.x); assert!((first.y - -1.27).abs() < 1e-6, "y {}", first.y); assert!(first.angle.abs() < 1e-9, "angle {}", first.angle); } @@ -374,7 +424,7 @@ mod embedded_text { assert!((p.labels[0].height - 5.08).abs() < 1e-9); let places = dash_labels(&[[0.0, 0.0], [200.0, 0.0]], false, &p); assert!( - (places[0].x - (20.0 - 5.08)).abs() < 1e-6, + (places[0].x - (30.0 - 5.08)).abs() < 1e-6, "x {}", places[0].x ); @@ -418,12 +468,14 @@ mod tests { assert!(dash_polyline(&pts, false, &p).is_none()); let label = Label { run: 1, - text: "X".to_string(), + mark: Mark::Text { + text: "X".to_string(), + face: super::super::font::Face::Stroke, + }, height: 1.0, offset: [0.0, 0.0], rotation: 0.0, absolute_rotation: false, - face: super::super::font::Face::Stroke, }; let labelled = Pattern::with_labels(&[1.0, -1.0], &[label], 1.0).unwrap(); assert!(dash_labels(&pts, false, &labelled).is_empty()); diff --git a/crates/exav-render/src/formats/dwg/metrics.rs b/crates/exav-render/src/formats/dwg/metrics.rs index 700e5c7..89ad8fc 100644 --- a/crates/exav-render/src/formats/dwg/metrics.rs +++ b/crates/exav-render/src/formats/dwg/metrics.rs @@ -49,13 +49,187 @@ pub static SANS: [u16; 560] = [ 3186, 2278, 2732, 2048, 1430, 2804, 1504, 910, 3632, 3632, 2732, 2958, 2048, 2278, 2502, 2048, 2048, 2378, 1856, 2732, 2958, 2736, 2732, 2278, 2048, 910, 3020, 2278, 2958, 1364, 2732, 2048, ]; -pub static SANS_SPARSE: [(u32, u16); 22] = [ +pub static SANS_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2734), + (0x0387, 1138), + (0x0388, 3212), + (0x0389, 3432), + (0x038a, 1572), + (0x038c, 3172), + (0x038e, 3504), + (0x038f, 3082), + (0x0390, 910), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2256), + (0x0394, 2736), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2732), + (0x039b, 2736), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2662), + (0x039f, 3186), + (0x03a0, 2958), + (0x03a1, 2732), + (0x03a3, 2532), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3268), + (0x03a7, 2732), + (0x03a8, 3422), (0x03a9, 3062), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2368), + (0x03ad, 1826), + (0x03ae, 2278), + (0x03af, 910), + (0x03b0, 2240), + (0x03b1, 2368), + (0x03b2, 2356), + (0x03b3, 2048), + (0x03b4, 2280), + (0x03b5, 1826), + (0x03b6, 1806), + (0x03b7, 2278), + (0x03b8, 2278), + (0x03b9, 910), + (0x03ba, 2048), + (0x03bb, 2048), (0x03bc, 2360), + (0x03bd, 2048), + (0x03be, 1834), + (0x03bf, 2278), + (0x03c0, 2826), + (0x03c1, 2330), + (0x03c2, 1974), + (0x03c3, 2528), + (0x03c4, 1618), + (0x03c5, 2240), + (0x03c6, 2656), + (0x03c7, 2150), + (0x03c8, 2920), + (0x03c9, 3198), + (0x03ca, 910), + (0x03cb, 2240), + (0x03cc, 2278), + (0x03cd, 2240), + (0x03ce, 3198), + (0x03cf, 2732), + (0x03d0, 2356), + (0x03d1, 2240), + (0x03d2, 3164), + (0x03d3, 3924), + (0x03d4, 3164), + (0x03d5, 2294), + (0x03d6, 3198), + (0x0400, 2732), + (0x0401, 2734), + (0x0402, 3542), + (0x0403, 2218), + (0x0404, 2944), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2048), + (0x0409, 4330), + (0x040a, 4138), + (0x040b, 3498), + (0x040c, 2386), + (0x040d, 2944), + (0x040e, 2602), + (0x040f, 2944), + (0x0410, 2732), + (0x0411, 2688), + (0x0412, 2732), + (0x0413, 2218), + (0x0414, 2774), + (0x0415, 2732), + (0x0416, 3782), + (0x0417, 2474), + (0x0418, 2944), + (0x0419, 2944), + (0x041a, 2386), + (0x041b, 2688), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2944), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2602), + (0x0424, 3114), + (0x0425, 2732), + (0x0426, 3030), + (0x0427, 2730), + (0x0428, 3754), + (0x0429, 3840), + (0x042a, 3242), + (0x042b, 3626), + (0x042c, 2688), + (0x042d, 2944), + (0x042e, 4138), + (0x042f, 2958), + (0x0430, 2278), + (0x0431, 2346), + (0x0432, 2176), + (0x0433, 1494), + (0x0434, 2390), + (0x0435, 2278), + (0x0436, 2740), + (0x0437, 1878), + (0x0438, 2288), + (0x0439, 2288), + (0x043a, 1792), + (0x043b, 2390), + (0x043c, 2816), + (0x043d, 2262), + (0x043e, 2278), + (0x043f, 2218), + (0x0440, 2278), + (0x0441, 2048), + (0x0442, 1876), + (0x0443, 2048), + (0x0444, 3370), + (0x0445, 2048), + (0x0446, 2346), + (0x0447, 2134), + (0x0448, 3286), + (0x0449, 3370), + (0x044a, 2560), + (0x044b, 2944), + (0x044c, 2134), + (0x044d, 2090), + (0x044e, 3072), + (0x044f, 2218), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2278), + (0x0453, 1494), + (0x0454, 2090), + (0x0455, 2048), + (0x0456, 910), + (0x0457, 1138), + (0x0458, 910), + (0x0459, 3712), + (0x045a, 3328), + (0x045b, 2278), + (0x045c, 1792), + (0x045d, 2288), + (0x045e, 2048), + (0x045f, 2260), (0x2013, 2278), (0x2014, 4096), (0x2018, 910), @@ -69,6 +243,7 @@ pub static SANS_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -110,13 +285,187 @@ pub static SANS_BOLD: [u16; 560] = [ 3186, 2502, 2732, 2278, 2044, 3414, 2072, 1138, 3906, 3906, 2958, 2958, 2278, 2502, 2502, 2278, 2048, 2642, 1982, 2958, 2958, 2732, 2732, 2278, 2278, 1138, 3174, 2502, 2958, 1594, 2732, 2278, ]; -pub static SANS_BOLD_SPARSE: [(u32, u16); 22] = [ +pub static SANS_BOLD_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1904), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3494), + (0x0389, 3710), + (0x038a, 1940), + (0x038c, 3378), + (0x038e, 3798), + (0x038f, 3432), + (0x0390, 1138), + (0x0391, 2958), + (0x0392, 2958), + (0x0393, 2462), + (0x0394, 2944), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2958), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2636), + (0x039f, 3186), + (0x03a0, 2958), + (0x03a1, 2732), + (0x03a3, 2458), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3362), + (0x03a7, 2732), + (0x03a8, 3314), (0x03a9, 3284), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2518), + (0x03ad, 1848), + (0x03ae, 2502), + (0x03af, 1138), + (0x03b0, 2384), + (0x03b1, 2518), + (0x03b2, 2500), + (0x03b3, 2278), + (0x03b4, 2484), + (0x03b5, 1944), + (0x03b6, 1886), + (0x03b7, 2502), + (0x03b8, 2214), + (0x03b9, 1138), + (0x03ba, 2284), + (0x03bb, 2278), (0x03bc, 2506), + (0x03bd, 2278), + (0x03be, 1824), + (0x03bf, 2502), + (0x03c0, 3136), + (0x03c1, 2534), + (0x03c2, 2130), + (0x03c3, 2802), + (0x03c4, 1828), + (0x03c5, 2384), + (0x03c6, 2930), + (0x03c7, 2358), + (0x03c8, 3086), + (0x03c9, 3460), + (0x03ca, 1138), + (0x03cb, 2384), + (0x03cc, 2502), + (0x03cd, 2384), + (0x03ce, 3460), + (0x03cf, 2958), + (0x03d0, 2500), + (0x03d1, 2384), + (0x03d2, 3154), + (0x03d3, 4100), + (0x03d4, 3154), + (0x03d5, 3070), + (0x03d6, 3460), + (0x0400, 2732), + (0x0401, 2740), + (0x0402, 3626), + (0x0403, 2322), + (0x0404, 2914), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1130), + (0x0408, 2278), + (0x0409, 4480), + (0x040a, 4352), + (0x040b, 3584), + (0x040c, 2500), + (0x040d, 2944), + (0x040e, 2548), + (0x040f, 2944), + (0x0410, 2958), + (0x0411, 2944), + (0x0412, 2958), + (0x0413, 2322), + (0x0414, 2918), + (0x0415, 2732), + (0x0416, 3702), + (0x0417, 2566), + (0x0418, 2944), + (0x0419, 2944), + (0x041a, 2500), + (0x041b, 2874), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2944), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2548), + (0x0424, 3496), + (0x0425, 2732), + (0x0426, 2992), + (0x0427, 2878), + (0x0428, 4116), + (0x0429, 4174), + (0x042a, 3562), + (0x042b, 4010), + (0x042c, 2944), + (0x042d, 2914), + (0x042e, 4224), + (0x042f, 2944), + (0x0430, 2278), + (0x0431, 2530), + (0x0432, 2518), + (0x0433, 1706), + (0x0434, 2600), + (0x0435, 2278), + (0x0436, 2904), + (0x0437, 2036), + (0x0438, 2518), + (0x0439, 2518), + (0x043a, 2050), + (0x043b, 2602), + (0x043c, 3030), + (0x043d, 2474), + (0x043e, 2502), + (0x043f, 2474), + (0x0440, 2502), + (0x0441, 2278), + (0x0442, 2006), + (0x0443, 2278), + (0x0444, 3584), + (0x0445, 2278), + (0x0446, 2518), + (0x0447, 2378), + (0x0448, 3414), + (0x0449, 3456), + (0x044a, 2986), + (0x044b, 3498), + (0x044c, 2518), + (0x044d, 2262), + (0x044e, 3498), + (0x044f, 2390), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2502), + (0x0453, 1706), + (0x0454, 2262), + (0x0455, 2278), + (0x0456, 1138), + (0x0457, 1152), + (0x0458, 1138), + (0x0459, 3968), + (0x045a, 3712), + (0x045b, 2502), + (0x045c, 2050), + (0x045d, 2518), + (0x045e, 2278), + (0x045f, 2474), (0x2013, 2278), (0x2014, 4096), (0x2018, 1138), @@ -130,6 +479,7 @@ pub static SANS_BOLD_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -171,13 +521,187 @@ pub static SANS_ITALIC: [u16; 560] = [ 3186, 2278, 2732, 2048, 1204, 2576, 1424, 910, 3618, 3608, 2732, 2958, 2048, 2278, 2502, 2048, 2048, 2378, 1856, 2732, 2958, 2732, 2732, 2278, 2048, 910, 3092, 2278, 2958, 1364, 2732, 2048, ]; -pub static SANS_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SANS_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2732), + (0x0387, 1138), + (0x0388, 3230), + (0x0389, 3466), + (0x038a, 1592), + (0x038c, 3252), + (0x038e, 3544), + (0x038f, 3174), + (0x0390, 910), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2336), + (0x0394, 2748), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2732), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2656), + (0x039f, 3186), + (0x03a0, 2968), + (0x03a1, 2732), + (0x03a3, 2456), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3428), + (0x03a7, 2732), + (0x03a8, 3402), (0x03a9, 3116), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2336), + (0x03ad, 1800), + (0x03ae, 2274), + (0x03af, 910), + (0x03b0, 2252), + (0x03b1, 2336), + (0x03b2, 2340), + (0x03b3, 2048), + (0x03b4, 2276), + (0x03b5, 1800), + (0x03b6, 1898), + (0x03b7, 2274), + (0x03b8, 2222), + (0x03b9, 910), + (0x03ba, 2048), + (0x03bb, 2016), (0x03bc, 2244), + (0x03bd, 2048), + (0x03be, 1830), + (0x03bf, 2278), + (0x03c0, 2744), + (0x03c1, 2346), + (0x03c2, 1992), + (0x03c3, 2470), + (0x03c4, 1532), + (0x03c5, 2252), + (0x03c6, 2672), + (0x03c7, 2236), + (0x03c8, 2980), + (0x03c9, 3190), + (0x03ca, 910), + (0x03cb, 2252), + (0x03cc, 2278), + (0x03cd, 2252), + (0x03ce, 3190), + (0x03cf, 2732), + (0x03d0, 2068), + (0x03d1, 2252), + (0x03d2, 2932), + (0x03d3, 3612), + (0x03d4, 2932), + (0x03d5, 2278), + (0x03d6, 3190), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3452), + (0x0403, 2228), + (0x0404, 2898), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2048), + (0x0409, 4366), + (0x040a, 4024), + (0x040b, 3458), + (0x040c, 2414), + (0x040d, 2930), + (0x040e, 2618), + (0x040f, 2964), + (0x0410, 2732), + (0x0411, 2666), + (0x0412, 2732), + (0x0413, 2228), + (0x0414, 2884), + (0x0415, 2732), + (0x0416, 3756), + (0x0417, 2514), + (0x0418, 2930), + (0x0419, 2930), + (0x041a, 2414), + (0x041b, 2808), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2968), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2618), + (0x0424, 3256), + (0x0425, 2732), + (0x0426, 2978), + (0x0427, 2758), + (0x0428, 3770), + (0x0429, 3780), + (0x042a, 3298), + (0x042b, 3630), + (0x042c, 2666), + (0x042d, 2842), + (0x042e, 4188), + (0x042f, 2792), + (0x0430, 2278), + (0x0431, 2304), + (0x0432, 2138), + (0x0433, 2020), + (0x0434, 2264), + (0x0435, 2278), + (0x0436, 2818), + (0x0437, 1904), + (0x0438, 2278), + (0x0439, 2278), + (0x043a, 1934), + (0x043b, 2310), + (0x043c, 2808), + (0x043d, 2254), + (0x043e, 2278), + (0x043f, 2254), + (0x0440, 2278), + (0x0441, 2048), + (0x0442, 3412), + (0x0443, 2048), + (0x0444, 3422), + (0x0445, 2048), + (0x0446, 2342), + (0x0447, 2120), + (0x0448, 3400), + (0x0449, 3486), + (0x044a, 2544), + (0x044b, 3014), + (0x044c, 2154), + (0x044d, 2014), + (0x044e, 3080), + (0x044f, 2188), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2278), + (0x0453, 1548), + (0x0454, 2030), + (0x0455, 2048), + (0x0456, 910), + (0x0457, 910), + (0x0458, 910), + (0x0459, 3728), + (0x045a, 3390), + (0x045b, 2278), + (0x045c, 1934), + (0x045d, 2278), + (0x045e, 2048), + (0x045f, 2278), (0x2013, 2278), (0x2014, 4096), (0x2018, 910), @@ -191,6 +715,7 @@ pub static SANS_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -232,13 +757,187 @@ pub static SANS_BOLD_ITALIC: [u16; 560] = [ 3186, 2502, 2732, 2278, 1852, 3216, 2086, 1138, 3906, 3898, 2958, 2958, 2278, 2502, 2502, 2278, 2048, 2642, 1982, 2958, 2958, 2732, 2732, 2278, 2278, 1138, 3198, 2502, 2958, 1594, 2732, 2278, ]; -pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3498), + (0x0389, 3710), + (0x038a, 1938), + (0x038c, 3458), + (0x038e, 3810), + (0x038f, 3470), + (0x0390, 1138), + (0x0391, 2958), + (0x0392, 2958), + (0x0393, 2498), + (0x0394, 2750), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2958), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2692), + (0x039f, 3186), + (0x03a0, 2940), + (0x03a1, 2732), + (0x03a3, 2418), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3368), + (0x03a7, 2732), + (0x03a8, 3394), (0x03a9, 3198), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2540), + (0x03ad, 1960), + (0x03ae, 2502), + (0x03af, 1138), + (0x03b0, 2422), + (0x03b1, 2540), + (0x03b2, 2542), + (0x03b3, 2278), + (0x03b4, 2500), + (0x03b5, 1960), + (0x03b6, 2014), + (0x03b7, 2502), + (0x03b8, 2284), + (0x03b9, 1138), + (0x03ba, 2318), + (0x03bb, 2278), (0x03bc, 2470), + (0x03bd, 2278), + (0x03be, 1842), + (0x03bf, 2502), + (0x03c0, 2916), + (0x03c1, 2480), + (0x03c2, 2180), + (0x03c3, 2718), + (0x03c4, 1674), + (0x03c5, 2422), + (0x03c6, 2882), + (0x03c7, 2366), + (0x03c8, 3166), + (0x03c9, 3418), + (0x03ca, 1138), + (0x03cb, 2422), + (0x03cc, 2502), + (0x03cd, 2422), + (0x03ce, 3418), + (0x03cf, 2958), + (0x03d0, 2310), + (0x03d1, 2422), + (0x03d2, 3066), + (0x03d3, 4066), + (0x03d4, 3066), + (0x03d5, 3102), + (0x03d6, 3418), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3628), + (0x0403, 2514), + (0x0404, 2944), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2278), + (0x0409, 4482), + (0x040a, 4268), + (0x040b, 3496), + (0x040c, 2518), + (0x040d, 2946), + (0x040e, 2774), + (0x040f, 2946), + (0x0410, 2958), + (0x0411, 2900), + (0x0412, 2958), + (0x0413, 2514), + (0x0414, 2958), + (0x0415, 2732), + (0x0416, 3798), + (0x0417, 2632), + (0x0418, 2946), + (0x0419, 2946), + (0x041a, 2518), + (0x041b, 2814), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2946), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2774), + (0x0424, 3198), + (0x0425, 2732), + (0x0426, 2986), + (0x0427, 2900), + (0x0428, 4010), + (0x0429, 4050), + (0x042a, 3498), + (0x042b, 4096), + (0x042c, 2900), + (0x042d, 2944), + (0x042e, 4268), + (0x042f, 2986), + (0x0430, 2278), + (0x0431, 2536), + (0x0432, 2474), + (0x0433, 2186), + (0x0434, 2532), + (0x0435, 2278), + (0x0436, 3016), + (0x0437, 2090), + (0x0438, 2502), + (0x0439, 2502), + (0x043a, 2076), + (0x043b, 2548), + (0x043c, 3030), + (0x043d, 2474), + (0x043e, 2502), + (0x043f, 2502), + (0x0440, 2502), + (0x0441, 2278), + (0x0442, 3642), + (0x0443, 2278), + (0x0444, 3624), + (0x0445, 2278), + (0x0446, 2646), + (0x0447, 2388), + (0x0448, 3642), + (0x0449, 3830), + (0x044a, 2896), + (0x044b, 3498), + (0x044c, 2432), + (0x044d, 2260), + (0x044e, 3542), + (0x044f, 2412), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2502), + (0x0453, 1922), + (0x0454, 2306), + (0x0455, 2278), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 3970), + (0x045a, 3710), + (0x045b, 2502), + (0x045c, 2076), + (0x045d, 2502), + (0x045e, 2278), + (0x045f, 2502), (0x2013, 2278), (0x2014, 4096), (0x2018, 1138), @@ -252,6 +951,7 @@ pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -293,13 +993,187 @@ pub static SERIF: [u16; 560] = [ 2958, 2048, 2958, 2048, 1138, 2048, 1298, 1138, 3170, 3164, 2958, 2732, 2048, 2502, 2502, 1594, 1818, 2186, 1628, 2732, 2958, 2970, 2502, 1818, 1594, 1138, 2876, 2048, 2732, 1364, 2958, 2048, ]; -pub static SERIF_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1138), + (0x0388, 2842), + (0x0389, 3310), + (0x038a, 1684), + (0x038c, 2958), + (0x038e, 3342), + (0x038f, 3044), + (0x0390, 1102), + (0x0391, 2958), + (0x0392, 2732), + (0x0393, 2368), + (0x0394, 2634), + (0x0395, 2502), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 2958), + (0x0399, 1364), + (0x039a, 2958), + (0x039b, 2970), + (0x039c, 3642), + (0x039d, 2958), + (0x039e, 2634), + (0x039f, 2958), + (0x03a0, 2958), + (0x03a1, 2278), + (0x03a3, 2384), + (0x03a4, 2502), + (0x03a5, 2958), + (0x03a6, 2994), + (0x03a7, 2958), + (0x03a8, 3022), (0x03a9, 3044), + (0x03aa, 1364), + (0x03ab, 2958), + (0x03ac, 2146), + (0x03ad, 1720), + (0x03ae, 2142), + (0x03af, 1102), + (0x03b0, 2028), + (0x03b1, 2146), + (0x03b2, 2084), + (0x03b3, 1810), + (0x03b4, 1930), + (0x03b5, 1720), + (0x03b6, 1696), + (0x03b7, 2142), + (0x03b8, 1962), + (0x03b9, 1102), + (0x03ba, 2064), + (0x03bb, 1986), (0x03bc, 2196), + (0x03bd, 1852), + (0x03be, 1826), + (0x03bf, 2048), + (0x03c0, 2068), + (0x03c1, 2044), + (0x03c2, 1622), + (0x03c3, 2208), + (0x03c4, 1646), + (0x03c5, 2028), + (0x03c6, 2364), + (0x03c7, 1818), + (0x03c8, 2564), + (0x03c9, 2696), + (0x03ca, 1102), + (0x03cb, 2028), + (0x03cc, 2048), + (0x03cd, 2028), + (0x03ce, 2696), + (0x03cf, 2958), + (0x03d0, 2084), + (0x03d1, 2028), + (0x03d2, 2958), + (0x03d3, 3646), + (0x03d4, 2958), + (0x03d5, 2156), + (0x03d6, 2696), + (0x0400, 2502), + (0x0401, 2502), + (0x0402, 3080), + (0x0403, 2368), + (0x0404, 2704), + (0x0405, 2278), + (0x0406, 1364), + (0x0407, 1364), + (0x0408, 1594), + (0x0409, 3572), + (0x040a, 3572), + (0x040b, 3036), + (0x040c, 2732), + (0x040d, 2958), + (0x040e, 2900), + (0x040f, 2958), + (0x0410, 2958), + (0x0411, 2352), + (0x0412, 2732), + (0x0413, 2368), + (0x0414, 2794), + (0x0415, 2502), + (0x0416, 3670), + (0x0417, 2052), + (0x0418, 2958), + (0x0419, 2958), + (0x041a, 2732), + (0x041b, 2778), + (0x041c, 3642), + (0x041d, 2958), + (0x041e, 2958), + (0x041f, 2958), + (0x0420, 2278), + (0x0421, 2732), + (0x0422, 2502), + (0x0423, 2900), + (0x0424, 3236), + (0x0425, 2958), + (0x0426, 2958), + (0x0427, 2662), + (0x0428, 4132), + (0x0429, 4132), + (0x042a, 2892), + (0x042b, 3572), + (0x042c, 2352), + (0x042d, 2704), + (0x042e, 4210), + (0x042f, 2732), + (0x0430, 1818), + (0x0431, 2084), + (0x0432, 1934), + (0x0433, 1680), + (0x0434, 2084), + (0x0435, 1818), + (0x0436, 2830), + (0x0437, 1618), + (0x0438, 2192), + (0x0439, 2192), + (0x043a, 1990), + (0x043b, 2044), + (0x043c, 2592), + (0x043d, 2192), + (0x043e, 2048), + (0x043f, 2192), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 1790), + (0x0443, 2048), + (0x0444, 2654), + (0x0445, 2048), + (0x0446, 2192), + (0x0447, 2060), + (0x0448, 3154), + (0x0449, 3154), + (0x044a, 2118), + (0x044b, 2752), + (0x044c, 1868), + (0x044d, 1758), + (0x044e, 3060), + (0x044f, 1884), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 1978), + (0x0453, 1680), + (0x0454, 1758), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2978), + (0x045a, 2962), + (0x045b, 2048), + (0x045c, 1990), + (0x045d, 2192), + (0x045e, 2048), + (0x045f, 2192), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -313,6 +1187,7 @@ pub static SERIF_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2310), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -354,13 +1229,187 @@ pub static SERIF_BOLD: [u16; 560] = [ 3186, 2048, 2958, 2048, 1138, 2278, 1478, 1364, 3370, 3370, 2958, 2958, 2048, 2732, 2732, 1594, 1818, 2180, 1908, 2732, 2958, 2936, 2732, 1818, 2048, 1364, 3378, 2278, 2958, 1818, 2958, 2048, ]; -pub static SERIF_BOLD_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_BOLD_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3272), + (0x0389, 3724), + (0x038a, 2138), + (0x038c, 3186), + (0x038e, 3542), + (0x038f, 3280), + (0x0390, 1270), + (0x0391, 2958), + (0x0392, 2732), + (0x0393, 2606), + (0x0394, 2568), + (0x0395, 2732), + (0x0396, 2732), + (0x0397, 3186), + (0x0398, 3186), + (0x0399, 1594), + (0x039a, 3186), + (0x039b, 2936), + (0x039c, 3866), + (0x039d, 2958), + (0x039e, 2774), + (0x039f, 3186), + (0x03a0, 3186), + (0x03a1, 2502), + (0x03a3, 2678), + (0x03a4, 2732), + (0x03a5, 2958), + (0x03a6, 3396), + (0x03a7, 2958), + (0x03a8, 3194), (0x03a9, 3280), + (0x03aa, 1594), + (0x03ab, 2958), + (0x03ac, 2286), + (0x03ad, 1750), + (0x03ae, 2326), + (0x03af, 1270), + (0x03b0, 2126), + (0x03b1, 2286), + (0x03b2, 2158), + (0x03b3, 1916), + (0x03b4, 2126), + (0x03b5, 1750), + (0x03b6, 1692), + (0x03b7, 2326), + (0x03b8, 2138), + (0x03b9, 1270), + (0x03ba, 2274), + (0x03bb, 2012), (0x03bc, 2322), + (0x03bd, 1830), + (0x03be, 1826), + (0x03bf, 2048), + (0x03c0, 2244), + (0x03c1, 2192), + (0x03c2, 1728), + (0x03c3, 2228), + (0x03c4, 1888), + (0x03c5, 2126), + (0x03c6, 2556), + (0x03c7, 1892), + (0x03c8, 2818), + (0x03c9, 2994), + (0x03ca, 1270), + (0x03cb, 2126), + (0x03cc, 2048), + (0x03cd, 2126), + (0x03ce, 2994), + (0x03cf, 3186), + (0x03d0, 2158), + (0x03d1, 2454), + (0x03d2, 2958), + (0x03d3, 3782), + (0x03d4, 2958), + (0x03d5, 2556), + (0x03d6, 2994), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3276), + (0x0403, 2606), + (0x0404, 2778), + (0x0405, 2278), + (0x0406, 1594), + (0x0407, 1594), + (0x0408, 2048), + (0x0409, 4108), + (0x040a, 4104), + (0x040b, 3276), + (0x040c, 2970), + (0x040d, 3186), + (0x040e, 3006), + (0x040f, 3186), + (0x0410, 2958), + (0x0411, 2708), + (0x0412, 2732), + (0x0413, 2606), + (0x0414, 2818), + (0x0415, 2732), + (0x0416, 4050), + (0x0417, 2162), + (0x0418, 3186), + (0x0419, 3186), + (0x041a, 2970), + (0x041b, 3052), + (0x041c, 3866), + (0x041d, 3186), + (0x041e, 3186), + (0x041f, 3186), + (0x0420, 2502), + (0x0421, 2958), + (0x0422, 2732), + (0x0423, 3006), + (0x0424, 3518), + (0x0425, 2958), + (0x0426, 3186), + (0x0427, 3006), + (0x0428, 4498), + (0x0429, 4498), + (0x042a, 3134), + (0x042b, 4022), + (0x042c, 2708), + (0x042d, 2778), + (0x042e, 4612), + (0x042f, 2958), + (0x0430, 2048), + (0x0431, 2048), + (0x0432, 2212), + (0x0433, 1860), + (0x0434, 2072), + (0x0435, 1818), + (0x0436, 2970), + (0x0437, 1646), + (0x0438, 2360), + (0x0439, 2360), + (0x043a, 2360), + (0x043b, 2298), + (0x043c, 2790), + (0x043d, 2360), + (0x043e, 2048), + (0x043f, 2360), + (0x0440, 2278), + (0x0441, 1818), + (0x0442, 2012), + (0x0443, 2048), + (0x0444, 2834), + (0x0445, 2048), + (0x0446, 2360), + (0x0447, 2310), + (0x0448, 3458), + (0x0449, 3458), + (0x044a, 2388), + (0x044b, 3198), + (0x044c, 2166), + (0x044d, 1770), + (0x044e, 3130), + (0x044f, 2216), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 2200), + (0x0453, 1860), + (0x0454, 1770), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1364), + (0x0459, 3194), + (0x045a, 3264), + (0x045b, 2278), + (0x045c, 2360), + (0x045d, 2360), + (0x045e, 2048), + (0x045f, 2360), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -374,6 +1423,7 @@ pub static SERIF_BOLD_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2334), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -415,13 +1465,187 @@ pub static SERIF_ITALIC: [u16; 560] = [ 2958, 2048, 2278, 1818, 1138, 2048, 1138, 1138, 3082, 3088, 2502, 2732, 2048, 2278, 2278, 1594, 1594, 1994, 1786, 2502, 2958, 2474, 2502, 1818, 1818, 1138, 2900, 2048, 2502, 1594, 2278, 1818, ]; -pub static SERIF_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2502), + (0x0387, 1364), + (0x0388, 2782), + (0x0389, 3244), + (0x038a, 1646), + (0x038c, 2958), + (0x038e, 2704), + (0x038f, 2974), + (0x0390, 1138), + (0x0391, 2502), + (0x0392, 2502), + (0x0393, 2330), + (0x0394, 2404), + (0x0395, 2502), + (0x0396, 2278), + (0x0397, 2958), + (0x0398, 2958), + (0x0399, 1364), + (0x039a, 2732), + (0x039b, 2474), + (0x039c, 3412), + (0x039d, 2732), + (0x039e, 2618), + (0x039f, 2958), + (0x03a0, 2958), + (0x03a1, 2502), + (0x03a3, 2434), + (0x03a4, 2278), + (0x03a5, 2278), + (0x03a6, 3118), + (0x03a7, 2502), + (0x03a8, 2880), (0x03a9, 2962), + (0x03aa, 1364), + (0x03ab, 2278), + (0x03ac, 2150), + (0x03ad, 1614), + (0x03ae, 2032), + (0x03af, 1138), + (0x03b0, 1880), + (0x03b1, 2150), + (0x03b2, 2040), + (0x03b3, 1614), + (0x03b4, 1904), + (0x03b5, 1614), + (0x03b6, 1658), + (0x03b7, 2032), + (0x03b8, 2012), + (0x03b9, 1138), + (0x03ba, 1966), + (0x03bb, 1774), (0x03bc, 2056), + (0x03bd, 1818), + (0x03be, 1754), + (0x03bf, 2048), + (0x03c0, 2052), + (0x03c1, 1966), + (0x03c2, 1662), + (0x03c3, 2020), + (0x03c4, 1466), + (0x03c5, 1880), + (0x03c6, 2266), + (0x03c7, 1802), + (0x03c8, 2544), + (0x03c9, 2892), + (0x03ca, 1138), + (0x03cb, 1880), + (0x03cc, 2048), + (0x03cd, 1880), + (0x03ce, 2858), + (0x03cf, 2732), + (0x03d0, 2040), + (0x03d1, 2010), + (0x03d2, 2606), + (0x03d3, 3108), + (0x03d4, 2606), + (0x03d5, 2120), + (0x03d6, 2892), + (0x0400, 2502), + (0x0401, 2502), + (0x0402, 3264), + (0x0403, 2330), + (0x0404, 2740), + (0x0405, 2048), + (0x0406, 1364), + (0x0407, 1364), + (0x0408, 1818), + (0x0409, 3798), + (0x040a, 3772), + (0x040b, 3272), + (0x040c, 2720), + (0x040d, 2958), + (0x040e, 2756), + (0x040f, 2958), + (0x0410, 2502), + (0x0411, 2416), + (0x0412, 2502), + (0x0413, 2330), + (0x0414, 2606), + (0x0415, 2502), + (0x0416, 3752), + (0x0417, 2028), + (0x0418, 2958), + (0x0419, 2958), + (0x041a, 2720), + (0x041b, 2774), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 2958), + (0x041f, 2958), + (0x0420, 2502), + (0x0421, 2732), + (0x0422, 2278), + (0x0423, 2756), + (0x0424, 3294), + (0x0425, 2502), + (0x0426, 2958), + (0x0427, 2822), + (0x0428, 4272), + (0x0429, 4272), + (0x042a, 2768), + (0x042b, 3572), + (0x042c, 2416), + (0x042d, 2682), + (0x042e, 4228), + (0x042f, 2692), + (0x0430, 2048), + (0x0431, 2060), + (0x0432, 1778), + (0x0433, 1598), + (0x0434, 2072), + (0x0435, 1818), + (0x0436, 3810), + (0x0437, 1598), + (0x0438, 2048), + (0x0439, 2048), + (0x043a, 1916), + (0x043b, 1786), + (0x043c, 2614), + (0x043d, 2032), + (0x043e, 2048), + (0x043f, 2048), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 2958), + (0x0443, 1818), + (0x0444, 2908), + (0x0445, 1818), + (0x0446, 2048), + (0x0447, 1954), + (0x0448, 3092), + (0x0449, 3092), + (0x044a, 2126), + (0x044b, 2774), + (0x044c, 1864), + (0x044d, 1806), + (0x044e, 2888), + (0x044f, 1904), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 1962), + (0x0453, 1544), + (0x0454, 1786), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2782), + (0x045a, 2916), + (0x045b, 2048), + (0x045c, 1916), + (0x045d, 2048), + (0x045e, 1818), + (0x045f, 2048), (0x2013, 2048), (0x2014, 3642), (0x2018, 1364), @@ -435,6 +1659,7 @@ pub static SERIF_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2764), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -476,13 +1701,187 @@ pub static SERIF_BOLD_ITALIC: [u16; 560] = [ 2958, 2048, 2502, 1818, 1138, 2278, 1138, 1138, 2998, 3010, 2732, 2732, 2048, 2502, 2502, 1594, 1594, 2084, 1908, 2732, 2958, 2732, 2732, 1818, 2048, 1138, 3214, 2048, 2732, 1594, 2502, 1818, ]; -pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2732), + (0x0387, 1364), + (0x0388, 3182), + (0x0389, 3666), + (0x038a, 2072), + (0x038c, 2994), + (0x038e, 3072), + (0x038f, 3216), + (0x0390, 1138), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2474), + (0x0394, 2568), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 3186), + (0x0398, 3126), + (0x0399, 1594), + (0x039a, 2732), + (0x039b, 2732), + (0x039c, 3642), + (0x039d, 2958), + (0x039e, 2700), + (0x039f, 2958), + (0x03a0, 3186), + (0x03a1, 2502), + (0x03a3, 2536), + (0x03a4, 2502), + (0x03a5, 2502), + (0x03a6, 3232), + (0x03a7, 2732), + (0x03a8, 3276), (0x03a9, 3056), + (0x03aa, 1594), + (0x03ab, 2502), + (0x03ac, 2266), + (0x03ad, 1680), + (0x03ae, 2248), + (0x03af, 1138), + (0x03b0, 2006), + (0x03b1, 2266), + (0x03b2, 2098), + (0x03b3, 1704), + (0x03b4, 2098), + (0x03b5, 1680), + (0x03b6, 1806), + (0x03b7, 2248), + (0x03b8, 2126), + (0x03b9, 1138), + (0x03ba, 2170), + (0x03bb, 1818), (0x03bc, 2278), + (0x03bd, 1818), + (0x03be, 1840), + (0x03bf, 2048), + (0x03c0, 2236), + (0x03c1, 2110), + (0x03c2, 1826), + (0x03c3, 2208), + (0x03c4, 1818), + (0x03c5, 2006), + (0x03c6, 2396), + (0x03c7, 1904), + (0x03c8, 2736), + (0x03c9, 2962), + (0x03ca, 1138), + (0x03cb, 2006), + (0x03cc, 2048), + (0x03cd, 2006), + (0x03ce, 2962), + (0x03cf, 2732), + (0x03d0, 2098), + (0x03d1, 2160), + (0x03d2, 2968), + (0x03d3, 3444), + (0x03d4, 2968), + (0x03d5, 2370), + (0x03d6, 3098), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3350), + (0x0403, 2474), + (0x0404, 2692), + (0x0405, 2278), + (0x0406, 1594), + (0x0407, 1594), + (0x0408, 2048), + (0x0409, 3720), + (0x040a, 3998), + (0x040b, 3322), + (0x040c, 2778), + (0x040d, 3186), + (0x040e, 2850), + (0x040f, 3186), + (0x0410, 2732), + (0x0411, 2602), + (0x0412, 2732), + (0x0413, 2474), + (0x0414, 2798), + (0x0415, 2732), + (0x0416, 3760), + (0x0417, 2084), + (0x0418, 3186), + (0x0419, 3186), + (0x041a, 2778), + (0x041b, 2810), + (0x041c, 3642), + (0x041d, 3186), + (0x041e, 2958), + (0x041f, 3186), + (0x0420, 2502), + (0x0421, 2732), + (0x0422, 2502), + (0x0423, 2850), + (0x0424, 3482), + (0x0425, 2732), + (0x0426, 3186), + (0x0427, 3064), + (0x0428, 4612), + (0x0429, 4612), + (0x042a, 3114), + (0x042b, 3970), + (0x042c, 2596), + (0x042d, 2666), + (0x042e, 4326), + (0x042f, 2822), + (0x0430, 2048), + (0x0431, 2044), + (0x0432, 1830), + (0x0433, 1512), + (0x0434, 2032), + (0x0435, 1818), + (0x0436, 3900), + (0x0437, 1598), + (0x0438, 2278), + (0x0439, 2278), + (0x043a, 2076), + (0x043b, 2102), + (0x043c, 2806), + (0x043d, 2274), + (0x043e, 2048), + (0x043f, 2278), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 3186), + (0x0443, 1818), + (0x0444, 2932), + (0x0445, 2048), + (0x0446, 2278), + (0x0447, 2150), + (0x0448, 3412), + (0x0449, 3412), + (0x044a, 2158), + (0x044b, 2842), + (0x044c, 1872), + (0x044d, 1700), + (0x044e, 2990), + (0x044f, 2110), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 2142), + (0x0453, 1610), + (0x0454, 1700), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2700), + (0x045a, 2920), + (0x045b, 2278), + (0x045c, 2076), + (0x045d, 2278), + (0x045e, 1818), + (0x045f, 2278), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -496,6 +1895,7 @@ pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2482), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -537,13 +1937,186 @@ pub static MONO: [u16; 560] = [ 2458, 2458, 2458, 2458, 2458, 2458, 2458, 0, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, ]; -pub static MONO_SPARSE: [(u32, u16); 22] = [ +pub static MONO_SPARSE: [(u32, u16); 196] = [ (0x00b0, 2458), (0x00b1, 2458), (0x00b2, 2458), (0x00b3, 2458), + (0x0384, 2458), + (0x0385, 2458), + (0x0386, 2458), + (0x0387, 2458), + (0x0388, 2458), + (0x0389, 2458), + (0x038a, 2458), + (0x038c, 2458), + (0x038e, 2458), + (0x038f, 2458), + (0x0390, 2458), + (0x0391, 2458), + (0x0392, 2458), + (0x0393, 2458), + (0x0394, 2458), + (0x0395, 2458), + (0x0396, 2458), + (0x0397, 2458), + (0x0398, 2458), + (0x0399, 2458), + (0x039a, 2458), + (0x039b, 2458), + (0x039c, 2458), + (0x039d, 2458), + (0x039e, 2458), + (0x039f, 2458), + (0x03a0, 2458), + (0x03a1, 2458), + (0x03a3, 2458), + (0x03a4, 2458), + (0x03a5, 2458), + (0x03a6, 2458), + (0x03a7, 2458), + (0x03a8, 2458), (0x03a9, 2458), + (0x03aa, 2458), + (0x03ab, 2458), + (0x03ac, 2458), + (0x03ad, 2458), + (0x03ae, 2458), + (0x03af, 2458), + (0x03b0, 2458), + (0x03b1, 2458), + (0x03b2, 2458), + (0x03b3, 2458), + (0x03b4, 2458), + (0x03b5, 2458), + (0x03b6, 2458), + (0x03b7, 2458), + (0x03b8, 2458), + (0x03b9, 2458), + (0x03ba, 2458), + (0x03bb, 2458), (0x03bc, 2458), + (0x03bd, 2458), + (0x03be, 2458), + (0x03bf, 2458), + (0x03c0, 2458), + (0x03c1, 2458), + (0x03c2, 2458), + (0x03c3, 2458), + (0x03c4, 2458), + (0x03c5, 2458), + (0x03c6, 2458), + (0x03c7, 2458), + (0x03c8, 2458), + (0x03c9, 2458), + (0x03ca, 2458), + (0x03cb, 2458), + (0x03cc, 2458), + (0x03cd, 2458), + (0x03ce, 2458), + (0x03d0, 2458), + (0x03d1, 2458), + (0x03d2, 2458), + (0x03d3, 2458), + (0x03d4, 2458), + (0x03d5, 2458), + (0x03d6, 2458), + (0x0400, 2458), + (0x0401, 2458), + (0x0402, 2458), + (0x0403, 2458), + (0x0404, 2458), + (0x0405, 2458), + (0x0406, 2458), + (0x0407, 2458), + (0x0408, 2458), + (0x0409, 2458), + (0x040a, 2458), + (0x040b, 2458), + (0x040c, 2458), + (0x040d, 2458), + (0x040e, 2458), + (0x040f, 2458), + (0x0410, 2458), + (0x0411, 2458), + (0x0412, 2458), + (0x0413, 2458), + (0x0414, 2458), + (0x0415, 2458), + (0x0416, 2458), + (0x0417, 2458), + (0x0418, 2458), + (0x0419, 2458), + (0x041a, 2458), + (0x041b, 2458), + (0x041c, 2458), + (0x041d, 2458), + (0x041e, 2458), + (0x041f, 2458), + (0x0420, 2458), + (0x0421, 2458), + (0x0422, 2458), + (0x0423, 2458), + (0x0424, 2458), + (0x0425, 2458), + (0x0426, 2458), + (0x0427, 2458), + (0x0428, 2458), + (0x0429, 2458), + (0x042a, 2458), + (0x042b, 2458), + (0x042c, 2458), + (0x042d, 2458), + (0x042e, 2458), + (0x042f, 2458), + (0x0430, 2458), + (0x0431, 2458), + (0x0432, 2458), + (0x0433, 2458), + (0x0434, 2458), + (0x0435, 2458), + (0x0436, 2458), + (0x0437, 2458), + (0x0438, 2458), + (0x0439, 2458), + (0x043a, 2458), + (0x043b, 2458), + (0x043c, 2458), + (0x043d, 2458), + (0x043e, 2458), + (0x043f, 2458), + (0x0440, 2458), + (0x0441, 2458), + (0x0442, 2458), + (0x0443, 2458), + (0x0444, 2458), + (0x0445, 2458), + (0x0446, 2458), + (0x0447, 2458), + (0x0448, 2458), + (0x0449, 2458), + (0x044a, 2458), + (0x044b, 2458), + (0x044c, 2458), + (0x044d, 2458), + (0x044e, 2458), + (0x044f, 2458), + (0x0450, 2458), + (0x0451, 2458), + (0x0452, 2458), + (0x0453, 2458), + (0x0454, 2458), + (0x0455, 2458), + (0x0456, 2458), + (0x0457, 2458), + (0x0458, 2458), + (0x0459, 2458), + (0x045a, 2458), + (0x045b, 2458), + (0x045c, 2458), + (0x045d, 2458), + (0x045e, 2458), + (0x045f, 2458), (0x2013, 2458), (0x2014, 2458), (0x2018, 2458), @@ -557,6 +2130,7 @@ pub static MONO_SPARSE: [(u32, u16); 22] = [ (0x2192, 2458), (0x2193, 2458), (0x2206, 2458), + (0x2212, 2458), (0x2260, 2458), (0x2264, 2458), (0x2265, 2458), diff --git a/crates/exav-render/src/formats/dwg/mod.rs b/crates/exav-render/src/formats/dwg/mod.rs index 5916d10..1a76103 100644 --- a/crates/exav-render/src/formats/dwg/mod.rs +++ b/crates/exav-render/src/formats/dwg/mod.rs @@ -18,6 +18,7 @@ mod clip; mod curves; +mod dimension; mod fill; mod font; mod gradient; @@ -31,6 +32,7 @@ pub mod stroke_font; mod tessellate; mod text; mod tiles; +mod wide; pub use curves::{flatten_arc, flatten_circle, flatten_ellipse}; pub use fill::fill_even_odd; @@ -39,8 +41,10 @@ pub use font::{ }; pub use hatch::nesting_depths; pub use palette::Palette; -pub use scene::{Affine, Scene, FILL_BYTES, STROKE_BYTES}; -pub use tessellate::{expand_bulges, LayerInfo, LayoutInfo, Tessellator, Warnings, SCENE_BUDGET}; +pub use scene::{Affine, Scene, FILL_BYTES, FILL_EDGE, STROKE_BYTES}; +pub use tessellate::{ + expand_bulges, LayerInfo, LayoutInfo, Sheet, Tessellator, Warnings, SCENE_BUDGET, +}; pub use text::{mtext_lines, HAlign, TextRun, VAlign, TEXT_RECORD_BYTES}; /// Why a file did not parse. @@ -198,24 +202,31 @@ impl Document { String::new() } }; + // Everything read from the scene before `buffers`, which empties it. let (texts, text_strings) = t.scene.text_buffers(); + let (origin, extents) = (t.scene.origin(), t.scene.extents_local()); + let clips = t.scene.clip_buffer(); + let b = t.scene.buffers(); Drawing { layout: drawn, - strokes: t.scene.stroke_buffer(), - fills: t.scene.fill_buffer(), + strokes: b.strokes, + fills: b.fills, texts, text_strings, - origin: t.scene.origin(), - extents: t.scene.extents_local(), + origin, + extents, max_order: t.scene.max_order(), - opaque_strokes: t.scene.opaque_strokes(), - opaque_fills: t.scene.opaque_fills(), - stroke_tiles: t.scene.stroke_tiles(), - fill_tiles: t.scene.fill_tiles(), - opaque_stroke_tiles: t.scene.opaque_stroke_tiles(), - opaque_fill_tiles: t.scene.opaque_fill_tiles(), + opaque_strokes: b.opaque_strokes, + opaque_fills: b.opaque_fills, + clips, + stroke_tiles: b.stroke_tiles, + fill_tiles: b.fill_tiles, + opaque_stroke_tiles: b.opaque_stroke_tiles, + opaque_fill_tiles: b.opaque_fill_tiles, + translucent_runs: b.translucent_runs, layers: t.layers, warnings: t.warnings, + sheet: t.sheet, } } } @@ -236,8 +247,9 @@ pub struct Drawing { /// for does not exist. pub layout: String, /// Stroke instances, [`STROKE_BYTES`] each: `f32 x0, y0, x1, y1 | u32 rgba - /// | u32 attr | u32 order`, little-endian. `attr` packs the layer id, the - /// lineweight and the flags (see `scene.rs`). + /// | u32 attr | u32 order | f32 bulge`, little-endian. `attr` packs the + /// layer id, the lineweight and the flags (see `scene.rs`); `bulge` is 0 + /// for a line, else the stroke is a circular arc (`Stroke::bulge`). pub strokes: Vec, /// Fill vertices, [`FILL_BYTES`] each: `f32 x, y | u32 rgba | u32 attr | /// u32 order`. @@ -245,12 +257,16 @@ pub struct Drawing { /// Text placement records, [`TEXT_RECORD_BYTES`] each, indexing into /// `text_strings`: `f32 x, y, height, rotation, widthFactor, oblique | u32 /// rgba, attr, strOffset | u16 strLen | u8 hAlign, vAlign | u32 order | u8 - /// face | 3 bytes padding`. Only runs in an outline face appear here; a + /// face | u8 padding | u16 clip`. Only runs in an outline face appear here; a /// stroke-font run is drawn as strokes, so that the drawing's lineweight /// decides how thick its letters are. pub texts: Vec, /// UTF-8, every run's characters back to back. pub text_strings: Vec, + /// The regions text runs are clipped to (a viewport's window, an XCLIP + /// boundary), which a run's `clip` numbers from 1: for each, `u32` + /// triangle count, then `f32 x, y` per corner relative to `origin`. + pub clips: Vec, /// World coordinates the buffers are relative to. pub origin: [f64; 2], /// `[min_x, min_y, max_x, max_y]`, relative to `origin`. @@ -258,22 +274,29 @@ pub struct Drawing { /// Highest draw-order index, for normalising depth. pub max_order: u32, /// Leading strokes and fill vertices that are opaque; the rest are - /// translucent and are drawn after, without writing depth. + /// translucent, in draw order, and are drawn after. pub opaque_strokes: usize, pub opaque_fills: usize, - /// Spatial buckets: `u32 start, count | f32 min_x, min_y, max_x, max_y`, - /// 24 bytes each, `start` and `count` in strokes (in vertices for fills). - /// The box is the true extent of the contents, which can reach outside - /// the bucket's grid square. + /// Tiles: `u32 start, count | f32 min_x, min_y, max_x, max_y`, 24 bytes + /// each, `start` and `count` in strokes (in vertices for fills). The + /// opaque half's are spatial buckets, the translucent half's runs of it + /// in draw order. The box is the true extent of the contents, which can + /// reach outside the bucket's grid square. pub stroke_tiles: Vec, pub fill_tiles: Vec, /// Leading entries of each table covering the opaque half. pub opaque_stroke_tiles: usize, pub opaque_fill_tiles: usize, + /// The order of the translucent pass: for each run, `u32` 1 for fill + /// tiles or 0 for stroke tiles, then the first tile and the one past the + /// last, indices into that kind's table. + pub translucent_runs: Vec, /// The layer table; a vertex's layer id indexes it. pub layers: Vec, /// What could not be drawn. pub warnings: Warnings, + /// What the tab is drawn on. + pub sheet: Sheet, } impl Drawing { @@ -310,11 +333,38 @@ impl Drawing { format!("[{}]", items.join(",")) } + /// [`Sheet`] as JSON: `{"mmPerUnit", "lineweights"}`. + pub fn sheet_json(&self) -> String { + let s = &self.sheet; + // The paper relative to `origin`, as the buffers are. + let paper = match s.paper { + Some(p) => format!( + "[{},{},{},{}]", + json_number(p[0] - self.origin[0]), + json_number(p[1] - self.origin[1]), + json_number(p[2] - self.origin[0]), + json_number(p[3] - self.origin[1]) + ), + None => "null".to_string(), + }; + format!( + r#"{{"mmPerUnit":{},"lineweights":{},"paper":{paper}}}"#, + json_number(s.mm_per_unit), + s.lineweights + ) + } + /// [`Warnings`] as JSON. All zeroes means everything was drawn. pub fn warnings_json(&self) -> String { let w = &self.warnings; + let not_drawn: Vec = w + .not_drawn + .iter() + .map(|(k, n)| format!("{}:{n}", json_string(k))) + .collect(); + let files: Vec = w.external_files.iter().map(|f| json_string(f)).collect(); format!( - r#"{{"unknownEntities":{},"missingBlocks":{},"textRuns":{},"strokeGlyphs":{},"hatchPatternsMissing":{},"hatchPatternsTruncated":{},"externalReferences":{},"depthExceeded":{},"sceneTruncated":{},"proxyWithoutGraphics":{}}}"#, + r#"{{"unknownEntities":{},"missingBlocks":{},"textRuns":{},"strokeGlyphs":{},"hatchPatternsMissing":{},"hatchPatternsTruncated":{},"externalReferences":{},"depthExceeded":{},"sceneTruncated":{},"proxyWithoutGraphics":{},"notDrawn":{{{}}},"externalFiles":[{}]}}"#, w.unknown_entities, w.missing_blocks, w.text_runs, @@ -324,11 +374,22 @@ impl Drawing { w.external_references, w.depth_exceeded, w.scene_truncated, - w.proxy_without_graphics + w.proxy_without_graphics, + not_drawn.join(","), + files.join(",") ) } } +/// A number JSON can hold: a non-finite one is 0. +fn json_number(v: f64) -> String { + if v.is_finite() { + format!("{v}") + } else { + "0".to_string() + } +} + /// JSON string escaping. Layer and layout names are file data and routinely /// hold quotes, backslashes and control characters. fn json_string(s: &str) -> String { diff --git a/crates/exav-render/src/formats/dwg/scene.rs b/crates/exav-render/src/formats/dwg/scene.rs index ce74c9d..13a41f9 100644 --- a/crates/exav-render/src/formats/dwg/scene.rs +++ b/crates/exav-render/src/formats/dwg/scene.rs @@ -4,6 +4,8 @@ //! precision, then re-emitted as f32 relative to a per-drawing origin. See //! DESIGN.md section 4.4. +use super::tiles::Tile; + /// 2D affine transform, applied as the block/insert stack is walked. /// /// x' = a*x + c*y + e @@ -127,7 +129,8 @@ impl Affine { } } -/// One line segment, pre-transform-applied, still in drawing coordinates. +/// One line segment, or circular arc, pre-transform-applied, still in drawing +/// coordinates. #[derive(Clone, Copy)] pub struct Stroke { pub x0: f64, @@ -139,6 +142,32 @@ pub struct Stroke { pub attr: u32, /// Position in the drawing's draw order. See `Scene::order`. pub order: u32, + /// 0 for a line; for an arc from the first point to the second, the tan + /// of a quarter of its sweep, positive counter-clockwise (DXF's bulge), + /// at most [`MAX_ARC_BULGE`]: the renderer draws it exactly at any zoom. + pub bulge: f32, +} + +/// The largest bulge a stroke carries, a quarter turn's: an arc stroke then +/// lies within the box of its ends widened by a fifth of its chord, and +/// within its sector the shader's test is two cross products. +pub const MAX_ARC_BULGE: f64 = 0.414_213_562_373_095_1; + +impl Stroke { + /// How far an arc stroke's curve stands off its chord, at most. + pub fn sagitta(&self) -> f64 { + (self.bulge.abs() as f64) * (self.x1 - self.x0).hypot(self.y1 - self.y0) / 2.0 + } + + /// The stroke as straight segments: itself, or its arc in `n` pieces. + pub fn flatten(&self, n: usize) -> Vec<[[f64; 2]; 2]> { + let (a, b) = ([self.x0, self.y0], [self.x1, self.y1]); + if self.bulge == 0.0 { + return vec![[a, b]]; + } + let pts = super::curves::bulge_points(a, b, self.bulge as f64, n); + pts.windows(2).map(|w| [w[0], w[1]]).collect() + } } /// One triangle corner. @@ -151,7 +180,7 @@ pub struct FillVertex { pub order: u32, } -pub const STROKE_BYTES: usize = 28; +pub const STROKE_BYTES: usize = 32; pub const FILL_BYTES: usize = 20; /// Fill flag: paint the viewer's background colour instead of the vertex's. @@ -160,6 +189,10 @@ pub const FILL_BYTES: usize = 20; /// background, which the tessellator does not know. The renderer substitutes it. pub const FILL_FLAG_BACKGROUND: u8 = 1; +/// The lineweight code of a fill's edge, a stroke of no width whose fringe is +/// the fill's anti-aliasing (`shaders.ts`). Lineweights stop at 211. +pub const FILL_EDGE: u8 = 255; + /// Flag: this colour is AutoCAD's colour 7, so it flips with the background. /// /// Colour 7 means "white or black, whichever contrasts". A true colour that @@ -178,15 +211,22 @@ pub const FLAG_CONTRAST: u8 = 0x80; /// should average out to. 0 means "do not fade". #[inline] pub fn encode_fade_spacing(spacing: f64) -> u8 { - if !spacing.is_finite() || spacing <= 1.0 { + if !spacing.is_finite() || spacing <= 0.0 { return 0; } - // Logarithmic, eight steps to the octave, which covers millimetres through - // to tens of metres in the seven bits left beside the contrast flag. - let code = (spacing.log2() * 8.0).round(); + // Logarithmic, five steps to the octave from 2^-9: in the seven bits left + // beside the contrast flag, 0.002 to 86,000 units, a pattern a few + // millimetres apart in a drawing in metres to one metres apart in a + // drawing in millimetres. `spacingFade` in shaders.ts decodes it. + let code = ((spacing.log2() + FADE_OCTAVES_BELOW_ONE) * FADE_STEPS_PER_OCTAVE).round(); code.clamp(1.0, 127.0) as u8 } +/// Octaves below a unit `encode_fade_spacing` reaches, and its steps per +/// octave. +const FADE_OCTAVES_BELOW_ONE: f64 = 9.0; +const FADE_STEPS_PER_OCTAVE: f64 = 5.0; + #[inline] pub fn pack_attr(layer: u16, lineweight: u8, flags: u8) -> u32 { (layer as u32) | ((lineweight as u32) << 16) | ((flags as u32) << 24) @@ -217,11 +257,15 @@ pub struct Scene { pub strokes: Vec, pub fills: Vec, pub texts: Vec, + /// Clip regions as triangles, a text run's `clip` indexing them from 1. + pub clips: Vec>, /// Draw order counter. AutoCAD paints entities in this sequence, and later /// entities cover earlier ones: a small solid fill placed over a hatch is /// how a roof vent masks the tiles beneath it. Without it a viewer that /// batches all fills before all strokes draws the hatch back on top. pub order: u32, + /// Fill vertices `edge_fills` has outlined. + edged: usize, min_x: f64, min_y: f64, max_x: f64, @@ -234,7 +278,9 @@ impl Scene { strokes: Vec::new(), fills: Vec::new(), texts: Vec::new(), + clips: Vec::new(), order: 0, + edged: 0, min_x: f64::INFINITY, min_y: f64::INFINITY, max_x: f64::NEG_INFINITY, @@ -245,7 +291,10 @@ impl Scene { /// Record a text run. Only the anchor grows the extents: the rendered width /// depends on font metrics the renderer has and this side does not. pub fn push_text(&mut self, run: super::text::TextRun) { - if !(run.x.is_finite() && run.y.is_finite() && run.height.is_finite()) { + if ![run.x, run.y, run.u[0], run.u[1], run.v[0], run.v[1]] + .iter() + .all(|c| c.is_finite()) + { return; } if run.text.is_empty() { @@ -286,9 +335,65 @@ impl Scene { rgba, attr, order, + bulge: 0.0, }); } + /// Push a circular arc as arc strokes of at most a quarter turn each: + /// centre, radius, start angle and signed sweep (positive + /// counter-clockwise), in drawing coordinates. + #[allow(clippy::too_many_arguments)] + pub fn push_arc( + &mut self, + cx: f64, + cy: f64, + r: f64, + start: f64, + sweep: f64, + rgba: u32, + attr: u32, + ) { + if ![cx, cy, r, start, sweep].iter().all(|v| v.is_finite()) || r <= 0.0 || sweep == 0.0 { + return; + } + let n = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize; + let step = sweep / n as f64; + let bulge = (step / 4.0).tan() as f32; + debug_assert!(f64::from(bulge.abs()) <= MAX_ARC_BULGE + 1e-6); + let at = |a: f64| [cx + r * a.cos(), cy + r * a.sin()]; + // The extents: the arc's ends and the quadrant points it passes. + let (lo, hi) = if sweep > 0.0 { + (start, start + sweep) + } else { + (start + sweep, start) + }; + let first = (lo / std::f64::consts::FRAC_PI_2).ceil() as i64; + let last = (hi / std::f64::consts::FRAC_PI_2).floor() as i64; + for k in first..=last.min(first + 4) { + let p = at(k as f64 * std::f64::consts::FRAC_PI_2); + self.grow(p[0], p[1]); + } + let order = self.order; + for i in 0..n { + let (a, b) = ( + at(start + step * i as f64), + at(start + step * (i + 1) as f64), + ); + self.grow(a[0], a[1]); + self.grow(b[0], b[1]); + self.strokes.push(Stroke { + x0: a[0], + y0: a[1], + x1: b[0], + y1: b[1], + rgba, + attr, + order, + bulge, + }); + } + } + /// Push a flattened polyline as a run of segments. pub fn push_polyline(&mut self, pts: &[[f64; 2]], closed: bool, rgba: u32, attr: u32) { if pts.len() < 2 { @@ -355,6 +460,60 @@ impl Scene { } /// Where each buffer currently ends, for a later `clip_since`. + /// Outline the opaque fills of one colour pushed since the last call with + /// `FILL_EDGE` strokes, whose fringe anti-aliases the fill's edge: drawn + /// at the fill's depth, each is hidden where the fill is and shows only + /// outside it. An edge two triangles share is left out; a translucent, + /// shaded or background fill is not outlined. + pub fn edge_fills(&mut self) { + let from = self.edged.min(self.fills.len()); + self.edged = self.fills.len(); + let tris = self.fills[from..].as_chunks::<3>().0; + if tris.is_empty() { + return; + } + let key = |a: &FillVertex, b: &FillVertex| { + let (pa, pb) = ( + [a.x.to_bits(), a.y.to_bits()], + [b.x.to_bits(), b.y.to_bits()], + ); + let (lo, hi) = if pa <= pb { (pa, pb) } else { (pb, pa) }; + (a.rgba, a.attr, a.order, lo, hi) + }; + let mut edges = Vec::new(); + let mut uses: std::collections::HashMap<_, u32> = std::collections::HashMap::new(); + for t in tris { + let one_colour = t[1].rgba == t[0].rgba && t[2].rgba == t[0].rgba; + let opaque = t[0].rgba >> 24 == 255; + let background = (t[0].attr >> 24) as u8 & FILL_FLAG_BACKGROUND != 0; + if !one_colour || !opaque || background { + continue; + } + for (a, b) in [(&t[0], &t[1]), (&t[1], &t[2]), (&t[2], &t[0])] { + *uses.entry(key(a, b)).or_insert(0) += 1; + edges.push((*a, *b)); + } + } + for (a, b) in edges { + if uses[&key(&a, &b)] != 1 { + continue; + } + let attr = (a.attr & 0xFFFF) + | u32::from(FILL_EDGE) << 16 + | (a.attr & (u32::from(FLAG_CONTRAST) << 24)); + self.strokes.push(Stroke { + x0: a.x, + y0: a.y, + x1: b.x, + y1: b.y, + rgba: a.rgba, + attr, + order: a.order, + bulge: 0.0, + }); + } + } + pub fn mark(&self) -> Mark { Mark { strokes: self.strokes.len(), @@ -381,15 +540,30 @@ impl Scene { } continue; } - shape.clip_segment(a, b, &mut spans); - for (t0, t1) in spans.iter().copied() { - kept.push(Stroke { - x0: a[0] + (b[0] - a[0]) * t0, - y0: a[1] + (b[1] - a[1]) * t0, - x1: a[0] + (b[0] - a[0]) * t1, - y1: a[1] + (b[1] - a[1]) * t1, - ..s - }); + // An arc wholly inside stays one; one crossing the edge is cut as + // the straight pieces of its curve. + if s.bulge != 0.0 { + let [x0, y0, x1, y1] = stroke_box(&s); + if [[x0, y0], [x1, y0], [x1, y1], [x0, y1]] + .iter() + .all(|&c| shape.contains(c)) + { + kept.push(s); + continue; + } + } + for [a, b] in s.flatten(16) { + shape.clip_segment(a, b, &mut spans); + for (t0, t1) in spans.iter().copied() { + kept.push(Stroke { + x0: a[0] + (b[0] - a[0]) * t0, + y0: a[1] + (b[1] - a[1]) * t0, + x1: a[0] + (b[0] - a[0]) * t1, + y1: a[1] + (b[1] - a[1]) * t1, + bulge: 0.0, + ..s + }); + } } } self.strokes.extend(kept); @@ -411,12 +585,87 @@ impl Scene { } } self.fills.extend(kept_fills); - - // A run is kept or dropped whole: its rendered width lives on the other - // side of the worker boundary, so there is no box here to cut against. + // Outlined before they were cut, and the outlines cut with the + // strokes. + self.edged = self.fills.len(); + + // A run's glyphs are laid out on the other side of the worker + // boundary, so it cannot be cut here: one that may cross the shape's + // edge is drawn through a clip region, which the renderer masks it + // with. One wholly outside is dropped, one wholly inside is left be. let tail: Vec = self.texts.drain(mark.texts..).collect(); - self.texts - .extend(tail.into_iter().filter(|r| shape.contains([r.x, r.y]))); + let (min, max) = shape.bounds(); + let mut regions: std::collections::HashMap> = Default::default(); + for mut r in tail { + let reach = text_reach(&r); + if r.x + reach < min[0] + || r.x - reach > max[0] + || r.y + reach < min[1] + || r.y - reach > max[1] + { + continue; + } + let corners = [ + [r.x - reach, r.y - reach], + [r.x + reach, r.y - reach], + [r.x + reach, r.y + reach], + [r.x - reach, r.y + reach], + ]; + if !corners.iter().all(|c| shape.contains(*c)) { + let region = *regions + .entry(r.clip) + .or_insert_with(|| self.clip_region(r.clip, shape)); + match region { + Some(id) => r.clip = id, + // Nothing of the earlier region is inside this one. + None => continue, + } + } + self.texts.push(r); + } + } + + /// A new clip region: the shape, or the part of region `within` it, as + /// its id; None when that is empty, or when ids have run out (the run is + /// then kept or dropped by its anchor, as before regions). + fn clip_region(&mut self, within: u16, shape: &super::clip::ClipShape) -> Option { + let triangles = if within == 0 { + shape.triangles() + } else { + let mut out = Vec::new(); + let mut piece = Vec::new(); + for t in self + .clips + .get(within as usize - 1) + .cloned() + .unwrap_or_default() + { + shape.clip_triangle(t, &mut piece); + out.extend_from_slice(&piece); + } + out + }; + if triangles.is_empty() || self.clips.len() >= u16::MAX as usize { + return None; + } + self.clips.push(triangles); + Some(self.clips.len() as u16) + } + + /// The clip regions as the renderer takes them: for each, a `u32` + /// triangle count, then `f32 x, y` for each corner, relative to + /// `origin()`. + pub fn clip_buffer(&self) -> Vec { + let o = self.origin(); + let mut out = Vec::new(); + for region in &self.clips { + out.extend_from_slice(&(region.len() as u32).to_le_bytes()); + for p in region.iter().flatten() { + out.extend_from_slice(&((p[0] - o[0]) as f32).to_le_bytes()); + out.extend_from_slice(&((p[1] - o[1]) as f32).to_le_bytes()); + } + } + out } /// Declare the extents instead of deriving them from the geometry. @@ -452,6 +701,48 @@ impl Scene { } } + /// Narrow the extents to where the drawing is when a few strays lie far + /// off it (a point left at the origin of a drawing in national grid + /// coordinates): the f32 origin, the fitted view and the culling grid + /// then serve the drawing, not the empty space between it and them, + /// which cost it centimetres of precision and showed it as a speck. + /// The core is what lies between the 0.5th and 99.5th percentiles on + /// each axis, with a margin; the strays are still drawn. + pub fn focus_on_core(&mut self) { + let points = self.strokes.len() * 2 + self.fills.len() + self.texts.len(); + if points < 200 || !self.min_x.is_finite() { + return; + } + let stride = (points / 50_000).max(1); + let all = self + .strokes + .iter() + .flat_map(|s| [[s.x0, s.y0], [s.x1, s.y1]]) + .chain(self.fills.iter().map(|v| [v.x, v.y])) + .chain(self.texts.iter().map(|r| [r.x, r.y])); + let (mut xs, mut ys): (Vec, Vec) = + all.step_by(stride).map(|p| (p[0], p[1])).unzip(); + let span = |v: &mut Vec| -> (f64, f64) { + let n = v.len(); + let (lo, hi) = (n / 200, (n - 1) - n / 200); + let a = *v.select_nth_unstable_by(lo, f64::total_cmp).1; + let b = *v.select_nth_unstable_by(hi, f64::total_cmp).1; + (a, b) + }; + let ((x0, x1), (y0, y1)) = (span(&mut xs), span(&mut ys)); + let margin = (x1 - x0).max(y1 - y0) * 0.25; + let core = [x0 - margin, y0 - margin, x1 + margin, y1 + margin]; + let (full_w, full_h) = (self.max_x - self.min_x, self.max_y - self.min_y); + let (core_w, core_h) = (core[2] - core[0], core[3] - core[1]); + if !(core_w > 0.0 && core_h > 0.0) || (full_w <= 4.0 * core_w && full_h <= 4.0 * core_h) { + return; + } + self.min_x = self.min_x.max(core[0]); + self.min_y = self.min_y.max(core[1]); + self.max_x = self.max_x.min(core[2]); + self.max_y = self.max_y.min(core[3]); + } + /// Highest draw-order value used, for normalising depth. pub fn max_order(&self) -> u32 { self.order @@ -492,173 +783,285 @@ impl Scene { [self.min_x, self.min_y, self.max_x, self.max_y] } - /// Interleaved stroke instance buffer, `STROKE_BYTES` per segment. + /// The geometry as the renderer takes it (see [`Buffers`]). /// - /// Opaque segments come first, then translucent ones, and each half is - /// sorted into spatial buckets so the renderer can skip what a view does - /// not touch. The halves stay whole: the opaque pass writes depth and the - /// translucent pass only tests it, so interleaving them by bucket would let - /// one bucket's opaque geometry paint over another's translucent. - pub fn stroke_buffer(&self) -> Vec { + /// Opaque geometry comes first, sorted into spatial buckets so the + /// renderer can skip what a view does not touch, then translucent + /// geometry in draw order: blending is drawn in order, and the opaque + /// pass must be done before any of it, so the halves stay whole. + /// + /// It takes the strokes and fills out of the scene as it encodes them, + /// so each list is freed once its bytes are made: read the origin, the + /// extents, the text and the clip regions first. + pub fn buffers(&mut self) -> Buffers { let o = self.origin(); - let mut out = Vec::with_capacity(self.strokes.len() * STROKE_BYTES); - for half in self.stroke_halves() { - let (order, _) = self.bucket_strokes(&half); - for i in order { - let s = half[i as usize]; - out.extend_from_slice(&((s.x0 - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.y0 - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.x1 - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.y1 - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&s.rgba.to_le_bytes()); - out.extend_from_slice(&s.attr.to_le_bytes()); - out.extend_from_slice(&s.order.to_le_bytes()); + // Indices rather than copies: a stroke is 48 bytes, its index 4. + let [opaque_strokes, strokes] = self.stroke_halves(); + let [opaque_fills, fills] = self.fill_halves(); + let (stroke_order, mut stroke_tiles) = self.bucket_strokes(&opaque_strokes); + let (fill_order, mut fill_tiles) = self.bucket_fills(&opaque_fills); + let translucent = self.translucent(&strokes, &fills); + + let mut stroke_bytes = Vec::with_capacity(self.strokes.len() * STROKE_BYTES); + let ordered = stroke_order + .iter() + .map(|&i| opaque_strokes[i as usize]) + .chain(strokes.iter().copied()); + for s in ordered.map(|i| &self.strokes[i as usize]) { + stroke_bytes.extend_from_slice(&((s.x0 - o[0]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.y0 - o[1]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.x1 - o[0]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.y1 - o[1]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&s.rgba.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.attr.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.order.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.bulge.to_le_bytes()); + } + drop((stroke_order, strokes)); + self.strokes = Vec::new(); + let mut fill_bytes = Vec::with_capacity(self.fills.len() * FILL_BYTES); + let ordered = fill_order + .iter() + .map(|&i| opaque_fills[i as usize]) + .chain(fills.iter().copied()); + for t in ordered { + for v in &self.fills[t as usize * 3..t as usize * 3 + 3] { + fill_bytes.extend_from_slice(&((v.x - o[0]) as f32).to_le_bytes()); + fill_bytes.extend_from_slice(&((v.y - o[1]) as f32).to_le_bytes()); + fill_bytes.extend_from_slice(&v.rgba.to_le_bytes()); + fill_bytes.extend_from_slice(&v.attr.to_le_bytes()); + fill_bytes.extend_from_slice(&v.order.to_le_bytes()); } } - out - } + drop((fill_order, fills)); + self.fills = Vec::new(); + + // Tiles count strokes, and fill vertices (for drawArrays); the + // translucent ones start past the opaque half. + for tile in &mut fill_tiles { + tile.start *= 3; + tile.count *= 3; + } + let (opaque_stroke_tiles, opaque_fill_tiles) = (stroke_tiles.len(), fill_tiles.len()); + for mut tile in translucent.strokes { + tile.start += opaque_strokes.len() as u32; + stroke_tiles.push(tile); + } + for mut tile in translucent.fills { + tile.start = (opaque_fills.len() as u32 + tile.start) * 3; + tile.count *= 3; + fill_tiles.push(tile); + } + let mut runs = Vec::with_capacity(translucent.runs.len() * 12); + for (fill, from, to) in translucent.runs { + let base = if fill { + opaque_fill_tiles + } else { + opaque_stroke_tiles + } as u32; + runs.extend_from_slice(&u32::from(fill).to_le_bytes()); + runs.extend_from_slice(&(base + from).to_le_bytes()); + runs.extend_from_slice(&(base + to).to_le_bytes()); + } - /// Interleaved fill vertex buffer, `FILL_BYTES` per vertex. - pub fn fill_buffer(&self) -> Vec { - let o = self.origin(); - let mut out = Vec::with_capacity(self.fills.len() * FILL_BYTES); - for half in self.fill_halves() { - let (order, _) = self.bucket_fills(&half); - for i in order { - for v in half[i as usize] { - out.extend_from_slice(&((v.x - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((v.y - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&v.rgba.to_le_bytes()); - out.extend_from_slice(&v.attr.to_le_bytes()); - out.extend_from_slice(&v.order.to_le_bytes()); - } - } + Buffers { + strokes: stroke_bytes, + fills: fill_bytes, + opaque_strokes: opaque_strokes.len(), + opaque_fills: opaque_fills.len() * 3, + stroke_tiles: super::tiles::encode(&stroke_tiles, o), + fill_tiles: super::tiles::encode(&fill_tiles, o), + opaque_stroke_tiles, + opaque_fill_tiles, + translucent_runs: runs, } - out } - /// Strokes split into the opaque half and the translucent half, in that - /// order. Both buffer emission and the tile table walk this, so they cannot - /// disagree about what went where. - fn stroke_halves(&self) -> [Vec; 2] { - let (a, b): (Vec, Vec) = - self.strokes.iter().partition(|s| is_opaque(s.rgba)); + /// The strokes of the opaque half and of the translucent half, by + /// index, the translucent one in draw order. Both buffer emission and + /// the tile table walk this, so they cannot disagree about what went + /// where. + fn stroke_halves(&self) -> [Vec; 2] { + let all = 0..self.strokes.len() as u32; + let (a, mut b): (Vec, Vec) = + all.partition(|&i| is_opaque(self.strokes[i as usize].rgba)); + b.sort_by_key(|&i| self.strokes[i as usize].order); [a, b] } - /// The same for fills, kept as whole triangles. A gradient gives its - /// corners different colours but the same alpha, so one corner decides. - fn fill_halves(&self) -> [Vec<[FillVertex; 3]>; 2] { - let tris: Vec<[FillVertex; 3]> = self.fills.as_chunks::<3>().0.to_vec(); - let (a, b): (Vec<_>, Vec<_>) = tris.into_iter().partition(|t| is_opaque(t[0].rgba)); + /// The same for fills, by triangle. A gradient gives its corners + /// different colours but the same alpha, so one corner decides. + fn fill_halves(&self) -> [Vec; 2] { + let all = 0..(self.fills.len() / 3) as u32; + let (a, mut b): (Vec, Vec) = + all.partition(|&t| is_opaque(self.fills[t as usize * 3].rgba)); + b.sort_by_key(|&t| self.fills[t as usize * 3].order); [a, b] } - fn bucket_strokes(&self, half: &[Stroke]) -> (Vec, Vec) { + fn triangle(&self, t: u32) -> &[FillVertex; 3] { + let at = t as usize * 3; + self.fills[at..at + 3].try_into().expect("three vertices") + } + + /// The translucent halves' tiles, and the order to draw them in. A tile + /// is a run of the half in draw order, so it is no spatial bucket, but + /// neighbours in draw order are often neighbours on the sheet too. Fills + /// and strokes interleave by draw order, a run of tiles of one kind at a + /// time; past [`MAX_TRANSLUCENT_RUNS`] runs, the fills then the strokes. + fn translucent(&self, strokes: &[u32], fills: &[u32]) -> Translucent { + let stroke = |i: u32| &self.strokes[i as usize]; + let mut t = Translucent::default(); + let (mut i, mut j) = (0, 0); + while i < strokes.len() || j < fills.len() { + let fill = match (strokes.get(i), fills.get(j)) { + (Some(&s), Some(&f)) => self.triangle(f)[0].order <= stroke(s).order, + (None, _) => true, + (Some(_), None) => false, + }; + if fill { + t.push(true, j, fill_box(self.triangle(fills[j]))); + j += 1; + } else { + t.push(false, i, stroke_box(stroke(strokes[i]))); + i += 1; + } + } + if t.runs.len() > MAX_TRANSLUCENT_RUNS { + t = Translucent::default(); + for (j, &f) in fills.iter().enumerate() { + t.push(true, j, fill_box(self.triangle(f))); + } + for (i, &s) in strokes.iter().enumerate() { + t.push(false, i, stroke_box(stroke(s))); + } + } + t + } + + fn bucket_strokes(&self, half: &[u32]) -> (Vec, Vec) { + let stroke = |i: &u32| &self.strokes[*i as usize]; super::tiles::bucket( half, self.world_extent(), - |s| [(s.x0 + s.x1) / 2.0, (s.y0 + s.y1) / 2.0], - |s| { - [ - s.x0.min(s.x1), - s.y0.min(s.y1), - s.x0.max(s.x1), - s.y0.max(s.y1), - ] + |i| { + let s = stroke(i); + [(s.x0 + s.x1) / 2.0, (s.y0 + s.y1) / 2.0] }, + |i| stroke_box(stroke(i)), ) } - fn bucket_fills(&self, half: &[[FillVertex; 3]]) -> (Vec, Vec) { + fn bucket_fills(&self, half: &[u32]) -> (Vec, Vec) { super::tiles::bucket( half, self.world_extent(), - |t| { + |&i| { + let t = self.triangle(i); [ (t[0].x + t[1].x + t[2].x) / 3.0, (t[0].y + t[1].y + t[2].y) / 3.0, ] }, - |t| { - [ - t[0].x.min(t[1].x).min(t[2].x), - t[0].y.min(t[1].y).min(t[2].y), - t[0].x.max(t[1].x).max(t[2].x), - t[0].y.max(t[1].y).max(t[2].y), - ] - }, + |&i| fill_box(self.triangle(i)), ) } +} - /// Tile table for `stroke_buffer`, counted in strokes. The translucent - /// half's tiles are offset past the opaque half, so a start index addresses - /// the whole buffer. - pub fn stroke_tiles(&self) -> Vec { - let halves = self.stroke_halves(); - let mut tiles = Vec::new(); - let mut base = 0u32; - for half in &halves { - let (_, mut t) = self.bucket_strokes(half); - for tile in &mut t { - tile.start += base; - } - base += half.len() as u32; - tiles.extend(t); - } - super::tiles::encode(&tiles, self.origin()) - } - - /// Tile table for `fill_buffer`, counted in vertices, so it can be handed - /// straight to `drawArrays`. - pub fn fill_tiles(&self) -> Vec { - let halves = self.fill_halves(); - let mut tiles = Vec::new(); - let mut base = 0u32; - for half in &halves { - let (_, mut t) = self.bucket_fills(half); - for tile in &mut t { - tile.start = base + tile.start * 3; - tile.count *= 3; - } - base += half.len() as u32 * 3; - tiles.extend(t); - } - super::tiles::encode(&tiles, self.origin()) - } +/// What [`Scene::buffers`] hands the renderer; see [`super::Drawing`]. +pub struct Buffers { + pub strokes: Vec, + pub fills: Vec, + pub opaque_strokes: usize, + pub opaque_fills: usize, + pub stroke_tiles: Vec, + pub fill_tiles: Vec, + pub opaque_stroke_tiles: usize, + pub opaque_fill_tiles: usize, + pub translucent_runs: Vec, +} - /// Where the translucent tiles begin, as an index into the tile table. - pub fn opaque_stroke_tiles(&self) -> usize { - self.bucket_strokes(&self.stroke_halves()[0]).1.len() - } +/// Elements in one tile of a translucent half. +const TRANSLUCENT_TILE: u32 = 4096; +/// Most runs the translucent pass alternates fills and strokes in, each a +/// few draw calls. +const MAX_TRANSLUCENT_RUNS: usize = 1024; - pub fn opaque_fill_tiles(&self) -> usize { - self.bucket_fills(&self.fill_halves()[0]).1.len() - } +/// The translucent halves' tiles, `start` and `count` in elements of the +/// half, and the runs of tiles to draw: (fills, first tile, end tile). +#[derive(Default)] +struct Translucent { + strokes: Vec, + fills: Vec, + runs: Vec<(bool, u32, u32)>, +} - /// How many leading strokes of `stroke_buffer` are opaque. - pub fn opaque_strokes(&self) -> usize { - self.strokes.iter().filter(|s| is_opaque(s.rgba)).count() +impl Translucent { + /// Element `index` of a half, next in draw order. + fn push(&mut self, fill: bool, index: usize, b: [f64; 4]) { + let tiles = if fill { + &mut self.fills + } else { + &mut self.strokes + }; + let same_run = self.runs.last().is_some_and(|r| r.0 == fill); + if let Some(t) = tiles + .last_mut() + .filter(|t| same_run && t.count < TRANSLUCENT_TILE) + { + t.count += 1; + t.min_x = t.min_x.min(b[0]); + t.min_y = t.min_y.min(b[1]); + t.max_x = t.max_x.max(b[2]); + t.max_y = t.max_y.max(b[3]); + return; + } + tiles.push(Tile { + start: index as u32, + count: 1, + min_x: b[0], + min_y: b[1], + max_x: b[2], + max_y: b[3], + }); + let n = tiles.len() as u32; + match self.runs.last_mut() { + Some(r) if same_run => r.2 = n, + _ => self.runs.push((fill, n - 1, n)), + } } +} - /// How many leading vertices of `fill_buffer` are opaque. - pub fn opaque_fills(&self) -> usize { - self.fills - .as_chunks::<3>() - .0 - .iter() - .filter(|t| is_opaque(t[0].rgba)) - .count() - * 3 - } +fn stroke_box(s: &Stroke) -> [f64; 4] { + let h = s.sagitta(); + [ + s.x0.min(s.x1) - h, + s.y0.min(s.y1) - h, + s.x0.max(s.x1) + h, + s.y0.max(s.y1) + h, + ] } -/// Fully opaque geometry can write depth; translucent geometry cannot. -/// -/// The draw order is resolved through the depth buffer, so a translucent -/// entity that writes depth rejects everything drawn behind it afterwards -/// instead of letting it blend through. Splitting the buffers lets the -/// renderer draw the translucent tail with depth writes off. +fn fill_box(t: &[FillVertex; 3]) -> [f64; 4] { + [ + t[0].x.min(t[1].x).min(t[2].x), + t[0].y.min(t[1].y).min(t[2].y), + t[0].x.max(t[1].x).max(t[2].x), + t[0].y.max(t[1].y).max(t[2].y), + ] +} + +/// How far from its anchor a run can reach, whatever its alignment and +/// rotation: every character an em wide, which no bundled face exceeds, and +/// a line's height. +fn text_reach(r: &super::text::TextRun) -> f64 { + let chars = r.text.chars().count() as f64; + r.unit() * (chars + 1.0) +} + +/// Fully opaque geometry can be drawn in any order, the depth buffer +/// deciding what covers what; translucent geometry blends over what is +/// already drawn, so it comes after, in draw order. #[inline] fn is_opaque(rgba: u32) -> bool { rgba >> 24 == 255 @@ -736,23 +1139,119 @@ mod tests { s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); s.push_triangle([2.0, 0.0], [3.0, 0.0], [2.0, 1.0], solid, 0); - assert_eq!(s.opaque_strokes(), 1); - assert_eq!(s.opaque_fills(), 3); + let b = s.buffers(); + assert_eq!(b.opaque_strokes, 1); + assert_eq!(b.opaque_fills, 3); // The opaque one comes first in the buffer whatever order it arrived in. - let buf = s.stroke_buffer(); - let first_rgba = u32::from_le_bytes(buf[16..20].try_into().unwrap()); + let first_rgba = u32::from_le_bytes(b.strokes[16..20].try_into().unwrap()); assert_eq!(first_rgba, solid); - let fills = s.fill_buffer(); - let first_fill_rgba = u32::from_le_bytes(fills[8..12].try_into().unwrap()); + let first_fill_rgba = u32::from_le_bytes(b.fills[8..12].try_into().unwrap()); assert_eq!(first_fill_rgba, solid); } + fn u32s(b: &[u8]) -> Vec { + b.as_chunks::<4>() + .0 + .iter() + .map(|c| u32::from_le_bytes(*c)) + .collect() + } + + /// An opaque fill is outlined by edges of no width, which anti-alias it: + /// a square of two triangles by its four sides, not their diagonal. A + /// translucent fill and a background one are not. + #[test] + fn opaque_fills_are_outlined_for_their_edges() { + let mut s = Scene::new(); + let red = pack_rgba(255, 0, 0, 255); + let attr = pack_attr(3, 25, FLAG_CONTRAST); + s.push_triangle([0.0, 0.0], [1.0, 0.0], [1.0, 1.0], red, attr); + s.push_triangle([0.0, 0.0], [1.0, 1.0], [0.0, 1.0], red, attr); + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + for k in &s.strokes { + assert_eq!(k.attr, pack_attr(3, FILL_EDGE, FLAG_CONTRAST)); + assert_eq!(k.rgba, red); + assert!(k.x0 == k.x1 || k.y0 == k.y1, "a side, not the diagonal"); + } + // Each fill once: a second call outlines nothing more. + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + s.push_triangle( + [5.0, 0.0], + [6.0, 0.0], + [6.0, 1.0], + pack_rgba(255, 0, 0, 128), + attr, + ); + s.push_triangle( + [7.0, 0.0], + [8.0, 0.0], + [8.0, 1.0], + red, + pack_attr(3, 25, FILL_FLAG_BACKGROUND), + ); + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + } + + /// Translucent geometry is drawn in draw order, fills and strokes + /// interleaved, wherever it was pushed from. + #[test] + fn translucent_geometry_keeps_draw_order() { + let mut s = Scene::new(); + let clear = pack_rgba(255, 0, 0, 128); + for (order, fill) in [(3, false), (1, true), (2, false), (4, true), (0, false)] { + s.order = order; + if fill { + s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); + } else { + s.push_stroke(0.0, 0.0, 1.0, 0.0, clear, 0); + } + } + // An opaque stroke elsewhere gives the stroke table a bucket first. + s.order = 9; + s.push_stroke(50.0, 50.0, 60.0, 50.0, pack_rgba(0, 0, 0, 255), 0); + let b = s.buffers(); + let orders: Vec = b + .strokes + .chunks(STROKE_BYTES) + .map(|c| u32s(&c[24..28])[0]) + .collect(); + assert_eq!(orders, [9, 0, 2, 3]); + // Strokes 0, fill 1, strokes 2 and 3, fill 4: stroke tiles from 1 + // (past the opaque bucket), fill tiles from 0. + assert_eq!( + u32s(&b.translucent_runs), + [0, 1, 2, 1, 0, 1, 0, 2, 3, 1, 1, 2] + ); + let tiles = u32s(&b.stroke_tiles); + let starts: Vec = tiles.chunks(6).map(|t| t[0]).collect(); + let counts: Vec = tiles.chunks(6).map(|t| t[1]).collect(); + assert_eq!((starts, counts), (vec![0, 1, 2], vec![1, 1, 2])); + } + + /// Past the most runs, the fills then the strokes. + #[test] + fn translucent_runs_have_a_ceiling() { + let mut s = Scene::new(); + let clear = pack_rgba(255, 0, 0, 128); + for i in 0..MAX_TRANSLUCENT_RUNS as u32 { + s.order = 2 * i; + s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); + s.order = 2 * i + 1; + s.push_stroke(0.0, 0.0, 1.0, 0.0, clear, 0); + } + let runs = u32s(&s.buffers().translucent_runs); + assert_eq!(runs, [1, 0, 1, 0, 0, 1]); + } + #[test] fn the_contrast_flag_and_the_fade_code_share_one_byte() { // Both live in the flags byte, so a hatch on colour 7 has to carry its // spacing and its flip without either eating the other. - for spacing in [1.5f64, 16.0, 500.0, 50_000.0] { + for spacing in [0.01, 0.5, 1.5f64, 16.0, 500.0, 50_000.0] { let fade = encode_fade_spacing(spacing); assert!( fade > 0 && fade < FLAG_CONTRAST, @@ -765,18 +1264,18 @@ mod tests { assert_eq!(a & 0xFFFF, 7); assert_eq!((a >> 16) & 0xFF, 25); } - assert_eq!(encode_fade_spacing(0.5), 0); + assert_eq!(encode_fade_spacing(0.0), 0); } #[test] fn the_fade_code_still_resolves_a_useful_spacing_range() { - // Seven bits, eight steps to the octave: a millimetre up to tens of - // metres, which is every hatch spacing a drawing uses. - let decode = |code: u8| (code as f64 / 8.0).exp2(); - for spacing in [2.0f64, 40.0, 5000.0] { + // Seven bits, five steps to the octave from 2^-9: a pattern + // millimetres apart in metres, or metres apart in millimetres. + let decode = |code: u8| (code as f64 / 5.0 - 9.0).exp2(); + for spacing in [0.003f64, 0.05, 0.5, 2.0, 40.0, 5000.0, 80_000.0] { let back = decode(encode_fade_spacing(spacing)); assert!( - (back / spacing - 1.0).abs() < 0.05, + (back / spacing - 1.0).abs() < 0.075, "{spacing} came back as {back}" ); } @@ -796,7 +1295,7 @@ mod tests { let o = s.origin(); assert!((o[0] - 6_500_000.5).abs() < 1e-6); - let buf = s.stroke_buffer(); + let buf = s.buffers().strokes; let x0 = f32::from_le_bytes(buf[0..4].try_into().unwrap()); let x1 = f32::from_le_bytes(buf[8..12].try_into().unwrap()); // Relative coordinates keep the 1-unit separation intact. @@ -814,13 +1313,66 @@ mod tests { ); } + /// A circle is four quarter arcs, its extents its own box exactly; an + /// arc inside a clip stays one, one across it is cut along its curve. + #[test] + fn arcs_are_strokes_of_a_quarter_turn() { + let mut s = Scene::new(); + s.push_arc(10.0, 0.0, 2.0, 0.0, std::f64::consts::TAU, 0, 0); + assert_eq!(s.strokes.len(), 4); + let quarter = (std::f64::consts::PI / 8.0).tan() as f32; + assert!(s.strokes.iter().all(|k| (k.bulge - quarter).abs() < 1e-6)); + let o = s.origin(); + assert_eq!( + s.extents_local().map(|v| (v * 1e4).round() / 1e4), + [-2.0, -2.0, 2.0, 2.0] + ); + assert_eq!(o, [10.0, 0.0]); + // The encoding carries the bulge last. + let b = s.buffers(); + let at = STROKE_BYTES - 4; + assert_eq!( + f32::from_le_bytes(b.strokes[at..at + 4].try_into().unwrap()), + quarter + ); + + let mut s = Scene::new(); + s.push_arc(0.0, 0.0, 1.0, 0.0, std::f64::consts::FRAC_PI_2, 0, 0); + let mark = Mark { + strokes: 0, + fills: 0, + texts: 0, + }; + s.clip_since( + mark, + &super::super::clip::ClipShape::rect([0.0, 0.0], 10.0, 10.0), + ); + assert_eq!(s.strokes.len(), 1, "inside: still one arc"); + s.clip_since( + mark, + &super::super::clip::ClipShape::rect([1.0, 0.0], 1.0, 10.0), + ); + assert!( + s.strokes.len() > 1 + && s.strokes + .iter() + .all(|k| k.bulge == 0.0 && k.x0 >= 0.5 - 1e-9) + ); + // On the curve, to the flattening's sagitta. + assert!(s + .strokes + .iter() + .all(|k| (k.x0.hypot(k.y0) - 1.0).abs() < 2e-3)); + } + #[test] fn buffers_have_the_documented_stride() { let mut s = Scene::new(); s.push_stroke(0.0, 0.0, 1.0, 1.0, 0, 0); s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], 0, 0); - assert_eq!(s.stroke_buffer().len(), STROKE_BYTES); - assert_eq!(s.fill_buffer().len(), 3 * FILL_BYTES); + let b = s.buffers(); + assert_eq!(b.strokes.len(), STROKE_BYTES); + assert_eq!(b.fills.len(), 3 * FILL_BYTES); } #[test] diff --git a/crates/exav-render/src/formats/dwg/tessellate.rs b/crates/exav-render/src/formats/dwg/tessellate.rs index c83f158..bedfe8d 100644 --- a/crates/exav-render/src/formats/dwg/tessellate.rs +++ b/crates/exav-render/src/formats/dwg/tessellate.rs @@ -1,6 +1,6 @@ //! Walk a drawing model and turn drawable entities into GPU buffers. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::cad::{ Color, Drawing as CadDrawing, Edge, Entity, EntityKind, HAlign, Handle, LineWeight, @@ -9,6 +9,7 @@ use crate::cad::{ use super::clip::ClipShape; use super::curves; +use super::dimension; use super::fill; use super::font; use super::gradient::{self, Gradient}; @@ -20,6 +21,7 @@ use super::scene::{ encode_fade_spacing, pack_attr, pack_rgba, Affine, Scene, FILL_FLAG_BACKGROUND, FLAG_CONTRAST, }; use super::text; +use super::wide::{self, WideVertex}; fn h_align_of(a: HAlign) -> text::HAlign { match a { @@ -94,6 +96,230 @@ fn island_style(h: &crate::cad::Hatch) -> fill::IslandStyle { /// step AutoCAD's own editor appears to use and is the width of a typical list /// indent; it is a guess, and it is only ever reached by text that tabs without /// declaring where to. +/// An arrowhead one unit long, its tip at the origin pointing along +x: +/// triangles to fill and lines to stroke. +struct ArrowShape { + filled: Vec<[f64; 2]>, + lines: Vec>, +} + +/// AutoCAD's built-in arrowheads by block name (`_OPEN`...); an empty or +/// unknown name is the closed filled one. +fn arrow_shape(name: &str) -> ArrowShape { + let circle = |r: f64, cx: f64| -> Vec<[f64; 2]> { + let mut c = curves::flatten_circle(cx, 0.0, r); + if let Some(&first) = c.first() { + c.push(first); + } + c + }; + let disc = |r: f64| -> Vec<[f64; 2]> { + let ring = curves::flatten_circle(0.0, 0.0, r); + (0..ring.len()) + .flat_map(|i| [[0.0, 0.0], ring[i], ring[(i + 1) % ring.len()]]) + .collect() + }; + let triangle = |w: f64, l: f64| vec![[0.0, 0.0], [-l, w], [-l, -w]]; + let (filled, lines) = match name { + "_NONE" => (vec![], vec![]), + "_OPEN" => ( + vec![], + vec![vec![[-1.0, 1.0 / 6.0], [0.0, 0.0], [-1.0, -1.0 / 6.0]]], + ), + "_OPEN30" => ( + vec![], + vec![vec![[-1.0, 0.268], [0.0, 0.0], [-1.0, -0.268]]], + ), + "_OPEN90" => (vec![], vec![vec![[-0.5, 0.5], [0.0, 0.0], [-0.5, -0.5]]]), + "_CLOSEDBLANK" => ( + vec![], + vec![{ + let mut t = triangle(1.0 / 6.0, 1.0); + t.push([0.0, 0.0]); + t + }], + ), + "_CLOSED" => ( + vec![], + vec![ + { + let mut t = triangle(1.0 / 6.0, 1.0); + t.push([0.0, 0.0]); + t + }, + vec![[-1.0, 0.0], [0.0, 0.0]], + ], + ), + "_SMALL" => (triangle(1.0 / 12.0, 0.5), vec![]), + "_DOT" => (disc(0.25), vec![]), + "_DOTSMALL" => (disc(1.0 / 16.0), vec![]), + "_DOTBLANK" => (vec![], vec![circle(0.25, 0.0)]), + "_ORIGIN" => (vec![], vec![circle(0.5, 0.0)]), + "_ORIGIN2" => (vec![], vec![circle(0.5, 0.0), circle(0.25, 0.0)]), + "_OBLIQUE" | "_ARCHTICK" => (vec![], vec![vec![[-0.5, -0.5], [0.5, 0.5]]]), + "_BOXFILLED" => ( + vec![ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + ], + vec![], + ), + "_BOXBLANK" => ( + vec![], + vec![vec![ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + [-0.5, -0.5], + ]], + ), + "_DATUMFILLED" => (vec![[0.0, 0.577], [0.0, -0.577], [-1.0, 0.0]], vec![]), + "_DATUMBLANK" => ( + vec![], + vec![vec![[0.0, 0.577], [0.0, -0.577], [-1.0, 0.0], [0.0, 0.577]]], + ), + _ => (triangle(1.0 / 6.0, 1.0), vec![]), + }; + ArrowShape { filled, lines } +} + +/// A tolerance box's text with its GD&T font runs (`{\Fgdt;j}`, the font +/// code in any case) as the symbols they stand for, the braces gone. +fn gdt_codes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.to_ascii_lowercase().find("\\fgdt;") { + let before = &rest[..at]; + out.push_str(before.strip_suffix('{').unwrap_or(before)); + let after = &rest[at + "\\fgdt;".len()..]; + let end = after.find('}').unwrap_or(after.len()); + out.push_str(&font::map_symbols(&after[..end], font::Symbols::Gdt)); + rest = after.get(end + 1..).unwrap_or(""); + } + out.push_str(rest); + out +} + +/// A polygon mesh's lines: along M and along N, closed as its flags say +/// (1 in M, 32 in N). +fn polygon_mesh_edges(p: &crate::cad::Polyline) -> Vec> { + let (m, n) = (p.m_count.max(0) as usize, p.n_count.max(0) as usize); + let pts: Vec<[f64; 2]> = p + .vertices + .iter() + .map(|v| [v.location.x, v.location.y]) + .collect(); + if m < 1 || n < 1 || pts.len() < m * n { + return Vec::new(); + } + let at = |i: usize, j: usize| pts[i * n + j]; + let mut out = Vec::new(); + for i in 0..m { + let mut row: Vec<[f64; 2]> = (0..n).map(|j| at(i, j)).collect(); + if p.flags & 32 != 0 && n > 2 { + row.push(at(i, 0)); + } + out.push(row); + } + for j in 0..n { + let mut column: Vec<[f64; 2]> = (0..m).map(|i| at(i, j)).collect(); + if p.flags & 1 != 0 && m > 2 { + column.push(at(0, j)); + } + out.push(column); + } + out +} + +/// A polyface mesh's visible edges: each face's sides, but those whose +/// starting index is negative, each side once. +fn polyface_edges(p: &crate::cad::Polyline) -> Vec> { + let corners: Vec<[f64; 2]> = p + .vertices + .iter() + .filter(|v| v.flags & 64 != 0) + .map(|v| [v.location.x, v.location.y]) + .collect(); + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for face in p + .vertices + .iter() + .filter(|v| v.flags & 64 == 0 && v.flags & 128 != 0) + { + let ids: Vec = face + .indices + .iter() + .copied() + .take_while(|&i| i != 0) + .collect(); + for k in 0..ids.len() { + let (a, b) = (ids[k], ids[(k + 1) % ids.len()]); + if a < 0 || ids.len() < 2 { + continue; + } + let (a, b) = (a.unsigned_abs() as usize, b.unsigned_abs() as usize); + let (Some(&pa), Some(&pb)) = (corners.get(a - 1), corners.get(b - 1)) else { + continue; + }; + if seen.insert((a.min(b), a.max(b))) { + out.push(vec![pa, pb]); + } + } + } + out +} + +/// Opacity of the fill drawn for a pattern too dense to draw: about what +/// sub-pixel lines, faded by their spacing, add up to on screen. +const TRUNCATED_PATTERN_TONE: f64 = 0.5; + +/// The scale, turn and mirroring of a transform that keeps circles round +/// (rotation, uniform scale, perhaps a mirror); None for any other. +fn similarity(xf: &Affine) -> Option<(f64, f64, bool)> { + let scale = xf.a.hypot(xf.b); + let tolerance = 1e-9 * scale.max(1e-300); + let turn = xf.b.atan2(xf.a); + if (xf.a - xf.d).abs() <= tolerance && (xf.b + xf.c).abs() <= tolerance { + return (scale > 0.0).then_some((scale, turn, false)); + } + if (xf.a + xf.d).abs() <= tolerance && (xf.b - xf.c).abs() <= tolerance { + return (scale > 0.0).then_some((scale, turn, true)); + } + None +} + +/// A stack's parts against the text height: AutoCAD's default (`TSTACKSIZE`). +const STACK_SIZE: f64 = 0.7; + +/// A run's height: the entity's, times a `\H...x` factor, or a `\H` height. +fn span_height(s: &text::Span, base: f64) -> f64 { + match s.height { + text::SpanHeight::Factor(f) if f > 0.0 => base * f, + text::SpanHeight::Absolute(h) if h > 0.0 => h, + _ => base, + } +} + +/// How wide a stack is drawn: its wider part, or both parts and the slash +/// of a diagonal one. +fn stack_width(s: &text::Stack, metrics: &font::Metrics, height: f64, w: f64) -> f64 { + let part = height * STACK_SIZE; + let (upper, lower) = ( + metrics.measure(&s.upper) * part * w, + metrics.measure(&s.lower) * part * w, + ); + match s.kind { + text::StackKind::Diagonal => upper + 0.5 * part + lower, + _ => upper.max(lower), + } +} + fn next_tab_stop(cursor: f64, paragraph: &text::Paragraph, height: f64) -> f64 { if let Some(stop) = paragraph.tabs.iter().find(|s| **s > cursor + 1e-9) { return *stop; @@ -130,41 +356,69 @@ struct LineTypeDef { labels: Vec, } -/// The text a complex linetype embeds, ready to scale. +/// The text and shapes a complex linetype embeds, ready to scale. /// -/// Shape elements are skipped: they name a glyph in an SHX shape file, which -/// cannot be shipped any more than an SHX font can (DESIGN.md section 5.3). +/// A shape names a glyph in an SHX shape file, which cannot be shipped any +/// more than an SHX font can (DESIGN.md section 5.3): only ltypeshp.shx's, +/// which AutoCAD's own linetypes use, are drawn, redrawn here. fn embedded_labels(doc: &CadDrawing, lt: &crate::cad::Linetype) -> Vec { let mut out = Vec::new(); for (run, e) in lt.elements.iter().enumerate() { - if !e.has_text() || e.text.is_empty() || !e.scale.is_finite() || e.scale <= 0.0 { + if !e.scale.is_finite() || e.scale <= 0.0 { continue; } - // The element's scale multiplies the style's fixed height, or is the - // height itself when the style leaves it free, which is the usual case. let style = doc .text_styles .iter() .find(|s| !e.style.is_null() && s.handle == e.style); - let style_height = style.map(|s| s.height).unwrap_or(0.0); - let height = if style_height > 0.0 { - style_height * e.scale + let (mark, height) = if e.has_text() && !e.text.is_empty() { + // The element's scale multiplies the style's fixed height, or is + // the height itself when the style leaves it free, which is the + // usual case. + let style_height = style.map(|s| s.height).unwrap_or(0.0); + let height = if style_height > 0.0 { + style_height * e.scale + } else { + e.scale + }; + let face = style.map(font::resolve_style).unwrap_or(font::Face::Stroke); + ( + linetype::Mark::Text { + text: text::decode_special(&e.text), + face, + }, + height, + ) + } else if e.has_shape() { + let Some(shape) = style + .filter(|s| is_ltypeshp(&s.font_file)) + .and_then(|_| linetype::ltypeshp_shape(e.shape_number)) + else { + continue; + }; + (linetype::Mark::Shape(shape), e.scale) } else { - e.scale + continue; }; out.push(linetype::Label { run, - text: text::decode_special(&e.text), + mark, height, offset: [e.offset.x, e.offset.y], rotation: e.rotation, absolute_rotation: e.absolute_rotation(), - face: style.map(font::resolve_style).unwrap_or(font::Face::Stroke), }); } out } +/// A style's font file is ltypeshp.shx, under any path or case. +fn is_ltypeshp(file: &str) -> bool { + let name = file.rsplit(['/', '\\']).next().unwrap_or(file); + let stem = name.split('.').next().unwrap_or(name); + stem.eq_ignore_ascii_case("ltypeshp") +} + /// A RAY or XLINE, held back until the drawing's extents are known. struct InfiniteLine { base: [f64; 2], @@ -219,6 +473,32 @@ pub struct LayoutInfo { pub tab_order: i16, /// The sheet, as `[min_x, min_y, max_x, max_y]` in paper units. pub limits: Option<[f64; 4]>, + /// Millimetres per paper unit: 25.4 for a sheet set up in inches. + pub mm_per_unit: f64, +} + +/// What a tab is drawn on, beyond its geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct Sheet { + /// Millimetres per drawing unit, which a lineweight on a layout is a + /// width in: 25.4 for a sheet in inches. 0 for model space, where a + /// lineweight is a width on the screen. + pub mm_per_unit: f64, + /// `$LWDISPLAY`: the drawing is shown with its lineweights. + pub lineweights: bool, + /// A layout's paper, `[min_x, min_y, max_x, max_y]` in drawing units: + /// its limits. None for model space. + pub paper: Option<[f64; 4]>, +} + +impl Default for Sheet { + fn default() -> Self { + Sheet { + mm_per_unit: 0.0, + lineweights: true, + paper: None, + } + } } pub struct LayerInfo { @@ -259,6 +539,33 @@ pub struct Warnings { /// without proxy graphics, or with graphics that draw nothing: only the /// application that made them can show them. pub proxy_without_graphics: u32, + /// What `unknown_entities` counts, by DXF type, at most + /// [`MAX_NAMED`] types. + pub not_drawn: BTreeMap, + /// The files external references name (xrefs, images, underlays), at + /// most [`MAX_NAMED`]. + pub external_files: BTreeSet, +} + +/// Most names a warning lists. +pub const MAX_NAMED: usize = 16; + +impl Warnings { + fn not_drawn(&mut self, kind: &str) { + self.unknown_entities = self.unknown_entities.saturating_add(1); + if let Some(n) = self.not_drawn.get_mut(kind) { + *n = n.saturating_add(1); + } else if self.not_drawn.len() < MAX_NAMED { + self.not_drawn.insert(kind.to_string(), 1); + } + } + + fn external(&mut self, file: &str) { + self.external_references = self.external_references.saturating_add(1); + if !file.is_empty() && self.external_files.len() < MAX_NAMED { + self.external_files.insert(file.to_string()); + } + } } /// Default for [`Tessellator::budget`]: strokes and fill vertices one layout @@ -318,13 +625,14 @@ pub struct Tessellator<'a> { /// because a drawing names a handful of styles and uses them thousands of /// times. style_faces: HashMap, - /// The same, by style handle: entities that reference a style by name are - /// the common case, but leaders and dimensions point at one by handle. - style_faces_by_handle: HashMap, + /// Styles whose font draws symbols for letters (symbol.ttf, gdt.shx). + style_symbols: HashMap, /// Which ACI table indexed colours resolve through. palette: Palette, /// Block names currently being expanded, to break reference cycles. stack: Vec, + /// What the tab drawn last is drawn on. + pub sheet: Sheet, } fn normalize(name: &str) -> String { @@ -449,13 +757,17 @@ impl<'a> Tessellator<'a> { .iter() .map(|s| (normalize(&s.name), font::resolve_style(s))) .collect(), - style_faces_by_handle: doc + style_symbols: doc .text_styles .iter() - .map(|s| (s.handle.0, font::resolve_style(s))) + .filter_map(|s| Some((normalize(&s.name), font::symbols_of(&s.font_file)?))) .collect(), palette, stack: Vec::new(), + sheet: Sheet { + lineweights: doc.header.lwdisplay, + ..Sheet::default() + }, } } @@ -504,60 +816,74 @@ impl<'a> Tessellator<'a> { maxy += pad; for line in std::mem::take(&mut self.pending_infinite) { - let len = (line.dir[0] * line.dir[0] + line.dir[1] * line.dir[1]).sqrt(); - if !len.is_finite() || len < 1e-12 { - continue; - } - let u = [line.dir[0] / len, line.dir[1] / len]; + self.push_infinite(&line, [minx, miny], [maxx, maxy]); + } + } - // Slab clip: the parameter range where the line is inside the box. - let mut t0 = if line.both_ways { - f64::NEG_INFINITY - } else { - 0.0 - }; - let mut t1 = f64::INFINITY; - let mut outside = false; - for (p, d, lo, hi) in [ - (line.base[0], u[0], minx, maxx), - (line.base[1], u[1], miny, maxy), - ] { - if d.abs() < 1e-12 { - if p < lo || p > hi { - outside = true; - } - continue; + /// The part of a ray or xline inside a box, as a stroke. + fn push_infinite(&mut self, line: &InfiniteLine, min: [f64; 2], max: [f64; 2]) { + let len = (line.dir[0] * line.dir[0] + line.dir[1] * line.dir[1]).sqrt(); + if !len.is_finite() || len < 1e-12 { + return; + } + let u = [line.dir[0] / len, line.dir[1] / len]; + + // Slab clip: the parameter range where the line is inside the box. + let mut t0 = if line.both_ways { + f64::NEG_INFINITY + } else { + 0.0 + }; + let mut t1 = f64::INFINITY; + for (p, d, lo, hi) in [ + (line.base[0], u[0], min[0], max[0]), + (line.base[1], u[1], min[1], max[1]), + ] { + if d.abs() < 1e-12 { + if p < lo || p > hi { + return; } - let (a, b) = ((lo - p) / d, (hi - p) / d); - let (a, b) = if a <= b { (a, b) } else { (b, a) }; - t0 = t0.max(a); - t1 = t1.min(b); - } - if outside || !(t1 > t0) || !t0.is_finite() || !t1.is_finite() { continue; } - - self.scene.push_stroke( - line.base[0] + u[0] * t0, - line.base[1] + u[1] * t0, - line.base[0] + u[0] * t1, - line.base[1] + u[1] * t1, - line.rgba, - line.attr, - ); + let (a, b) = ((lo - p) / d, (hi - p) / d); + let (a, b) = if a <= b { (a, b) } else { (b, a) }; + t0 = t0.max(a); + t1 = t1.min(b); + } + if !(t1 > t0) || !t0.is_finite() || !t1.is_finite() { + return; } + + self.scene.push_stroke( + line.base[0] + u[0] * t0, + line.base[1] + u[1] * t0, + line.base[0] + u[0] * t1, + line.base[1] + u[1] * t1, + line.rgba, + line.attr, + ); } /// Leader lines plus the label they point at. fn multi_leader(&mut self, m: &crate::cad::MultiLeader, ctx: &Ctx, rgba: u32, attr: u32) { let c = &m.context; - // Every leader root contributes one or more polylines. + // Every leader root contributes one or more polylines, each with + // its arrowhead at its first vertex. + let scale = if c.scale > 0.0 { c.scale } else { 1.0 }; + let size = m.arrowhead_size * scale; for root in &c.leaders { for line in &root.lines { - let pts: Vec<[f64; 2]> = line.vertices.iter().map(|p| [p.x, p.y]).collect(); + // From the arrow's tip through the line's vertices to where + // the root lands. + let mut pts: Vec<[f64; 2]> = line.vertices.iter().map(|p| [p.x, p.y]).collect(); + let landing = [root.connection_point.x, root.connection_point.y]; + if pts.last() != Some(&landing) && (root.has_connection_point || pts.len() < 2) { + pts.push(landing); + } if pts.len() >= 2 { self.push_transformed(&pts, false, &ctx.xf, rgba, attr, None); + self.arrowhead(m.arrowhead, pts[0], pts[1], size, ctx, rgba, attr); } } // The landing runs from the last leader point to the content. @@ -582,47 +908,32 @@ impl<'a> Tessellator<'a> { } else { 2.5 }; - let spacing = height - * text::MTEXT_LINE_SPACING - * if c.line_spacing_factor > 0.0 { - c.line_spacing_factor - } else { - 1.0 - }; - let lines = text::mtext_lines(&c.text); - let face = self.style_face_by_handle(c.text_style); - // Attachment codes match MTEXT's. - let (h_align, dy0) = - text::mtext_layout(c.text_attachment as u8, lines.len(), height, spacing); - let (sin_r, cos_r) = rotation.sin_cos(); let text_rgba = match c.text_color { Color::ByLayer | Color::ByBlock => rgba, ref col => colour_rgb(col, self.palette) - .map(|(r, g, b)| pack_rgba(r, g, b, 255)) + .map(|(r, g, b)| pack_rgba(r, g, b, (rgba >> 24) as u8)) .unwrap_or(rgba), }; - - for (i, line) in lines.iter().enumerate() { - if line.is_empty() { - continue; - } - let dy = dy0 - i as f64 * spacing; - self.push_text_run( - line.clone(), - c.text_location.x - dy * sin_r, - c.text_location.y + dy * cos_r, - height, - rotation, - 1.0, - 0.0, - h_align, - text::VAlign::Baseline, - ctx, - text_rgba, - attr, - face, - ); - } + // The content is MTEXT, drawn as one (attachment codes match). + let style = self + .doc + .text_styles + .iter() + .find(|s| s.handle == c.text_style && !c.text_style.is_null()) + .map(|s| s.name.clone()) + .unwrap_or_default(); + let content = crate::cad::MText { + insertion: c.text_location, + height, + reference_width: c.text_width, + attachment: c.text_attachment, + style, + rotation, + line_spacing_factor: c.line_spacing_factor, + text: c.text.clone(), + ..crate::cad::MText::default() + }; + self.mtext(&content, ctx, text_rgba, attr); } } @@ -717,13 +1028,14 @@ impl<'a> Tessellator<'a> { .unwrap_or(font::Face::Stroke) } - /// The same, for an entity that points at its style by handle. - fn style_face_by_handle(&self, handle: Handle) -> font::Face { - self.style_faces_by_handle - .get(&handle.0) - .copied() - .filter(|_| !handle.is_null()) - .unwrap_or(font::Face::Stroke) + /// A TEXT's or ATTRIB's string as its style shows it: the `%%` codes + /// decoded, then a symbol font's letters as the symbols they draw. + fn style_text(&self, style: &str, raw: &str) -> String { + let s = text::decode_special(raw); + match self.style_symbols.get(&normalize(style)) { + Some(&set) => font::map_symbols(&s, set), + None => s, + } } /// MTEXT content as the lines it is drawn on: the explicit `\P` breaks, @@ -755,138 +1067,338 @@ impl<'a> Tessellator<'a> { .collect() } - /// Draw one laid-out MTEXT line, honouring per-run colour, height and rules. + /// A style's width factor and oblique angle, which MTEXT takes unless + /// `\W` and `\Q` say otherwise. + fn style_shape(&self, style: &str) -> (f64, f64) { + self.doc + .text_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(style)) + .map(|s| { + let w = if s.width_factor > 0.0 { + s.width_factor + } else { + 1.0 + }; + (w, s.oblique) + }) + .unwrap_or((1.0, 0.0)) + } + + /// An MTEXT, or a multiline attribute: its lines wrapped to its box and + /// stacked from the attachment point, each aligned as its paragraph + /// says, over its background mask when it has one. + fn mtext(&mut self, m: &crate::cad::MText, ctx: &Ctx, rgba: u32, attr: u32) { + let height = if m.height > 0.0 { m.height } else { 1.0 }; + let face = self.style_face(&m.style); + let shape = self.style_shape(&m.style); + let mut lines = self.wrap_mtext(&m.text, m.reference_width / shape.0, height, face); + if let Some(&set) = self.style_symbols.get(&normalize(&m.style)) { + for s in lines + .iter_mut() + .flat_map(|l| l.spans.iter_mut()) + .filter(|s| s.face.is_none()) + { + s.text = font::map_symbols(&s.text, set); + } + } + let spacing = height + * text::MTEXT_LINE_SPACING + * if m.line_spacing_factor > 0.0 { + m.line_spacing_factor + } else { + 1.0 + }; + + // DWG stores the text direction as a vector when it has one. + let rotation = match m.x_direction { + Some(d) if d.x != 0.0 || d.y != 0.0 => d.y.atan2(d.x), + _ => m.rotation, + }; + let (h_align, dy0) = text::mtext_layout(m.attachment as u8, lines.len(), height, spacing); + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + m.insertion.x + along * cos_r - up * sin_r, + m.insertion.y + along * sin_r + up * cos_r, + ] + }; + + // The box lines align in and the mask covers: the entity's width, + // else its widest line. + let realigned = lines + .iter() + .any(|l| l.paragraph.align.is_some_and(|a| a != h_align)); + let masked = m.background_fill & 0x13 != 0; + let box_width = if m.reference_width > 0.0 { + m.reference_width + } else if realigned || masked { + lines + .iter() + .map(|l| self.mtext_line_layout(l, height, face, shape.0).1) + .fold(0.0, f64::max) + } else { + 0.0 + }; + let left = match h_align { + text::HAlign::Center | text::HAlign::Middle => -box_width / 2.0, + text::HAlign::Right => -box_width, + _ => 0.0, + }; + + if masked { + // A border of (scale - 1) heights around the lines, from the + // first line's capitals to the last line's baseline. + let scale = if m.background_scale > 0.0 { + m.background_scale + } else { + 1.5 + }; + let border = height * (scale - 1.0).max(0.0); + let top = dy0 + height + border; + let bottom = dy0 - (lines.len().max(1) - 1) as f64 * spacing - border; + let corners: Vec<[f64; 2]> = [ + (left - border, bottom), + (left + box_width + border, bottom), + (left + box_width + border, top), + (left - border, top), + ] + .iter() + .map(|&(a, b)| { + let p = at(a, b); + ctx.xf.apply(p[0], p[1]) + }) + .collect(); + self.mtext_background(m, &corners, rgba, attr); + // The text over its own mask: one step later in draw order. + self.scene.order = self.scene.order.saturating_add(1); + if m.background_fill & 0x10 != 0 { + self.scene.push_polyline(&corners, true, rgba, attr); + } + } + + for (i, line) in lines.iter().enumerate() { + if line.spans.is_empty() { + continue; + } + // Lines stack along the text's own down direction. + let dy = dy0 - i as f64 * spacing; + let (align, along) = match line.paragraph.align { + Some(a) if a != h_align && box_width > 0.0 => ( + a, + left + match a { + text::HAlign::Center => box_width / 2.0, + text::HAlign::Right => box_width, + _ => 0.0, + }, + ), + _ => (h_align, 0.0), + }; + let p = at(along, dy); + self.mtext_line( + line, height, p, rotation, align, shape, ctx, rgba, attr, face, + ); + } + } + + /// An MTEXT's background: its colour (group 90 bit 1) or the drawing + /// area's (bit 2), as a WIPEOUT paints it. + fn mtext_background( + &mut self, + m: &crate::cad::MText, + corners: &[[f64; 2]], + rgba: u32, + attr: u32, + ) { + let base = attr & 0x00FF_FFFF; + let colour = (m.background_fill & 2 == 0) + .then(|| colour_rgb(&m.background_color, self.palette)) + .flatten(); + let (fill, fill_attr) = match colour { + Some((r, g, b)) => (pack_rgba(r, g, b, 255), base), + None => (rgba, base | u32::from(FILL_FLAG_BACKGROUND) << 24), + }; + for t in fill::fill_even_odd(&[corners.to_vec()]).chunks(3) { + if t.len() == 3 { + self.scene.push_triangle(t[0], t[1], t[2], fill, fill_attr); + } + } + } + + /// Where each run of an MTEXT line goes, from the line's left edge: the + /// runs as (span, text, start, width), and the line's width. A tab stop + /// is measured from that edge, so alignment comes after. + fn mtext_line_layout( + &self, + line: &text::Line, + base_height: f64, + face: font::Face, + base_width: f64, + ) -> (Vec<(usize, String, f64, f64)>, f64) { + let mut placed = Vec::new(); + // The paragraph's indents; a first line can be indented differently + // from the rest, which is what makes a numbered list hang. + let mut cursor = line.paragraph.indent + + if line.first { + line.paragraph.first_indent + } else { + 0.0 + }; + for (i, span) in line.spans.iter().enumerate() { + let height = span_height(span, base_height); + let w = span.width.unwrap_or(base_width); + // Each run is measured in its own face, since a `\f` in the + // middle of a line changes how wide the rest of it sits. + let metrics = font::Metrics::for_face(span.face.unwrap_or(face)); + if let Some(stack) = &span.stack { + let width = stack_width(stack, &metrics, height, w); + placed.push((i, String::new(), cursor, width)); + cursor += width; + continue; + } + for (n, piece) in span.text.split('\t').enumerate() { + if n > 0 { + cursor = next_tab_stop(cursor, &line.paragraph, base_height); + } + if piece.is_empty() { + continue; + } + let width = metrics.measure(piece) * height * w * span.tracking.unwrap_or(1.0); + placed.push((i, piece.to_string(), cursor, width)); + cursor += width; + } + } + (placed, cursor) + } + + /// Draw one laid-out MTEXT line, honouring per-run colour, height, width + /// factor, slant, tracking, stacks and rules. /// - /// A line of one run is handed to the renderer to align, which measures the - /// string with the real font. A line of several has to be placed here, - /// because each run starts where the one before it ended. + /// A line of plain text is handed to the renderer to align, which + /// measures the string with the real font. Any other has to be placed + /// here, because each run starts where the one before it ended. #[allow(clippy::too_many_arguments)] - fn push_mtext_line( + fn mtext_line( &mut self, line: &text::Line, base_height: f64, - x: f64, - y: f64, + [x, y]: [f64; 2], rotation: f64, h_align: text::HAlign, + (base_width, base_oblique): (f64, f64), ctx: &Ctx, rgba: u32, attr: u32, face: font::Face, ) { let spans = &line.spans; - let height_of = |s: &text::Span| match s.height { - text::SpanHeight::Factor(f) if f > 0.0 => base_height * f, - text::SpanHeight::Absolute(h) if h > 0.0 => h, - _ => base_height, + let plain = |s: &text::Span| { + s.color.is_none() + && !s.underline + && !s.overline + && !s.strike + && s.width.is_none() + && s.oblique.is_none() + && s.tracking.is_none() + && s.stack.is_none() + && !s.text.contains('\t') }; - // A `\f` run draws in the face it names; the rest take the entity's. - let face_of = |s: &text::Span| s.face.unwrap_or(face); - - // Where the line starts, from its paragraph's indents. The first line - // of a paragraph can be indented differently from the rest, which is - // what makes a numbered list hang. - let indent = line.paragraph.indent - + if line.first { - line.paragraph.first_indent - } else { - 0.0 - }; - let tabbed = spans.iter().any(|s| s.text.contains('\t')); - - let styled = tabbed - || indent != 0.0 - || spans.iter().any(|s| { - s.color.is_some() - || s.face.is_some_and(|f| f != face) - || s.underline - || s.overline - || s.strike - || height_of(s) != base_height - }); + let indented = + line.paragraph.indent != 0.0 || line.first && line.paragraph.first_indent != 0.0; + let first = &spans[0]; + let one_style = spans.iter().all(|s| { + s.face.unwrap_or(face) == first.face.unwrap_or(face) + && span_height(s, base_height) == span_height(first, base_height) + }); // Nothing to place: hand the whole line over as one run. - if !styled - || spans.len() == 1 - && !spans[0].underline - && !spans[0].overline - && !spans[0].strike - && spans[0].color.is_none() - { + if !indented && one_style && spans.iter().all(plain) { let text: String = spans.iter().map(|s| s.text.as_str()).collect(); - let height = spans.first().map(height_of).unwrap_or(base_height); - let face = spans.first().map(face_of).unwrap_or(face); self.push_text_run( text, x, y, - height, - rotation, - 1.0, - 0.0, + text::frame( + span_height(first, base_height), + rotation, + base_width, + base_oblique, + ), h_align, text::VAlign::Baseline, ctx, rgba, attr, - face, + first.face.unwrap_or(face), ); return; } - // Lay the line out from its own left edge first. A tab stop is measured - // from there, not from the anchor, so alignment can only be applied - // once the whole line has been placed. - let mut placed: Vec<(usize, String, f64, f64)> = Vec::new(); - let mut cursor = indent; - for (i, span) in spans.iter().enumerate() { - let height = height_of(span); - for (n, piece) in span.text.split('\t').enumerate() { - if n > 0 { - cursor = next_tab_stop(cursor, &line.paragraph, base_height); - } - if piece.is_empty() { - continue; - } - // Each run is measured in its own face, since a `\f` in the - // middle of a line changes how wide the rest of it sits. - let width = font::Metrics::for_face(face_of(span)).measure(piece) * height; - placed.push((i, piece.to_string(), cursor, width)); - cursor += width; - } - } - + let (placed, total) = self.mtext_line_layout(line, base_height, face, base_width); let offset = match h_align { - text::HAlign::Center | text::HAlign::Middle => -cursor / 2.0, - text::HAlign::Right => -cursor, + text::HAlign::Center | text::HAlign::Middle => -total / 2.0, + text::HAlign::Right => -total, _ => 0.0, }; let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + x + along * cos_r - up * sin_r, + y + along * sin_r + up * cos_r, + ] + }; for (i, piece, start, width) in placed { let span = &spans[i]; let along = start + offset; - let width = &width; - let height = height_of(span); + let height = span_height(span, base_height); let (rgba, attr) = match span.color { Some(c) => self.span_colour(c, rgba, attr), None => (rgba, attr), }; - self.push_text_run( - piece, - x + along * cos_r, - y + along * sin_r, - height, - rotation, - 1.0, - 0.0, - text::HAlign::Left, - text::VAlign::Baseline, - ctx, - rgba, - attr, - face_of(span), - ); + let face = span.face.unwrap_or(face); + let w = span.width.unwrap_or(base_width); + let frame = text::frame(height, rotation, w, span.oblique.unwrap_or(base_oblique)); + if let Some(stack) = &span.stack { + let shape = (w, span.oblique.unwrap_or(base_oblique)); + self.mtext_stack( + stack, + at(along, 0.0), + rotation, + height, + shape, + width, + ctx, + rgba, + attr, + face, + ); + } else if let Some(t) = span.tracking.filter(|t| *t != 1.0) { + // Each character at its advance times the tracking. + let metrics = font::Metrics::for_face(face); + let mut pen = along; + for c in piece.chars() { + let p = at(pen, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run( + c.to_string(), + p[0], + p[1], + frame, + l, + b, + ctx, + rgba, + attr, + face, + ); + pen += metrics.advance(c) * height * w * t; + } + } else { + let p = at(along, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(piece, p[0], p[1], frame, l, b, ctx, rgba, attr, face); + } // The three rules, as strokes along the run at the usual offsets. for (on, dy) in [ @@ -897,19 +1409,76 @@ impl<'a> Tessellator<'a> { if !on { continue; } - let a = ctx.xf.apply( - x + along * cos_r - dy * sin_r, - y + along * sin_r + dy * cos_r, - ); - let b = ctx.xf.apply( - x + (along + width) * cos_r - dy * sin_r, - y + (along + width) * sin_r + dy * cos_r, - ); + let (a, b) = (at(along, dy), at(along + width, dy)); + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); } } } + /// A stack on the baseline at `p`, `width` wide (`stack_width`): its + /// parts at [`STACK_SIZE`] of the height, one above the other about the + /// capitals' middle (across a bar for a fraction, left-aligned for a + /// tolerance), or side by side across a slash. + #[allow(clippy::too_many_arguments)] + fn mtext_stack( + &mut self, + s: &text::Stack, + p: [f64; 2], + rotation: f64, + height: f64, + (w, oblique): (f64, f64), + width: f64, + ctx: &Ctx, + rgba: u32, + attr: u32, + face: font::Face, + ) { + let part = height * STACK_SIZE; + let metrics = font::Metrics::for_face(face); + let wide = |t: &str| metrics.measure(t) * part * w; + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + p[0] + along * cos_r - up * sin_r, + p[1] + along * sin_r + up * cos_r, + ] + }; + let frame = text::frame(part, rotation, w, oblique); + // Where each part's baseline starts, and the bar or slash. + let (upper, lower, line) = match s.kind { + text::StackKind::Diagonal => { + let slant = 0.5 * part; + let wu = wide(&s.upper); + ( + (0.0, height - part), + (wu + slant, 0.0), + Some(((wu, 0.0), (wu + slant, height))), + ) + } + kind => { + let (bar, gap) = (0.5 * height, 0.2 * part); + let centre = kind == text::StackKind::Fraction; + let x = |t: &str| if centre { (width - wide(t)) / 2.0 } else { 0.0 }; + ( + (x(&s.upper), bar + gap), + (x(&s.lower), bar - gap - part), + centre.then_some(((0.0, bar), (width, bar))), + ) + } + }; + for (t, (along, up)) in [(&s.upper, upper), (&s.lower, lower)] { + let q = at(along, up); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(t.clone(), q[0], q[1], frame, l, b, ctx, rgba, attr, face); + } + if let Some(((a0, a1), (b0, b1))) = line { + let (a, b) = (at(a0, a1), at(b0, b1)); + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + } + /// Resolve an MTEXT colour escape against the entity's own colour. fn span_colour(&self, c: text::SpanColor, rgba: u32, attr: u32) -> (u32, u32) { let alpha = (rgba >> 24) as u8; @@ -970,8 +1539,17 @@ impl<'a> Tessellator<'a> { && limits[2] > limits[0] && limits[3] > limits[1]) .then_some(limits), + // Group 72: 0 inches, 1 millimetres, 2 pixels (no physical + // size, taken as millimetres). + mm_per_unit: if l.plot.paper_units == 0 { 25.4 } else { 1.0 }, }); } + // Without LAYOUT objects, the sheet is in the drawing's own system. + let mm_per_unit = if self.doc.header.measurement == 0 { + 25.4 + } else { + 1.0 + }; // Some drawings carry no LAYOUT objects at all (R13 and R14 DWG // saved by AutoCAD before layouts): model space still draws, and so @@ -984,6 +1562,7 @@ impl<'a> Tessellator<'a> { is_model: true, tab_order: 0, limits: None, + mm_per_unit, }); } let paper = self @@ -1002,6 +1581,7 @@ impl<'a> Tessellator<'a> { && limits[2] > limits[0] && limits[3] > limits[1]) .then_some(limits), + mm_per_unit, }); } out.sort_by_key(|l| (!l.is_model, l.tab_order)); @@ -1018,10 +1598,16 @@ impl<'a> Tessellator<'a> { return false; }; let block = layout.block.clone(); + if !layout.is_model { + self.sheet.mm_per_unit = layout.mm_per_unit; + } self.run_block(&block, !layout.is_model); // The sheet, not the contents: see `Scene::set_extents`. if let Some(l) = layout.limits.filter(|_| !layout.is_model) { self.scene.set_extents([l[0], l[1]], [l[2], l[3]]); + if l[2] > l[0] && l[3] > l[1] { + self.sheet.paper = Some(l); + } } true } @@ -1071,6 +1657,7 @@ impl<'a> Tessellator<'a> { if paper { self.scene.recompute_extents(); } + self.scene.focus_on_core(); } fn root_ctx(&self) -> Ctx { @@ -1292,19 +1879,21 @@ impl<'a> Tessellator<'a> { // Colour 7 flips with the background; every other colour, including a // true colour that happens to be white, is drawn as stored. - let (rgba, contrast) = match common.color { - Color::ByLayer => (layer.rgba, layer.contrast), - Color::ByBlock => (ctx.rgba, ctx.contrast), + let (rgb, contrast) = match common.color { + Color::ByLayer => (layer.rgba & 0x00ff_ffff, layer.contrast), + Color::ByBlock => (ctx.rgba & 0x00ff_ffff, ctx.contrast), ref c => { let (r, g, b) = colour_rgb(c, self.palette)?; - let alpha = match common.transparency { - Transparency::Alpha(a) => a, - Transparency::ByBlock => (ctx.rgba >> 24) as u8, - Transparency::ByLayer => (layer.rgba >> 24) as u8, - }; - (pack_rgba(r, g, b, alpha), is_contrast_color(c)) + (pack_rgba(r, g, b, 0), is_contrast_color(c)) } }; + // Transparency resolves on its own, whatever the colour does. + let alpha = match common.transparency { + Transparency::Alpha(a) => a, + Transparency::ByBlock => (ctx.rgba >> 24) as u8, + Transparency::ByLayer => (layer.rgba >> 24) as u8, + }; + let rgba = rgb | u32::from(alpha) << 24; let flags = if contrast { FLAG_CONTRAST } else { 0 }; let lw = match common.lineweight { @@ -1317,6 +1906,11 @@ impl<'a> Tessellator<'a> { } fn entity(&mut self, e: &Entity, outer: &Ctx) { + self.entity_geometry(e, outer); + self.scene.edge_fills(); + } + + fn entity_geometry(&mut self, e: &Entity, outer: &Ctx) { // Advance the draw order for every entity, drawn or not, so the // sequence matches the document's own. self.scene.order = self.scene.order.saturating_add(1); @@ -1361,19 +1955,22 @@ impl<'a> Tessellator<'a> { } EntityKind::Circle(c) => { - let pts = curves::flatten_circle(c.center.x, c.center.y, c.radius); - self.push_transformed(&pts, false, xf, rgba, attr, pattern); + let tau = std::f64::consts::TAU; + self.push_curve( + c.center.x, c.center.y, c.radius, 0.0, tau, xf, rgba, attr, pattern, + ); } EntityKind::Arc(a) => { - let pts = curves::flatten_arc( - a.center.x, - a.center.y, - a.radius, - a.start_angle, - a.end_angle, - ); - self.push_transformed(&pts, false, xf, rgba, attr, pattern); + // DWG arcs always run counter-clockwise from start to end. + let sweep = (a.end_angle - a.start_angle).rem_euclid(std::f64::consts::TAU); + let sweep = if sweep > 0.0 { + sweep + } else { + std::f64::consts::TAU + }; + let (c, r) = (a.center, a.radius); + self.push_curve(c.x, c.y, r, a.start_angle, sweep, xf, rgba, attr, pattern); } EntityKind::Ellipse(el) => { @@ -1390,25 +1987,49 @@ impl<'a> Tessellator<'a> { } EntityKind::LwPolyline(p) => { - let pts: Vec<([f64; 2], f64)> = p + // Group 43, the constant width, stands for every vertex's. + let wide: Vec = p .vertices .iter() - .map(|v| ([v.point.x, v.point.y], v.bulge)) + .map(|v| { + let (w0, w1) = if p.constant_width > 0.0 { + (p.constant_width, p.constant_width) + } else { + (v.start_width, v.end_width) + }; + WideVertex { + p: [v.point.x, v.point.y], + bulge: v.bulge, + start_width: w0, + end_width: w1, + } + }) .collect(); - let closed = p.is_closed(); - let flat = expand_bulges(&pts, closed); - self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + let pattern = pattern.map(|q| (q, p.flags & 128 != 0)); + self.polyline(&wide, p.is_closed(), xf, rgba, attr, pattern); } EntityKind::Polyline(p) if p.kind() == PolylineKind::Polyline2D => { - let pts: Vec<([f64; 2], f64)> = p + // A vertex without widths of its own takes the polyline's. + let wide: Vec = p .vertices .iter() - .map(|v| ([v.location.x, v.location.y], v.bulge)) + .map(|v| { + let (w0, w1) = if v.start_width == 0.0 && v.end_width == 0.0 { + (p.default_start_width, p.default_end_width) + } else { + (v.start_width, v.end_width) + }; + WideVertex { + p: [v.location.x, v.location.y], + bulge: v.bulge, + start_width: w0, + end_width: w1, + } + }) .collect(); - let closed = p.is_closed(); - let flat = expand_bulges(&pts, closed); - self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + let pattern = pattern.map(|q| (q, p.flags & 128 != 0)); + self.polyline(&wide, p.is_closed(), xf, rgba, attr, pattern); } EntityKind::Polyline(p) if p.kind() == PolylineKind::Polyline3D => { @@ -1425,11 +2046,7 @@ impl<'a> Tessellator<'a> { self.push_transformed(&pts, false, xf, rgba, attr, pattern); } - EntityKind::Point(p) => { - // Zero-length stroke; the renderer draws it as a round dot. - let a = xf.apply(p.location.x, p.location.y); - self.scene.push_stroke(a[0], a[1], a[0], a[1], rgba, attr); - } + EntityKind::Point(p) => self.point(p, xf, rgba, attr), EntityKind::Solid(s) | EntityKind::Trace(s) => { // SOLID and TRACE store their corners in Z order, so corners 3 @@ -1453,8 +2070,50 @@ impl<'a> Tessellator<'a> { xf.apply(c[2].x, c[2].y), xf.apply(c[3].x, c[3].y), ]; - // 3DFACE is an outline in a 2D view, not a filled region. - self.push_path(&q, true, rgba, attr, pattern); + // 3DFACE is an outline in a 2D view, not a filled region, + // without the edges its flags hide. + if f.invisible_edges & 0x0F == 0 { + self.push_path(&q, true, rgba, attr, pattern); + } else { + for i in 0..4 { + if f.invisible_edges & (1 << i) == 0 { + self.push_path(&[q[i], q[(i + 1) % 4]], false, rgba, attr, pattern); + } + } + } + } + + // Meshes in plan: their edges. + EntityKind::Mesh(m) => { + let mut seen = HashSet::new(); + for f in &m.faces { + for (k, &a) in f.iter().enumerate() { + let b = f[(k + 1) % f.len()]; + if !seen.insert((a.min(b), a.max(b))) { + continue; + } + let (pa, pb) = (m.vertices[a as usize], m.vertices[b as usize]); + self.push_transformed( + &[[pa.x, pa.y], [pb.x, pb.y]], + false, + xf, + rgba, + attr, + pattern, + ); + } + } + } + EntityKind::Tolerance(t) => self.tolerance(t, ctx, rgba, attr), + EntityKind::Polyline(p) if p.kind() == PolylineKind::PolygonMesh => { + for edge in polygon_mesh_edges(p) { + self.push_transformed(&edge, false, xf, rgba, attr, pattern); + } + } + EntityKind::Polyline(p) if p.kind() == PolylineKind::PolyfaceMesh => { + for edge in polyface_edges(p) { + self.push_transformed(&edge, false, xf, rgba, attr, pattern); + } } EntityKind::MLine(m) => { @@ -1521,6 +2180,12 @@ impl<'a> Tessellator<'a> { EntityKind::Insert(ins) => self.insert(ins, ctx, lid, rgba, contrast, lw, lt), + EntityKind::Dimension(d) + if d.block_name.is_empty() || self.block(&d.block_name).is_none() => + { + self.dimension(d, ctx, rgba, attr); + } + EntityKind::Dimension(d) => { // AutoCAD bakes dimension graphics into an anonymous block. // Render that instead of reimplementing the dimension engine. @@ -1555,49 +2220,23 @@ impl<'a> Tessellator<'a> { EntityKind::Text(t) => self.text(t, ctx, rgba, attr), - EntityKind::Attribute(a) => self.attribute_text(&a.text, ctx, rgba, attr), - - EntityKind::MText(m) => { - let height = if m.height > 0.0 { m.height } else { 1.0 }; - let face = self.style_face(&m.style); - let lines = self.wrap_mtext(&m.text, m.reference_width, height, face); - let spacing = height - * text::MTEXT_LINE_SPACING - * if m.line_spacing_factor > 0.0 { - m.line_spacing_factor - } else { - 1.0 - }; - - // DWG stores the text direction as a vector when it has one. - let rotation = match m.x_direction { - Some(d) if d.x != 0.0 || d.y != 0.0 => d.y.atan2(d.x), - _ => m.rotation, - }; - let (h_align, dy0) = - text::mtext_layout(m.attachment as u8, lines.len(), height, spacing); + // A multiline attribute (2018 on) is an MTEXT. + EntityKind::Attribute(a) => match &a.mtext { + Some(m) => self.mtext(m, ctx, rgba, attr), + None => self.attribute_text(&a.text, ctx, rgba, attr), + }, - let (sin_r, cos_r) = rotation.sin_cos(); - for (i, line) in lines.iter().enumerate() { - if line.spans.is_empty() { - continue; - } - // Lines stack along the text's own down direction. - let dy = dy0 - i as f64 * spacing; - let x = m.insertion.x - dy * sin_r; - let y = m.insertion.y + dy * cos_r; - self.push_mtext_line( - line, height, x, y, rotation, h_align, ctx, rgba, attr, face, - ); - } - } + EntityKind::MText(m) => self.mtext(m, ctx, rgba, attr), // An ordinary ATTDEF is the template for an ATTRIB carried by the // insert, so drawing it too would double the text. A *constant* // attribute has no ATTRIB: the definition is the only copy, and - // AutoCAD draws it. - EntityKind::AttributeDefinition(a) if a.is_constant() => { - self.attribute_text(&a.text, ctx, rgba, attr) + // AutoCAD draws it, unless it is invisible too. + EntityKind::AttributeDefinition(a) if a.is_constant() && !a.is_invisible() => { + match &a.mtext { + Some(m) => self.mtext(m, ctx, rgba, attr), + None => self.attribute_text(&a.text, ctx, rgba, attr), + } } EntityKind::AttributeDefinition(_) => {} @@ -1695,26 +2334,17 @@ impl<'a> Tessellator<'a> { } if l.arrowhead { - // A filled triangle at the first vertex, pointing back - // along the first segment, as long as the dimension - // style's arrow size (DIMASZ times DIMSCALE) and a third - // as wide: AutoCAD's default closed filled arrowhead. + // At the first vertex, pointing back along the first + // segment, the dimension style's arrow size (DIMASZ + // times DIMSCALE) long, in its leader arrow (DIMLDRBLK). let size = self.arrow_size(&l.style); - let d = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]]; - let len = d[0].hypot(d[1]); - if size > 0.0 && len > 1e-9 { - let u = [d[0] / len, d[1] / len]; - let n = [-u[1], u[0]]; - let base = [pts[0][0] + u[0] * size, pts[0][1] + u[1] * size]; - let half = size / 6.0; - self.scene.push_triangle( - xf.apply(pts[0][0], pts[0][1]), - xf.apply(base[0] + n[0] * half, base[1] + n[1] * half), - xf.apply(base[0] - n[0] * half, base[1] - n[1] * half), - rgba, - attr, - ); - } + let block = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&l.style)) + .map_or(Handle::NULL, |s| s.dimldrblk); + self.arrowhead(block, pts[0], pts[1], size, ctx, rgba, attr); } } @@ -1722,8 +2352,13 @@ impl<'a> Tessellator<'a> { // that can be drawn is the frame, which is what AutoCAD shows for // an image it cannot load, and the count so the host can say so. EntityKind::Image(img) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + let file = self + .doc + .image_defs + .iter() + .find(|d| d.handle == img.image_def); + self.warnings + .external(file.map_or("", |d| d.file_name.as_str())); let u = [img.u_vector.x, img.u_vector.y]; let v = [img.v_vector.x, img.v_vector.y]; let o = [img.insertion.x, img.insertion.y]; @@ -1744,8 +2379,13 @@ impl<'a> Tessellator<'a> { } EntityKind::Underlay(u) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + let file = self + .doc + .underlay_defs + .iter() + .find(|d| d.handle == u.definition); + self.warnings + .external(file.map_or("", |d| d.file_name.as_str())); if u.clip_vertices.len() >= 2 { let pts: Vec<[f64; 2]> = u.clip_vertices.iter().map(|p| [p.x, p.y]).collect(); // Two points are the corners of a rectangle, more are a path. @@ -1771,8 +2411,7 @@ impl<'a> Tessellator<'a> { } EntityKind::Ole2Frame(o) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + self.warnings.external(""); let (a, b) = (o.upper_left, o.lower_right); let frame = [[a.x, a.y], [b.x, a.y], [b.x, b.y], [a.x, b.y]]; self.push_transformed(&frame, true, xf, rgba, attr, pattern); @@ -1796,11 +2435,11 @@ impl<'a> Tessellator<'a> { self.warnings.proxy_without_graphics.saturating_add(1); } - // Meshes, SHAPE (a glyph of an SHX file, which cannot be shipped) - // and every other type the model does not hold. - EntityKind::Polyline(_) | EntityKind::Shape(_) | EntityKind::Unknown(_) => { - self.warnings.unknown_entities = self.warnings.unknown_entities.saturating_add(1); - } + // SHAPE (a glyph of an SHX file, which cannot be shipped) and + // every other type the model does not hold. + EntityKind::Polyline(_) => self.warnings.not_drawn("POLYLINE"), + EntityKind::Shape(_) => self.warnings.not_drawn("SHAPE"), + EntityKind::Unknown(u) => self.warnings.not_drawn(&u.type_name), } } @@ -1841,7 +2480,7 @@ impl<'a> Tessellator<'a> { } else { t.alignment_point.unwrap_or(t.insertion) }; - let value = text::decode_special(&t.value); + let value = self.style_text(&t.style, &t.value); // Aligned and Fit stretch the string between two points rather than // anchoring it at one, so they need its width. Aligned scales the @@ -1849,24 +2488,38 @@ impl<'a> Tessellator<'a> { // instead. let stretched = matches!(t.h_align, HAlign::Aligned | HAlign::Fit); let face = self.style_face(&t.style); + let width_factor = if t.width_factor > 0.0 { + t.width_factor + } else { + 1.0 + }; if stretched { if let Some(second) = t.alignment_point { let first = t.insertion; let span = (second.x - first.x).hypot(second.y - first.y); - let natural = font::Metrics::for_face(face).measure(&value) * t.height; + let natural = + font::Metrics::for_face(face).measure(&value) * t.height * width_factor; if span > 1e-9 && natural > 1e-9 { let factor = span / natural; let fit = t.h_align == HAlign::Fit; + let frame = text::generated_frame( + if fit { t.height } else { t.height * factor }, + (second.y - first.y).atan2(second.x - first.x), + if fit { + width_factor * factor + } else { + width_factor + }, + t.oblique, + t.generation, + ); self.push_text_run( value, first.x, first.y, - if fit { t.height } else { t.height * factor }, - (second.y - first.y).atan2(second.x - first.x), - if fit { factor } else { 1.0 }, - t.oblique, + frame, text::HAlign::Left, - v_align_of(t.v_align), + text::VAlign::Baseline, ctx, rgba, attr, @@ -1881,10 +2534,13 @@ impl<'a> Tessellator<'a> { value, anchor.x, anchor.y, - t.height, - t.rotation, - t.width_factor, - t.oblique, + text::generated_frame( + t.height, + t.rotation, + t.width_factor, + t.oblique, + t.generation, + ), h_align_of(t.h_align), v_align_of(t.v_align), ctx, @@ -1905,13 +2561,16 @@ impl<'a> Tessellator<'a> { }; let face = self.style_face(&t.style); self.push_text_run( - text::decode_special(&t.value), + self.style_text(&t.style, &t.value), anchor.x, anchor.y, - t.height, - t.rotation, - t.width_factor, - t.oblique, + text::generated_frame( + t.height, + t.rotation, + t.width_factor, + t.oblique, + t.generation, + ), h_align_of(t.h_align), v_align_of(t.v_align), ctx, @@ -1932,10 +2591,7 @@ impl<'a> Tessellator<'a> { value: String, x: f64, y: f64, - height: f64, - rotation: f64, - width_factor: f64, - oblique: f64, + (u, v): ([f64; 2], [f64; 2]), h_align: text::HAlign, v_align: text::VAlign, ctx: &Ctx, @@ -1946,22 +2602,30 @@ impl<'a> Tessellator<'a> { if value.trim().is_empty() { return; } + // The stroke font has no CJK and few symbols: a run with characters + // it lacks goes to an outline face, which the browser completes from + // the system's fonts where the bundle has none. + let stroke = super::stroke_font::newstroke(); + let face = if face == font::Face::Stroke + && value + .chars() + .any(|c| !c.is_whitespace() && stroke.glyph(c).is_none()) + { + font::Face::default() + } else { + face + }; let p = ctx.xf.apply(x, y); - // Blocks rotate and scale their contents, so the text follows. - let block_rotation = ctx.xf.b.atan2(ctx.xf.a); - let scale = ctx.xf.scale_magnitude(); + // Blocks rotate, scale and mirror their contents, so the text + // follows, its frame with them. + let xf = &ctx.xf; + let linear = |w: [f64; 2]| [xf.a * w[0] + xf.c * w[1], xf.b * w[0] + xf.d * w[1]]; self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); let run = text::TextRun { x: p[0], y: p[1], - height: if height > 0.0 { height * scale } else { scale }, - rotation: rotation + block_rotation, - width_factor: if width_factor > 0.0 { - width_factor - } else { - 1.0 - }, - oblique, + u: linear(u), + v: linear(v), rgba, attr, h_align, @@ -1969,6 +2633,7 @@ impl<'a> Tessellator<'a> { text: value, face, order: self.scene.order, + clip: 0, }; match face { font::Face::Stroke => self.push_stroke_text(&run), @@ -1985,19 +2650,14 @@ impl<'a> Tessellator<'a> { /// order, layer visibility and viewport clipping all come from `attr`. fn push_stroke_text(&mut self, run: &text::TextRun) { let stroke = super::stroke_font::newstroke(); - let height = run.height; - if !(height > 0.0) { + if !(run.height() > 0.0) { return; } - let advance_scale = height * run.width_factor; - let width: f64 = run - .text - .chars() - .map(|c| stroke.advance(c) as f64 * advance_scale) - .sum(); + // Everything is in cap heights until the frame places it. + let width: f64 = run.text.chars().map(|c| stroke.advance(c) as f64).sum(); - // Aligned and Fit stretch between two points the record does not carry, - // so they centre, as the host's path does. + // Aligned and Fit arrive stretched and left-aligned (`text`); here + // they centre, as the host's path does. let dx = match run.h_align { text::HAlign::Center | text::HAlign::Middle @@ -2006,17 +2666,12 @@ impl<'a> Tessellator<'a> { text::HAlign::Right => -width, text::HAlign::Left => 0.0, }; - let dy = match run.v_align { - text::VAlign::Middle => -height / 2.0, - text::VAlign::Top => -height, - text::VAlign::Baseline | text::VAlign::Bottom => 0.0, - }; - - let (sin_r, cos_r) = run.rotation.sin_cos(); - let tan_o = if run.oblique.abs() < std::f64::consts::FRAC_PI_2 { - run.oblique.tan() - } else { - 0.0 + let dy = text::baseline_offset(run.h_align, run.v_align, font::Face::Stroke); + let at = |a: f64, b: f64| { + [ + run.x + a * run.u[0] + b * run.v[0], + run.y + a * run.u[1] + b * run.v[1], + ] }; let mut pen = dx; @@ -2031,22 +2686,142 @@ impl<'a> Tessellator<'a> { for polyline in glyph.polylines() { points.clear(); for p in polyline.points() { - // Glyph units are cap heights, so the text height is - // the only scale there is. - let gx = pen + p[0] as f64 * advance_scale; - let gy = dy + p[1] as f64 * height; - // Oblique leans the vertical, shifting x by height. - let sx = gx + gy * tan_o; - points.push([ - run.x + sx * cos_r - gy * sin_r, - run.y + sx * sin_r + gy * cos_r, - ]); + points.push(at(pen + p[0] as f64, dy + p[1] as f64)); } self.scene.push_polyline(&points, false, run.rgba, run.attr); } } - pen += stroke.advance(c) as f64 * advance_scale; + pen += stroke.advance(c) as f64; + } + } + + /// An LWPOLYLINE or 2D POLYLINE: a line through its bulges, or, where its + /// segments have width, the band they fill (outlined under `$FILLMODE` + /// 0). The band is in object coordinates and transformed corner by + /// corner, so a scaled block widens it too. + /// + /// A dashed one carries whether its linetype runs on across its vertices + /// (group 70 bit 128, PLINEGEN); without it each segment starts its + /// pattern afresh, as AutoCAD draws it. + fn polyline( + &mut self, + verts: &[WideVertex], + closed: bool, + xf: &Affine, + rgba: u32, + attr: u32, + dashes: Option<(&Pattern, bool)>, + ) { + let pattern = dashes.map(|(p, _)| p); + if !verts + .iter() + .any(|v| v.start_width > 0.0 || v.end_width > 0.0) + { + let pts: Vec<([f64; 2], f64)> = verts.iter().map(|v| (v.p, v.bulge)).collect(); + if pattern.is_none() + && pts.iter().any(|p| p.1 != 0.0) + && self.push_bulged(&pts, closed, xf, rgba, attr) + { + return; + } + if let Some((p, false)) = dashes { + let segments = if closed { + pts.len() + } else { + pts.len().saturating_sub(1) + }; + for i in 0..segments { + let piece = expand_bulges(&[pts[i], pts[(i + 1) % pts.len()]], false); + self.push_transformed(&piece, false, xf, rgba, attr, Some(p)); + } + return; + } + let flat = expand_bulges(&pts, closed); + self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + return; + } + let band = wide::band(verts, closed); + for run in &band.thin { + self.push_transformed(run, false, xf, rgba, attr, pattern); + } + if self.doc.header.fillmode { + for [a, b, c] in &band.triangles { + self.scene.push_triangle( + xf.apply(a[0], a[1]), + xf.apply(b[0], b[1]), + xf.apply(c[0], c[1]), + rgba, + attr, + ); + } + } else { + for outline in &band.outlines { + self.push_transformed(outline, true, xf, rgba, attr, None); + } + } + } + + /// A circular arc in object coordinates (centre, radius, start angle, + /// signed sweep): arc strokes, exact at any zoom, when nothing dashes it + /// and the transform keeps circles round; else its flattened points. + #[allow(clippy::too_many_arguments)] + fn push_curve( + &mut self, + cx: f64, + cy: f64, + r: f64, + start: f64, + sweep: f64, + xf: &Affine, + rgba: u32, + attr: u32, + pattern: Option<&Pattern>, + ) { + if pattern.is_none() { + if let Some((scale, turn, mirrored)) = similarity(xf) { + let c = xf.apply(cx, cy); + let (start, sweep) = if mirrored { + (turn - start, -sweep) + } else { + (start + turn, sweep) + }; + self.scene + .push_arc(c[0], c[1], r * scale, start, sweep, rgba, attr); + return; + } + } + let pts = curves::arc_points(cx, cy, r, start, sweep); + self.push_transformed(&pts, false, xf, rgba, attr, pattern); + } + + /// A polyline's straight and bulged segments, the bulged ones as arc + /// strokes, under a transform that keeps circles round; false, drawing + /// nothing, under any other. + fn push_bulged( + &mut self, + pts: &[([f64; 2], f64)], + closed: bool, + xf: &Affine, + rgba: u32, + attr: u32, + ) -> bool { + let Some((_, _, mirrored)) = similarity(xf) else { + return false; + }; + let n = pts.len(); + let segments = if closed { n } else { n.saturating_sub(1) }; + for i in 0..segments { + let ((a, bulge), (b, _)) = (pts[i], pts[(i + 1) % n]); + let (wa, wb) = (xf.apply(a[0], a[1]), xf.apply(b[0], b[1])); + let bulge = if mirrored { -bulge } else { bulge }; + if bulge == 0.0 || !bulge.is_finite() { + self.scene + .push_stroke(wa[0], wa[1], wb[0], wb[1], rgba, attr); + } else if let Some((cx, cy, r, start, sweep)) = curves::bulge_arc(wa, wb, bulge) { + self.scene.push_arc(cx, cy, r, start, sweep, rgba, attr); + } } + true } fn push_transformed( @@ -2089,7 +2864,8 @@ impl<'a> Tessellator<'a> { self.scene.push_polyline(pts, closed, rgba, attr); } - /// Stamp a complex linetype's text along a path it has just dashed. + /// Stamp a complex linetype's text and shapes along a path it has just + /// dashed. fn push_line_labels( &mut self, pts: &[[f64; 2]], @@ -2103,29 +2879,306 @@ impl<'a> Tessellator<'a> { } for place in linetype::dash_labels(pts, closed, pattern) { let label = &pattern.labels[place.label]; - let run = text::TextRun { - x: place.x, - y: place.y, - height: label.height, - rotation: place.angle, - width_factor: 1.0, - oblique: 0.0, + match &label.mark { + linetype::Mark::Text { text, face } => { + let (u, v) = text::frame(label.height, place.angle, 1.0, 0.0); + let run = text::TextRun { + x: place.x, + y: place.y, + u, + v, + rgba, + attr, + h_align: text::HAlign::Left, + v_align: text::VAlign::Baseline, + text: text.clone(), + face: *face, + order: self.scene.order, + clip: 0, + }; + self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); + match face { + font::Face::Stroke => self.push_stroke_text(&run), + font::Face::TrueType(_) => self.scene.push_text(run), + } + } + linetype::Mark::Shape(parts) => { + let (sin, cos) = place.angle.sin_cos(); + let k = label.height; + let at = |p: [f64; 2]| { + [ + place.x + k * (p[0] * cos - p[1] * sin), + place.y + k * (p[0] * sin + p[1] * cos), + ] + }; + for part in *parts { + match part { + linetype::ShapePart::Line(ps) => { + for w in ps.windows(2) { + let (a, b) = (at(w[0]), at(w[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + } + linetype::ShapePart::Arc(c, r, start, sweep) => { + let c = at(*c); + self.scene.push_arc( + c[0], + c[1], + r * k, + start + place.angle, + *sweep, + rgba, + attr, + ); + } + } + } + } + } + } + } + + /// An arrowhead `size` long at `tip`, pointing away from `from`: the + /// arrow block `block` names, drawn from the drawing's block of that + /// name when it has one (AutoCAD saves `_Open` and the like there), else + /// the built-in shape of that name, else the closed filled default. + #[allow(clippy::too_many_arguments)] + fn arrowhead( + &mut self, + block: Handle, + tip: [f64; 2], + from: [f64; 2], + size: f64, + ctx: &Ctx, + rgba: u32, + attr: u32, + ) { + let d = [tip[0] - from[0], tip[1] - from[1]]; + if !(size > 0.0) || !(d[0].hypot(d[1]) > 1e-9) { + return; + } + let angle = d[1].atan2(d[0]); + let place = ctx.xf.mul(&Affine::new(tip[0], tip[1], size, size, angle)); + let name = (!block.is_null()) + .then(|| self.doc.blocks.iter().find(|b| b.record == block)) + .flatten(); + if let Some(b) = name.filter(|b| !b.entities.is_empty()) { + let (lid, lw) = (attr as u16, (attr >> 16) as u8); + let contrast = (attr >> 24) as u8 & FLAG_CONTRAST != 0; + let name = b.name.clone(); + self.expand_block( + &name, + &place, + lid, rgba, - attr, - h_align: text::HAlign::Left, - v_align: text::VAlign::Baseline, - text: label.text.clone(), - face: label.face, - order: self.scene.order, + contrast, + lw, + 0, + ctx.lt_scale, + ctx.depth, + ); + return; + } + let kind = name.map_or(String::new(), |b| b.name.to_ascii_uppercase()); + let shape = arrow_shape(&kind); + let world = |p: &[f64; 2]| place.apply(p[0], p[1]); + for t in shape.filled.chunks(3) { + if let [a, b, c] = t { + self.scene + .push_triangle(world(a), world(b), world(c), rgba, attr); + } + } + for line in shape.lines { + let pts: Vec<[f64; 2]> = line.iter().map(world).collect(); + self.scene.push_polyline(&pts, false, rgba, attr); + } + } + + /// A dimension saved without its block, drawn from its definition points + /// and its style (`dimension.rs`). A style the drawing does not have + /// falls back to the header's variables. + fn dimension(&mut self, d: &crate::cad::Dimension, ctx: &Ctx, rgba: u32, attr: u32) { + let h = &self.doc.header; + let style = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&d.style)) + .cloned() + .unwrap_or_else(|| crate::cad::DimStyle { + dimscale: h.dimscale, + dimasz: h.dimasz, + dimtxt: h.dimtxt, + dimgap: h.dimgap, + ..crate::cad::DimStyle::default() + }); + let text_style = self + .doc + .text_styles + .iter() + .find(|ts| ts.handle == style.dimtxsty && !style.dimtxsty.is_null()) + .map(|ts| ts.name.clone()) + .unwrap_or_default(); + let face = self.style_face(&text_style); + let metrics = font::Metrics::for_face(face); + let s = dimension::Style::new(&style); + let measure = |t: &str| metrics.measure(&text::decode_special(t)); + let Some(g) = dimension::graphics(d, &s, &measure) else { + return; + }; + for [a, b] in &g.lines { + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + for (c, r, start, sweep) in &g.arcs { + self.push_curve(c[0], c[1], *r, *start, *sweep, &ctx.xf, rgba, attr, None); + } + for (tip, from) in &g.arrows { + self.arrowhead(style.dimblk, *tip, *from, s.asz, ctx, rgba, attr); + } + if let Some(label) = g.label { + let content = crate::cad::MText { + insertion: crate::cad::Vec3::new(label.at[0], label.at[1], 0.0), + height: s.txt, + attachment: 5, + style: text_style, + rotation: label.rotation, + text: label.text, + ..crate::cad::MText::default() }; - self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); - match label.face { - font::Face::Stroke => self.push_stroke_text(&run), - font::Face::TrueType(_) => self.scene.push_text(run), + self.mtext(&content, ctx, rgba, attr); + } + } + + /// A TOLERANCE's feature control frame: a row per `^J`, a box per + /// `%%v`, each its text and the style's gap around it, the GD&T codes as + /// the symbols they stand for; hung from the middle of the first row's + /// left side, along its direction. + fn tolerance(&mut self, t: &crate::cad::Tolerance, ctx: &Ctx, rgba: u32, attr: u32) { + let style = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&t.dimstyle)); + let header = &self.doc.header; + let (txt, gap, scale) = match style { + Some(s) => (s.dimtxt, s.dimgap, s.dimscale), + None => (header.dimtxt, header.dimgap, header.dimscale), + }; + let scale = if scale > 0.0 && scale.is_finite() { + scale + } else { + 1.0 + }; + let height = if txt > 0.0 { txt * scale } else { 0.18 }; + let gap = gap.abs() * scale; + let face = style + .and_then(|s| { + self.doc + .text_styles + .iter() + .find(|ts| ts.handle == s.dimtxsty && !s.dimtxsty.is_null()) + }) + .map_or(font::Face::Stroke, |ts| self.style_face(&ts.name)); + let metrics = font::Metrics::for_face(face); + let rotation = t.x_direction.y.atan2(t.x_direction.x); + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |a: f64, b: f64| { + ctx.xf.apply( + t.insertion.x + a * cos_r - b * sin_r, + t.insertion.y + a * sin_r + b * cos_r, + ) + }; + let row_height = height + 2.0 * gap; + for (r, row) in t.text.split("^J").enumerate() { + // The first row's middle is on the insertion point. + let top = row_height / 2.0 - r as f64 * row_height; + let mut left = 0.0; + for cell in row.split("%%v") { + let shown = text::decode_special(&gdt_codes(cell)); + if shown.is_empty() { + continue; + } + let width = metrics.measure(&shown) * height + 2.0 * gap; + let corners = [ + (left, top), + (left + width, top), + (left + width, top - row_height), + (left, top - row_height), + ] + .map(|(a, b)| at(a, b)); + self.scene.push_polyline(&corners, true, rgba, attr); + let (x, y) = ( + t.insertion.x + (left + gap) * cos_r - (top - gap - height) * sin_r, + t.insertion.y + (left + gap) * sin_r + (top - gap - height) * cos_r, + ); + let frame = text::frame(height, rotation, 1.0, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(shown, x, y, frame, l, b, ctx, rgba, attr, face); + left += width; } } } + /// A POINT in the drawing's `$PDMODE`: a dot (0), nothing (1), a plus + /// (2), a cross (3), a tick up (4), and around it a circle (+32) or a + /// square (+64), `$PDSIZE` across: units when above 0, else a share of + /// the drawing's height (5% at 0, -n for n%), as AutoCAD takes the + /// window's. + fn point(&mut self, p: &crate::cad::Point, xf: &Affine, rgba: u32, attr: u32) { + let h = &self.doc.header; + let mode = h.pdmode; + let at = xf.apply(p.location.x, p.location.y); + if mode & 7 == 0 { + // A zero-length stroke, which the renderer draws as a round dot. + self.scene + .push_stroke(at[0], at[1], at[0], at[1], rgba, attr); + } + if mode & !(7 | 32 | 64) != 0 || matches!(mode & 7, 0 | 1) && mode & 96 == 0 { + return; + } + let tall = h.extmax.y - h.extmin.y; + let tall = if tall.is_finite() && tall > 0.0 { + tall + } else { + 20.0 + }; + let size = match h.pdsize { + s if s > 0.0 => s, + s if s < 0.0 => tall * -s / 100.0, + _ => tall * 0.05, + }; + let r = size / 2.0; + let (sin_a, cos_a) = p.x_axis_angle.sin_cos(); + let local = |x: f64, y: f64| { + xf.apply( + p.location.x + x * cos_a - y * sin_a, + p.location.y + x * sin_a + y * cos_a, + ) + }; + let segments: &[[(f64, f64); 2]] = match mode & 7 { + 2 => &[[(-r, 0.0), (r, 0.0)], [(0.0, -r), (0.0, r)]], + 3 => &[[(-r, -r), (r, r)], [(-r, r), (r, -r)]], + 4 => &[[(0.0, 0.0), (0.0, r)]], + _ => &[], + }; + for [(x0, y0), (x1, y1)] in segments { + let (a, b) = (local(*x0, *y0), local(*x1, *y1)); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + if mode & 32 != 0 { + let ring: Vec<[f64; 2]> = curves::flatten_circle(0.0, 0.0, r) + .into_iter() + .map(|q| local(q[0], q[1])) + .collect(); + self.scene.push_polyline(&ring, true, rgba, attr); + } + if mode & 64 != 0 { + let square = [(-r, -r), (r, -r), (r, r), (-r, r)].map(|(x, y)| local(x, y)); + self.scene.push_polyline(&square, true, rgba, attr); + } + } + #[allow(clippy::too_many_arguments)] fn insert( &mut self, @@ -2168,9 +3221,12 @@ impl<'a> Tessellator<'a> { ins.insertion.y + dx * sin_r + dy * cos_r, ]; let child = Affine::new(p[0], p[1], sx, sy, ins.rotation); + let xf = ctx.xf.mul(&child); + let mark = self.scene.mark(); + let infinite = self.pending_infinite.len(); self.expand_block( &ins.block_name, - &ctx.xf.mul(&child), + &xf, lid, rgba, contrast, @@ -2179,6 +3235,22 @@ impl<'a> Tessellator<'a> { ctx.lt_scale, ctx.depth, ); + if let Some(outline) = self.xclip_outline(ins, &xf) { + if let Some(shape) = ClipShape::polygon(outline.clone()) { + // The block's rays and xlines end at the boundary rather + // than at the drawing's extents. + let (min, max) = shape.bounds(); + for line in self.pending_infinite.split_off(infinite) { + self.push_infinite(&line, min, max); + } + self.scene.clip_since(mark, &shape); + } + if self.doc.header.xclipframe != 0 { + let flags = if contrast { FLAG_CONTRAST } else { 0 }; + let attr = pack_attr(lid, lw, flags); + self.push_path(&outline, true, rgba, attr, None); + } + } } // The insert's attributes (title-block fields, room tags) are not in @@ -2203,6 +3275,29 @@ impl<'a> Tessellator<'a> { } } + /// An insert's XCLIP boundary in world coordinates, when clipping is on: + /// it is in block coordinates, so the block's base point comes off as + /// for its entities. Two points are the corners of a rectangle. + fn xclip_outline(&self, ins: &crate::cad::Insert, xf: &Affine) -> Option> { + let clip = ins.clip.as_ref().filter(|c| c.enabled)?; + let base = self.block(&ins.block_name)?.base_point; + let local: Vec<[f64; 2]> = match clip.boundary.as_slice() { + [a, b] => { + let (x0, x1) = (a.x.min(b.x), a.x.max(b.x)); + let (y0, y1) = (a.y.min(b.y), a.y.max(b.y)); + vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]] + } + ps if ps.len() >= 3 => ps.iter().map(|p| [p.x, p.y]).collect(), + _ => return None, + }; + Some( + local + .iter() + .map(|p| xf.apply(p[0] - base.x, p[1] - base.y)) + .collect(), + ) + } + #[allow(clippy::too_many_arguments)] fn expand_block( &mut self, @@ -2235,6 +3330,43 @@ impl<'a> Tessellator<'a> { return; }; + // An xref's content is in another file, which this viewer was not + // given: its path is drawn at the insertion point, as AutoCAD shows + // an xref it cannot find. + if block.is_xref() && block.entities.is_empty() { + let file = if block.xref_path.is_empty() { + &block.name + } else { + &block.xref_path + }; + self.warnings.external(file); + let at = Ctx { + xf: *xf, + layer: lid, + rgba, + contrast, + lw, + lt, + lt_scale, + depth, + }; + let flags = if contrast { FLAG_CONTRAST } else { 0 }; + let face = self.style_face(&self.doc.header.textstyle); + self.push_text_run( + file.clone(), + 0.0, + 0.0, + text::frame(self.doc.header.textsize, 0.0, 1.0, 0.0), + text::HAlign::Left, + text::VAlign::Baseline, + &at, + rgba, + pack_attr(lid, lw, flags), + face, + ); + return; + } + // Block geometry is defined relative to the block's base point. let base = block.base_point; let local = xf.mul(&Affine { @@ -2463,6 +3595,17 @@ impl<'a> Tessellator<'a> { if truncated { self.warnings.hatch_patterns_truncated = self.warnings.hatch_patterns_truncated.saturating_add(1); + // Too dense to draw line by line: the region in the tone so many + // lines average out to, rather than some of them and a gap. + let alpha = ((rgba >> 24) as f64 * TRUNCATED_PATTERN_TONE).round() as u32; + let tone = (rgba & 0x00FF_FFFF) | (alpha << 24); + for t in fill::fill_region(&loops, island_style(h)).chunks(3) { + if t.len() == 3 { + let [a, b, c] = [t[0], t[1], t[2]].map(|p| ctx.xf.apply(p[0], p[1])); + self.scene.push_triangle(a, b, c, tone, attr); + } + } + return; } // Record the pattern's spacing so the renderer can fade these lines @@ -2537,12 +3680,16 @@ fn spline_points(s: &crate::cad::Spline) -> Vec<[f64; 2]> { ) } +/// What "Default" stands for: AutoCAD's `LWDEFAULT`, 0.25 mm unless a user +/// changed it, which is a setting of their AutoCAD, not of the drawing. +const DEFAULT_LINEWEIGHT: u8 = 25; + /// Resolve a lineweight to hundredths of a millimetre, 0 meaning hairline. fn lineweight_value(lw: LineWeight, inherited: u8) -> u8 { match lw { LineWeight::Value(v) if v > 0 => v.min(211) as u8, LineWeight::Value(_) => 0, - LineWeight::Default => 0, + LineWeight::Default => DEFAULT_LINEWEIGHT, LineWeight::ByLayer | LineWeight::ByBlock => inherited, } } @@ -2650,8 +3797,11 @@ mod tests { #[test] fn a_minsert_asking_past_the_budget_is_drawn_up_to_it() { - // 160,000 rings of about seventy strokes: eleven million asked for. - let doc = minsert(400, 400); + // 810,000 cells of three rings of four arc strokes: 9.7 million + // asked for, under the cell cap. + let mut doc = minsert(900, 900); + let ring = doc.blocks[1].entities[0].clone(); + doc.blocks[1].entities.extend([ring.clone(), ring]); let mut t = Tessellator::new(&doc); t.run(); // Stopped between entities: over by one circle at most, and not short. @@ -2715,6 +3865,80 @@ mod tests { t.scene } + fn fill_area(s: &Scene) -> f64 { + s.fills + .chunks(3) + .map(|t| { + ((t[1].x - t[0].x) * (t[2].y - t[0].y) - (t[2].x - t[0].x) * (t[1].y - t[0].y)) + .abs() + / 2.0 + }) + .sum() + } + + /// A polyline with width fills its band, constant width standing for + /// every vertex's; one without stays strokes. + #[test] + fn a_wide_polyline_is_a_filled_band() { + use crate::cad::{LwPolyline, LwVertex, Vec2}; + let line = |w: f64| LwPolyline { + constant_width: w, + vertices: vec![ + LwVertex { + point: Vec2::new(0.0, 0.0), + ..LwVertex::default() + }, + LwVertex { + point: Vec2::new(10.0, 0.0), + ..LwVertex::default() + }, + ], + ..LwPolyline::default() + }; + let wide = tessellated(EntityKind::LwPolyline(line(2.0))); + // Only the band's four edges, which anti-alias it. + assert_eq!(wide.strokes.len(), 4); + assert!(wide + .strokes + .iter() + .all(|s| (s.attr >> 16) as u8 == super::super::scene::FILL_EDGE)); + assert!( + (fill_area(&wide) - 20.0).abs() < 1e-9, + "{}", + fill_area(&wide) + ); + let thin = tessellated(EntityKind::LwPolyline(line(0.0))); + assert_eq!((thin.strokes.len(), thin.fills.len()), (1, 0)); + } + + /// `$FILLMODE` 0 draws the band's edges instead. + #[test] + fn without_fillmode_a_wide_polyline_is_its_outline() { + use crate::cad::{LwPolyline, LwVertex, Vec2}; + let mut doc = CadDrawing::default(); + doc.header.fillmode = false; + let mut t = Tessellator::new(&doc); + t.tessellate_entity(&Entity { + kind: EntityKind::LwPolyline(LwPolyline { + constant_width: 2.0, + vertices: vec![ + LwVertex { + point: Vec2::new(0.0, 0.0), + ..LwVertex::default() + }, + LwVertex { + point: Vec2::new(10.0, 0.0), + ..LwVertex::default() + }, + ], + ..LwPolyline::default() + }), + ..Entity::default() + }); + assert!(t.scene.fills.is_empty()); + assert_eq!(t.scene.strokes.len(), 4); + } + /// TRACE is filled like SOLID, its corners in the same Z order. #[test] fn a_trace_is_filled_like_a_solid() { @@ -2800,7 +4024,7 @@ mod tests { #[test] fn lineweight_resolution() { assert_eq!(lineweight_value(LineWeight::Value(50), 9), 50); - assert_eq!(lineweight_value(LineWeight::Default, 9), 0); + assert_eq!(lineweight_value(LineWeight::Default, 9), DEFAULT_LINEWEIGHT); assert_eq!(lineweight_value(LineWeight::ByLayer, 9), 9); assert_eq!(lineweight_value(LineWeight::ByBlock, 13), 13); // Out-of-range values clamp rather than wrapping through the u8 cast. diff --git a/crates/exav-render/src/formats/dwg/text.rs b/crates/exav-render/src/formats/dwg/text.rs index 26efbbe..d64ae48 100644 --- a/crates/exav-render/src/formats/dwg/text.rs +++ b/crates/exav-render/src/formats/dwg/text.rs @@ -39,10 +39,11 @@ pub enum VAlign { pub struct TextRun { pub x: f64, pub y: f64, - pub height: f64, - pub rotation: f64, - pub width_factor: f64, - pub oblique: f64, + /// One cap height of advance along the baseline, width factor applied. + pub u: [f64; 2], + /// One cap height up the glyphs, oblique applied. With `u`, it carries + /// rotation, mirroring and a block's non-uniform scale. + pub v: [f64; 2], pub rgba: u32, pub attr: u32, pub h_align: HAlign, @@ -52,6 +53,101 @@ pub struct TextRun { pub face: super::font::Face, /// Position in the drawing's draw order. pub order: u32, + /// The clip region the run is drawn through, from 1 (`Scene::clips`), + /// or 0: a run that crosses a viewport's edge or an XCLIP boundary is + /// cut there by the renderer, which knows how wide it is. + pub clip: u16, +} + +impl TextRun { + /// Cap height on the sheet. + pub fn height(&self) -> f64 { + self.v[0].hypot(self.v[1]) + } + + /// The longer of the frame's two units, for bounding what the run covers. + pub fn unit(&self) -> f64 { + self.u[0].hypot(self.u[1]).max(self.height()) + } +} + +/// A run's frame (`TextRun::u`, `TextRun::v`) from a height, a rotation, a +/// width factor and an oblique angle (radians, leaning right). +pub fn frame(height: f64, rotation: f64, width_factor: f64, oblique: f64) -> ([f64; 2], [f64; 2]) { + let (s, c) = rotation.sin_cos(); + let height = if height > 0.0 { height } else { 1.0 }; + let w = if width_factor > 0.0 { + width_factor + } else { + 1.0 + }; + let t = if oblique.abs() < std::f64::consts::FRAC_PI_2 { + oblique.tan() + } else { + 0.0 + }; + ( + [c * height * w, s * height * w], + [(c * t - s) * height, (s * t + c) * height], + ) +} + +/// A TEXT's or ATTRIB's frame, mirrored as its generation flags say: 2 +/// backward (mirrored along the baseline), 4 upside down. A mirror turns the +/// slant the other way too. +pub fn generated_frame( + height: f64, + rotation: f64, + width_factor: f64, + oblique: f64, + generation: i16, +) -> ([f64; 2], [f64; 2]) { + let backward = generation & 2 != 0; + let upside_down = generation & 4 != 0; + let lean = if backward != upside_down { + -oblique + } else { + oblique + }; + let (mut u, mut v) = frame(height, rotation, width_factor, lean); + if backward { + u = [-u[0], -u[1]]; + } + if upside_down { + v = [-v[0], -v[1]]; + } + (u, v) +} + +/// How far below the baseline a face's descenders reach, in cap heights: +/// where Bottom alignment puts the baseline. The outline faces' are their +/// OS/2 descenders over their cap heights; the stroke font stands in for +/// SHX fonts, whose usual one (txt.shx: 2 below 6 above) is a third. +pub fn descent(face: Face) -> f64 { + match face { + Face::Stroke => 1.0 / 3.0, + Face::TrueType(f) => match f.family { + super::font::Family::Sans => 434.0 / 1409.0, + super::font::Family::Serif => 442.0 / 1341.0, + super::font::Family::Mono => 615.0 / 1349.0, + }, + } +} + +/// Where the baseline sits from the anchor, in cap heights up: Bottom puts +/// the descenders on it, Middle centres the capitals, Top their tops, and +/// the Middle horizontal alignment (TEXT's MIDDLE, unlike Middle Center) +/// centres capitals and descenders together. +pub fn baseline_offset(h: HAlign, v: VAlign, face: Face) -> f64 { + if h == HAlign::Middle { + return -(1.0 - descent(face)) / 2.0; + } + match v { + VAlign::Baseline => 0.0, + VAlign::Bottom => descent(face), + VAlign::Middle => -0.5, + VAlign::Top => -1.0, + } } pub const TEXT_RECORD_BYTES: usize = 48; @@ -224,6 +320,24 @@ pub fn decode_special(s: &str) -> String { // %%u, %%o and %%k toggle under/over/strike; we drop the styling. Some('u') | Some('U') | Some('o') | Some('O') | Some('k') | Some('K') => {} Some('%') => out.push('%'), + // %%nnn: the character of that number, three digits. + Some(d) if d.is_ascii_digit() => { + let mut code = d.to_digit(10).unwrap_or(0); + let mut digits = 1; + while digits < 3 { + match chars.peek().and_then(|c| c.to_digit(10)) { + Some(n) => { + code = code * 10 + n; + chars.next(); + digits += 1; + } + None => break, + } + } + if let Some(c) = u8::try_from(code).ok().and_then(windows_1252) { + out.push(c); + } + } Some(other) => { out.push('%'); out.push('%'); @@ -235,11 +349,24 @@ pub fn decode_special(s: &str) -> String { out } -/// Strip MTEXT inline formatting and split into lines. -/// -/// Formatting is dropped rather than honoured: mixed fonts, per-run colour and -/// stacked fractions are a documented gap. Stacked fractions degrade to `a/b` -/// so the content survives even though the layout does not. +/// A Windows-1252 byte as a character; None for a control character or +/// one of the five the code page leaves out. +fn windows_1252(b: u8) -> Option { + const HIGH: [u16; 32] = [ + 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, + 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, + 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178, + ]; + match b { + 0..=0x1F | 0x7F => None, + 0x80..=0x9F => { + char::from_u32(u32::from(HIGH[usize::from(b - 0x80)])).filter(|&c| c != '\0') + } + _ => Some(char::from(b)), + } +} + +/// MTEXT as plain lines: the formatting gone, a stack as `a/b`. pub fn mtext_lines(raw: &str) -> Vec { mtext_spans(raw) .into_iter() @@ -265,13 +392,35 @@ pub enum SpanHeight { /// /// The code is not documented by Autodesk; this follows the arguments the /// format is widely understood to use: `l` a left indent applying to every -/// line, `i` an extra indent on the first line only, and `t` a list of tab -/// stops. `q` justification and `x` line spacing are read and ignored. +/// line, `i` an extra indent on the first line only, `t` a list of tab stops +/// and `q` the justification. `x` line spacing is ignored. #[derive(Clone, Debug, PartialEq, Default)] pub struct Paragraph { pub indent: f64, pub first_indent: f64, pub tabs: Vec, + /// `q`: the lines aligned in the box this way rather than by the + /// attachment point (justified and distributed taken as left). + pub align: Option, +} + +/// A stacked fraction from `\S`. +#[derive(Clone, Debug, PartialEq)] +pub struct Stack { + pub upper: String, + pub lower: String, + pub kind: StackKind, +} + +/// The character between a stack's parts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StackKind { + /// `/`: one above the other across a bar. + Fraction, + /// `#`: side by side across a slash. + Diagonal, + /// `^`: one above the other, no bar, left-aligned (tolerances). + Tolerance, } /// One laid-out line: its runs and the paragraph settings in force. @@ -295,6 +444,14 @@ pub struct Span { pub underline: bool, pub overline: bool, pub strike: bool, + /// `\W`: a width factor in place of the style's. + pub width: Option, + /// `\Q`: an oblique angle (radians) in place of the style's. + pub oblique: Option
Click here to continue
words
Click here to go on
{ + let l = len(a); + (l > 1e-12 && l.is_finite()).then(|| mul(a, 1.0 / l)) +} +fn perp(a: P) -> P { + [-a[1], a[0]] +} +fn polar(a: f64) -> P { + [a.cos(), a.sin()] +} + +/// An angle turned to read left to right: in (-90, 90] degrees. +fn readable(a: f64) -> f64 { + use std::f64::consts::{FRAC_PI_2, PI, TAU}; + let a = a.rem_euclid(TAU); + if a > FRAC_PI_2 + 1e-9 && a <= 3.0 * FRAC_PI_2 + 1e-9 { + a - PI + } else if a > 3.0 * FRAC_PI_2 { + a - TAU + } else { + a + } +} + +/// A number as the style writes it: its decimal places, its separator, and +/// leading (4) or trailing (8) zeros suppressed as DIMZIN says. +pub fn number(v: f64, places: usize, zin: i16, sep: char) -> String { + // Halves away from zero, as AutoCAD rounds: formatting alone takes them + // to even, 100.5 to 100. + let k = 10f64.powi(places as i32); + let rounded = (v * k).round() / k; + let v = if rounded.is_finite() { rounded } else { v }; + let mut s = format!("{v:.places$}"); + if s.starts_with('-') && s[1..].chars().all(|c| c == '0' || c == '.') { + s.remove(0); + } + if zin & 8 != 0 && s.contains('.') { + s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + } + if zin & 4 != 0 { + if let Some(rest) = s.strip_prefix("0.") { + s = format!(".{rest}"); + } else if let Some(rest) = s.strip_prefix("-0.") { + s = format!("-.{rest}"); + } + } + if sep != '.' { + s = s.replace('.', &sep.to_string()); + } + s +} + +/// What the dimension says: its own text, where `<>` stands for the +/// measurement (empty is the measurement alone, a single space nothing). +fn content(d: &Dimension, measured: String) -> Option { + match d.text.as_str() { + " " => None, + "" => Some(measured), + t => Some(t.replace("<>", &measured)), + } +} + +/// The text's middle the dimension stores (group 11), when it stores one: +/// a generator that leaves out the block leaves it at the origin too. +fn stored_text(d: &Dimension) -> Option { + let t = d.text_midpoint; + (d.flags & 128 != 0 || t.x != 0.0 || t.y != 0.0).then(|| p(t)) +} + +pub fn graphics(d: &Dimension, s: &Style, measure: &dyn Fn(&str) -> f64) -> Option { + match d.kind { + DimensionKind::Linear | DimensionKind::Aligned => linear(d, s, measure), + DimensionKind::Radius | DimensionKind::Diameter => radial(d, s), + DimensionKind::Angular | DimensionKind::Angular3Point => angular(d, s), + DimensionKind::Ordinate => ordinate(d, s), + } +} + +/// An extension line from `from` to `to` on the dimension line: `exo` short +/// of the feature, `exe` past the line. +fn extension(g: &mut Graphics, from: P, to: P, s: &Style) { + if let Some(e) = unit(sub(to, from)) { + if len(sub(to, from)) > s.exo { + g.lines + .push([add(from, mul(e, s.exo)), add(to, mul(e, s.exe))]); + } + } +} + +/// The ends of a dimension line or arc: arrowheads, or with DIMTSZ ticks +/// at 45 degrees and the line run on by DIMDLE. +fn ends(g: &mut Graphics, a: P, b: P, s: &Style) { + let Some(u) = unit(sub(b, a)) else { + return; + }; + if s.tsz > 0.0 { + let t = mul(add(u, perp(u)), 0.5 * s.tsz); + for c in [a, b] { + g.lines.push([sub(c, t), add(c, t)]); + } + } else { + g.arrows.push((a, b)); + g.arrows.push((b, a)); + } +} + +fn linear(d: &Dimension, s: &Style, measure: &dyn Fn(&str) -> f64) -> Option { + let (p1, p2, on) = (p(d.point13), p(d.point14), p(d.definition_point)); + let u = match d.kind { + DimensionKind::Aligned => unit(sub(p2, p1)).unwrap_or([1.0, 0.0]), + _ => polar(d.angle), + }; + let a = add(on, mul(u, dot(sub(p1, on), u))); + let b = add(on, mul(u, dot(sub(p2, on), u))); + let mut g = Graphics::default(); + extension(&mut g, p1, a, s); + extension(&mut g, p2, b, s); + let measured = dot(sub(p2, p1), u).abs() * s.lfac; + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(u[1].atan2(u[0])) + }; + let up = perp(polar(rotation)); + let mid = mul(add(a, b), 0.5); + let label = content(d, number(measured, s.dec, s.zin, s.dsep)).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| { + if s.above { + add(mid, mul(up, s.gap + s.txt / 2.0)) + } else { + mid + } + }), + text, + rotation, + }); + let du = unit(sub(b, a)).unwrap_or(u); + let run_on = if s.tsz > 0.0 { + mul(du, s.dle) + } else { + [0.0, 0.0] + }; + let (from, to) = (sub(a, run_on), add(b, run_on)); + // Text on the line breaks it, a gap either side. + let cut = label.as_ref().filter(|l| { + let off = sub(l.at, a); + let along = dot(off, du); + (dot(off, perp(du))).abs() < s.txt / 2.0 && along > 0.0 && along < len(sub(b, a)) + }); + match cut { + Some(l) => { + let half = measure(&l.text) * s.txt / 2.0 + s.gap; + let along = dot(sub(l.at, a), du); + let (c0, c1) = (add(a, mul(du, along - half)), add(a, mul(du, along + half))); + if dot(sub(c0, from), du) > 0.0 { + g.lines.push([from, c0]); + } + if dot(sub(to, c1), du) > 0.0 { + g.lines.push([c1, to]); + } + } + None => g.lines.push([from, to]), + } + ends(&mut g, a, b, s); + g.label = label; + Some(g) +} + +fn radial(d: &Dimension, s: &Style) -> Option { + let (far, near) = (p(d.definition_point), p(d.point15)); + let mut g = Graphics::default(); + let label_at = stored_text(d); + let (measured, prefix) = match d.kind { + DimensionKind::Radius => (len(sub(near, far)), "R"), + _ => (len(sub(near, far)), "%%c"), + }; + let u = unit(sub(near, far))?; + match d.kind { + DimensionKind::Radius => { + // From the centre to the curve, or from the curve out to text + // placed outside it. + let outside = label_at.filter(|t| len(sub(*t, far)) > measured); + match outside { + Some(t) => g.lines.push([near, t]), + None => g.lines.push([far, near]), + } + g.arrows.push((near, far)); + } + _ => { + g.lines.push([far, near]); + g.arrows.push((near, far)); + g.arrows.push((far, near)); + } + } + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(u[1].atan2(u[0])) + }; + let mid = mul(add(far, near), 0.5); + // The prefix is part of the measurement, `<>` included. + let measured = format!( + "{prefix}{}", + number(measured * s.lfac, s.dec, s.zin, s.dsep) + ); + g.label = content(d, measured).map(|text| Label { + at: label_at.unwrap_or_else(|| add(mid, mul(perp(polar(rotation)), s.gap + s.txt / 2.0))), + text, + rotation, + }); + Some(g) +} + +/// Where two lines cross, each through two points. +fn crossing(a0: P, a1: P, b0: P, b1: P) -> Option { + let (r, q) = (sub(a1, a0), sub(b1, b0)); + let den = r[0] * q[1] - r[1] * q[0]; + if den.abs() < 1e-12 * len(r) * len(q) || !den.is_finite() { + return None; + } + let t = ((b0[0] - a0[0]) * q[1] - (b0[1] - a0[1]) * q[0]) / den; + Some(add(a0, mul(r, t))) +} + +fn angular(d: &Dimension, s: &Style) -> Option { + use std::f64::consts::{PI, TAU}; + // The two legs, as the rays out of the centre and the feature points + // along each. + let (centre, legs, on) = match d.kind { + DimensionKind::Angular3Point => { + let c = p(d.point15); + ( + c, + [[c, p(d.point13)], [c, p(d.point14)]], + p(d.definition_point), + ) + } + _ => { + let l1 = [p(d.point13), p(d.point14)]; + let l2 = [p(d.point15), p(d.definition_point)]; + ( + crossing(l1[0], l1[1], l2[0], l2[1])?, + [l1, l2], + p(d.point16), + ) + } + }; + let radius = len(sub(on, centre)); + if !(radius > 1e-12) { + return None; + } + let theta = (on[1] - centre[1]).atan2(on[0] - centre[0]); + let (start, sweep) = match d.kind { + DimensionKind::Angular3Point => { + let a1 = angle_of(sub(legs[0][1], centre))?; + let a2 = angle_of(sub(legs[1][1], centre))?; + let ccw = (a2 - a1).rem_euclid(TAU); + if (theta - a1).rem_euclid(TAU) <= ccw { + (a1, ccw) + } else { + (a2, TAU - ccw) + } + } + _ => { + // The sector of the four the two lines make that holds the arc's + // point. + let a1 = angle_of(sub(legs[0][1], legs[0][0]))?; + let a2 = angle_of(sub(legs[1][1], legs[1][0]))?; + let mut rays = [a1, a1 + PI, a2, a2 + PI].map(|a| a.rem_euclid(TAU)); + rays.sort_by(f64::total_cmp); + let t = theta.rem_euclid(TAU); + let i = rays.iter().rposition(|r| *r <= t).unwrap_or(3); + let (lo, hi) = (rays[i], if i == 3 { rays[0] + TAU } else { rays[i + 1] }); + (lo, hi - lo) + } + }; + let mut g = Graphics::default(); + g.arcs.push((centre, radius, start, sweep)); + let e1 = add(centre, mul(polar(start), radius)); + let e2 = add(centre, mul(polar(start + sweep), radius)); + // An extension line from the leg's nearer feature point out to the arc, + // when the arc lies beyond the leg. + for (end, ray) in [(e1, polar(start)), (e2, polar(start + sweep))] { + let leg = legs.iter().find(|l| { + let dir = sub(l[1], l[0]); + (dir[0] * ray[1] - dir[1] * ray[0]).abs() < 1e-9 * len(dir) + }); + if let Some(leg) = leg { + let ts: Vec<(f64, P)> = leg + .iter() + .map(|q| (dot(sub(*q, centre), ray), *q)) + .collect(); + let near = ts + .iter() + .min_by(|a, b| (a.0 - radius).abs().total_cmp(&(b.0 - radius).abs()))?; + let (lo, hi) = (ts[0].0.min(ts[1].0), ts[0].0.max(ts[1].0)); + if radius > hi + 1e-9 || radius < lo - 1e-9 { + extension(&mut g, near.1, end, s); + } + } + } + if s.tsz > 0.0 { + ends(&mut g, e1, e2, s); + } else { + let t1 = perp(polar(start)); + let t2 = perp(polar(start + sweep)); + g.arrows.push((e1, add(e1, t1))); + g.arrows.push((e2, sub(e2, t2))); + } + let mid = start + sweep / 2.0; + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(mid + PI / 2.0) + }; + let lift = if s.above { s.gap + s.txt / 2.0 } else { 0.0 }; + let degrees = number(sweep.to_degrees(), s.adec, 0, s.dsep) + "%%d"; + g.label = content(d, degrees).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| add(centre, mul(polar(mid), radius + lift))), + text, + rotation, + }); + Some(g) +} + +fn angle_of(v: P) -> Option { + unit(v).map(|u| u[1].atan2(u[0])) +} + +fn ordinate(d: &Dimension, s: &Style) -> Option { + use std::f64::consts::FRAC_PI_2; + let (origin, feature, end) = (p(d.definition_point), p(d.point13), p(d.point14)); + let x_type = d.flags & 64 != 0; + let mut g = Graphics::default(); + extension(&mut g, feature, end, s); + let measured = if x_type { + feature[0] - origin[0] + } else { + feature[1] - origin[1] + }; + let rotation = if x_type { FRAC_PI_2 } else { 0.0 }; + let dir = unit(sub(end, feature)).unwrap_or(polar(rotation)); + g.label = content(d, number(measured.abs() * s.lfac, s.dec, s.zin, s.dsep)).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| add(end, mul(dir, s.gap + s.txt))), + text, + rotation, + }); + Some(g) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_follow_the_style() { + assert_eq!(number(12.5, 4, 0, '.'), "12.5000"); + assert_eq!(number(12.5, 4, 8, '.'), "12.5"); + assert_eq!(number(12.0, 2, 8, ','), "12"); + assert_eq!(number(0.25, 2, 4, ','), ",25"); + assert_eq!(number(-0.0001, 2, 0, '.'), "0.00"); + assert_eq!(number(100.5, 0, 0, '.'), "101"); + assert_eq!(number(2.25, 1, 0, '.'), "2.3"); + } + + #[test] + fn readable_text_never_runs_upside_down() { + use std::f64::consts::PI; + assert!((readable(PI) - 0.0).abs() < 1e-12); + assert!((readable(-PI / 2.0) - PI / 2.0).abs() < 1e-12); + assert!((readable(PI / 2.0) - PI / 2.0).abs() < 1e-12); + assert!((readable(0.3) - 0.3).abs() < 1e-12); + } +} diff --git a/crates/exav-render/src/formats/dwg/font.rs b/crates/exav-render/src/formats/dwg/font.rs index 9b8a8a3..d2a85e2 100644 --- a/crates/exav-render/src/formats/dwg/font.rs +++ b/crates/exav-render/src/formats/dwg/font.rs @@ -409,6 +409,73 @@ fn table_for(f: TrueTypeFace) -> (&'static [u16], &'static [(u32, u16)], f32) { } } +/// A font whose letters stand for other characters. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Symbols { + /// Symbol's Greek and mathematical signs; the SHX Greek fonts' letters. + Symbol, + /// greeks.shx, greekc.shx: Greek letters only. + Greek, + /// gdt.shx: the geometric tolerancing signs on lowercase letters. + Gdt, +} + +/// The symbol set a font file draws, by name. +pub fn symbols_of(file: &str) -> Option { + let lower = file.trim().to_ascii_lowercase(); + let base = lower.rsplit(['/', '\\']).next().unwrap_or(&lower); + let stem = base.rsplit_once('.').map_or(base, |(s, _)| s); + match stem { + "symbol" => Some(Symbols::Symbol), + "greeks" | "greekc" => Some(Symbols::Greek), + "gdt" | "amgdt" => Some(Symbols::Gdt), + _ => None, + } +} + +/// Greek for the Latin letters, in the order Symbol puts them: A Alpha, C +/// Chi, F Phi, J theta symbol, Q Theta, V final sigma, W Omega... +const GREEK_UPPER: [char; 26] = [ + 'Α', 'Î’', 'Χ', 'Δ', 'Ε', 'Φ', 'Γ', 'Η', 'Ι', 'Ï‘', 'Κ', 'Λ', 'Μ', 'Î�', 'Ο', 'Î ', 'Θ', 'Ρ', 'Σ', + 'Τ', 'Î¥', 'Ï‚', 'Ω', 'Ξ', 'Ψ', 'Ζ', +]; +const GREEK_LOWER: [char; 26] = [ + 'α', 'β', 'χ', 'δ', 'ε', 'φ', 'γ', 'η', 'ι', 'Ï•', 'κ', 'λ', 'μ', 'ν', 'ο', 'Ï€', 'θ', 'Ï�', 'σ', + 'Ï„', 'Ï…', 'Ï–', 'ω', 'ξ', 'ψ', 'ζ', +]; +/// gdt.shx's lowercase letters: a angularity, b perpendicularity, c +/// flatness, d surface profile, e circularity, f parallelism, g +/// cylindricity, h circular runout, i symmetry, j position, k line profile, +/// l LMC, m MMC, n diameter, o square, p projected zone, q centre line, r +/// concentricity, s RFS, t total runout, u straightness, v counterbore, w +/// countersink, x depth, y conical taper, z slope. +const GDT: [char; 26] = [ + '∠', '⊥', 'â�¥', '⌓', 'â—‹', '∥', 'âŒ', '↗', '⌯', '⌖', '⌒', 'â“�', 'â“‚', '⌀', 'â–¡', 'â“…', 'â„„', 'â—Ž', 'Ⓢ', + '⌰', 'â�¤', '⌴', '⌵', '↧', '⌲', '⌳', +]; + +/// What a string in a symbol font shows. +pub fn map_symbols(s: &str, set: Symbols) -> String { + let letter = |c: char, table: &[char; 26]| table[(c as u8 - b'a') as usize]; + s.chars() + .map(|c| match (set, c) { + (Symbols::Symbol | Symbols::Greek, 'A'..='Z') => GREEK_UPPER[(c as u8 - b'A') as usize], + (Symbols::Symbol | Symbols::Greek, 'a'..='z') => letter(c, &GREEK_LOWER), + (Symbols::Gdt, 'a'..='z') => letter(c, &GDT), + (Symbols::Symbol, '"') => '∀', + (Symbols::Symbol, '$') => '∃', + (Symbols::Symbol, '\'') => '∋', + (Symbols::Symbol, '*') => '∗', + (Symbols::Symbol, '-') => '−', + (Symbols::Symbol, '@') => '≅', + (Symbols::Symbol, '\\') => '∴', + (Symbols::Symbol, '^') => '⊥', + (Symbols::Symbol, '~') => '∼', + _ => c, + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -420,6 +487,28 @@ mod tests { } } + /// Greek and Cyrillic have their own widths, not the fallback's. + #[test] + fn greek_and_cyrillic_are_measured() { + let m = Metrics::for_face(Face::default()); + assert!(m.advance('Ж') > m.advance('г') * 1.5); + assert!(m.advance('Ω') > m.advance('ι') * 1.5); + assert_eq!(m.advance('Ω'), m.advance('\u{03A9}')); + } + + #[test] + fn symbol_fonts_draw_what_their_letters_stand_for() { + assert_eq!(symbols_of("SYMBOL.TTF"), Some(Symbols::Symbol)); + assert_eq!(symbols_of("fonts\\greeks.shx"), Some(Symbols::Greek)); + assert_eq!(symbols_of("gdt"), Some(Symbols::Gdt)); + assert_eq!(symbols_of("arial.ttf"), None); + assert_eq!(map_symbols("abg D=@", Symbols::Symbol), "αβγ Δ=≅"); + // The SHX Greek fonts change letters only. + assert_eq!(map_symbols("abg D=@", Symbols::Greek), "αβγ Δ=@"); + // GD&T: diameter 0.05, position, MMC; capitals stay. + assert_eq!(map_symbols("n0.05 jm A", Symbols::Gdt), "⌀0.05 ⌖Ⓜ A"); + } + /// The case the corpus is full of: a stock stroke font, stored with no /// extension at all. Testing for ".shx" would miss every one of these. #[test] diff --git a/crates/exav-render/src/formats/dwg/linetype.rs b/crates/exav-render/src/formats/dwg/linetype.rs index 6e61370..14e720f 100644 --- a/crates/exav-render/src/formats/dwg/linetype.rs +++ b/crates/exav-render/src/formats/dwg/linetype.rs @@ -10,27 +10,74 @@ /// segments for something that reads as solid anyway. const MAX_DASHES: usize = 20_000; -/// Text carried by one element of a complex linetype. +/// Text or a shape carried by one element of a complex linetype. /// /// A linetype like GAZ draws its dashes and stamps "GAS" into the gaps at every /// repetition. Without this the line is drawn as plain dashes and the label /// that says what the pipe carries is missing. #[derive(Clone, Debug)] pub struct Label { - /// Index into `runs` of the element that carries the text. + /// Index into `runs` of the element that carries the label, which is + /// placed at that element's end. pub run: usize, - pub text: String, - /// Text height in drawing units. + pub mark: Mark, + /// Text height, or the shape's unit, in drawing units. pub height: f64, - /// Offset from the element's position: along the line, then across it. + /// Offset from the element's end: along the line, then across it. pub offset: [f64; 2], pub rotation: f64, /// Rotation is world-absolute rather than relative to the line. pub absolute_rotation: bool, - /// The face the label's text style resolves to. These are usually stroke - /// fonts, since a linetype that stamps a symbol along a line is drawn from - /// an SHX shape file. - pub face: super::font::Face, +} + +#[derive(Clone, Debug)] +pub enum Mark { + /// `face` is what the label's text style resolves to. + Text { + text: String, + face: super::font::Face, + }, + /// A shape of ltypeshp.shx, as `ltypeshp_shape` draws it. + Shape(&'static [ShapePart]), +} + +/// A piece of a shape, in shape units from its insertion point, the line +/// running along +x. +#[derive(Debug, PartialEq)] +pub enum ShapePart { + Line(&'static [[f64; 2]]), + /// Centre, radius, start angle and signed sweep (radians). + Arc([f64; 2], f64, f64, f64), +} + +/// The shapes of ltypeshp.shx, which AutoCAD's own complex linetypes +/// (TRACKS, ZIGZAG, FENCELINE1 and 2, BATTING) stamp, redrawn here: the file +/// itself cannot be shipped, any more than an SHX font. Others are left out. +pub fn ltypeshp_shape(number: i16) -> Option<&'static [ShapePart]> { + use std::f64::consts::PI; + const TRACK1: &[ShapePart] = &[ShapePart::Line(&[[0.0, 1.0], [0.0, -1.0]])]; + const ZIG: &[ShapePart] = &[ShapePart::Line(&[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]])]; + const BOX: &[ShapePart] = &[ShapePart::Line(&[ + [0.0, -1.0], + [2.0, -1.0], + [2.0, 1.0], + [0.0, 1.0], + [0.0, -1.0], + ])]; + const CIRC1: &[ShapePart] = &[ShapePart::Arc([1.0, 0.0], 1.0, PI, -2.0 * PI)]; + const BAT: &[ShapePart] = &[ + ShapePart::Line(&[[0.0, 0.0], [-1.0, 2.0]]), + ShapePart::Arc([1.0, 2.0], 2.0, PI, -PI), + ShapePart::Line(&[[3.0, 2.0], [2.0, 0.0]]), + ]; + match number { + 130 => Some(TRACK1), + 131 => Some(ZIG), + 132 => Some(BOX), + 133 => Some(CIRC1), + 134 => Some(BAT), + _ => None, + } } /// One placed copy of a label, in drawing coordinates. @@ -215,7 +262,8 @@ pub fn dash_polyline( Some(out) } -/// Where each copy of the pattern's embedded text lands along a polyline. +/// Where each copy of the pattern's embedded text and shapes lands along a +/// polyline: the end of the element carrying it, shifted by its offset. /// /// The phase is the same walk the dashes use, so a label sits in the gap its /// definition puts it in rather than drifting against the dashes. @@ -241,7 +289,7 @@ pub fn dash_labels(pts: &[[f64; 2]], closed: bool, pattern: &Pattern) -> Vec len { continue; } @@ -323,12 +371,14 @@ mod embedded_text { &[10.0, -5.0], &[Label { run: 1, - text: "GAS".to_string(), + mark: Mark::Text { + text: "GAS".to_string(), + face: super::super::font::Face::Stroke, + }, height: 2.54, offset: [-2.54, -1.27], rotation: 0.0, absolute_rotation: false, - face: super::super::font::Face::Stroke, }], scale, ) @@ -361,9 +411,9 @@ mod embedded_text { fn the_offsets_place_it_along_and_across_the_line() { let places = dash_labels(&[[0.0, 0.0], [100.0, 0.0]], false, &gaz(1.0)); let first = &places[0]; - // The dash runs 0..10, so the gap starts at 10 and the label sits - // 2.54 back along it and 1.27 below it. - assert!((first.x - (10.0 - 2.54)).abs() < 1e-6, "x {}", first.x); + // The gap carrying it runs 10..15, and the label sits 2.54 back from + // its end and 1.27 below the line. + assert!((first.x - (15.0 - 2.54)).abs() < 1e-6, "x {}", first.x); assert!((first.y - -1.27).abs() < 1e-6, "y {}", first.y); assert!(first.angle.abs() < 1e-9, "angle {}", first.angle); } @@ -374,7 +424,7 @@ mod embedded_text { assert!((p.labels[0].height - 5.08).abs() < 1e-9); let places = dash_labels(&[[0.0, 0.0], [200.0, 0.0]], false, &p); assert!( - (places[0].x - (20.0 - 5.08)).abs() < 1e-6, + (places[0].x - (30.0 - 5.08)).abs() < 1e-6, "x {}", places[0].x ); @@ -418,12 +468,14 @@ mod tests { assert!(dash_polyline(&pts, false, &p).is_none()); let label = Label { run: 1, - text: "X".to_string(), + mark: Mark::Text { + text: "X".to_string(), + face: super::super::font::Face::Stroke, + }, height: 1.0, offset: [0.0, 0.0], rotation: 0.0, absolute_rotation: false, - face: super::super::font::Face::Stroke, }; let labelled = Pattern::with_labels(&[1.0, -1.0], &[label], 1.0).unwrap(); assert!(dash_labels(&pts, false, &labelled).is_empty()); diff --git a/crates/exav-render/src/formats/dwg/metrics.rs b/crates/exav-render/src/formats/dwg/metrics.rs index 700e5c7..89ad8fc 100644 --- a/crates/exav-render/src/formats/dwg/metrics.rs +++ b/crates/exav-render/src/formats/dwg/metrics.rs @@ -49,13 +49,187 @@ pub static SANS: [u16; 560] = [ 3186, 2278, 2732, 2048, 1430, 2804, 1504, 910, 3632, 3632, 2732, 2958, 2048, 2278, 2502, 2048, 2048, 2378, 1856, 2732, 2958, 2736, 2732, 2278, 2048, 910, 3020, 2278, 2958, 1364, 2732, 2048, ]; -pub static SANS_SPARSE: [(u32, u16); 22] = [ +pub static SANS_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2734), + (0x0387, 1138), + (0x0388, 3212), + (0x0389, 3432), + (0x038a, 1572), + (0x038c, 3172), + (0x038e, 3504), + (0x038f, 3082), + (0x0390, 910), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2256), + (0x0394, 2736), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2732), + (0x039b, 2736), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2662), + (0x039f, 3186), + (0x03a0, 2958), + (0x03a1, 2732), + (0x03a3, 2532), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3268), + (0x03a7, 2732), + (0x03a8, 3422), (0x03a9, 3062), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2368), + (0x03ad, 1826), + (0x03ae, 2278), + (0x03af, 910), + (0x03b0, 2240), + (0x03b1, 2368), + (0x03b2, 2356), + (0x03b3, 2048), + (0x03b4, 2280), + (0x03b5, 1826), + (0x03b6, 1806), + (0x03b7, 2278), + (0x03b8, 2278), + (0x03b9, 910), + (0x03ba, 2048), + (0x03bb, 2048), (0x03bc, 2360), + (0x03bd, 2048), + (0x03be, 1834), + (0x03bf, 2278), + (0x03c0, 2826), + (0x03c1, 2330), + (0x03c2, 1974), + (0x03c3, 2528), + (0x03c4, 1618), + (0x03c5, 2240), + (0x03c6, 2656), + (0x03c7, 2150), + (0x03c8, 2920), + (0x03c9, 3198), + (0x03ca, 910), + (0x03cb, 2240), + (0x03cc, 2278), + (0x03cd, 2240), + (0x03ce, 3198), + (0x03cf, 2732), + (0x03d0, 2356), + (0x03d1, 2240), + (0x03d2, 3164), + (0x03d3, 3924), + (0x03d4, 3164), + (0x03d5, 2294), + (0x03d6, 3198), + (0x0400, 2732), + (0x0401, 2734), + (0x0402, 3542), + (0x0403, 2218), + (0x0404, 2944), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2048), + (0x0409, 4330), + (0x040a, 4138), + (0x040b, 3498), + (0x040c, 2386), + (0x040d, 2944), + (0x040e, 2602), + (0x040f, 2944), + (0x0410, 2732), + (0x0411, 2688), + (0x0412, 2732), + (0x0413, 2218), + (0x0414, 2774), + (0x0415, 2732), + (0x0416, 3782), + (0x0417, 2474), + (0x0418, 2944), + (0x0419, 2944), + (0x041a, 2386), + (0x041b, 2688), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2944), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2602), + (0x0424, 3114), + (0x0425, 2732), + (0x0426, 3030), + (0x0427, 2730), + (0x0428, 3754), + (0x0429, 3840), + (0x042a, 3242), + (0x042b, 3626), + (0x042c, 2688), + (0x042d, 2944), + (0x042e, 4138), + (0x042f, 2958), + (0x0430, 2278), + (0x0431, 2346), + (0x0432, 2176), + (0x0433, 1494), + (0x0434, 2390), + (0x0435, 2278), + (0x0436, 2740), + (0x0437, 1878), + (0x0438, 2288), + (0x0439, 2288), + (0x043a, 1792), + (0x043b, 2390), + (0x043c, 2816), + (0x043d, 2262), + (0x043e, 2278), + (0x043f, 2218), + (0x0440, 2278), + (0x0441, 2048), + (0x0442, 1876), + (0x0443, 2048), + (0x0444, 3370), + (0x0445, 2048), + (0x0446, 2346), + (0x0447, 2134), + (0x0448, 3286), + (0x0449, 3370), + (0x044a, 2560), + (0x044b, 2944), + (0x044c, 2134), + (0x044d, 2090), + (0x044e, 3072), + (0x044f, 2218), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2278), + (0x0453, 1494), + (0x0454, 2090), + (0x0455, 2048), + (0x0456, 910), + (0x0457, 1138), + (0x0458, 910), + (0x0459, 3712), + (0x045a, 3328), + (0x045b, 2278), + (0x045c, 1792), + (0x045d, 2288), + (0x045e, 2048), + (0x045f, 2260), (0x2013, 2278), (0x2014, 4096), (0x2018, 910), @@ -69,6 +243,7 @@ pub static SANS_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -110,13 +285,187 @@ pub static SANS_BOLD: [u16; 560] = [ 3186, 2502, 2732, 2278, 2044, 3414, 2072, 1138, 3906, 3906, 2958, 2958, 2278, 2502, 2502, 2278, 2048, 2642, 1982, 2958, 2958, 2732, 2732, 2278, 2278, 1138, 3174, 2502, 2958, 1594, 2732, 2278, ]; -pub static SANS_BOLD_SPARSE: [(u32, u16); 22] = [ +pub static SANS_BOLD_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1904), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3494), + (0x0389, 3710), + (0x038a, 1940), + (0x038c, 3378), + (0x038e, 3798), + (0x038f, 3432), + (0x0390, 1138), + (0x0391, 2958), + (0x0392, 2958), + (0x0393, 2462), + (0x0394, 2944), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2958), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2636), + (0x039f, 3186), + (0x03a0, 2958), + (0x03a1, 2732), + (0x03a3, 2458), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3362), + (0x03a7, 2732), + (0x03a8, 3314), (0x03a9, 3284), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2518), + (0x03ad, 1848), + (0x03ae, 2502), + (0x03af, 1138), + (0x03b0, 2384), + (0x03b1, 2518), + (0x03b2, 2500), + (0x03b3, 2278), + (0x03b4, 2484), + (0x03b5, 1944), + (0x03b6, 1886), + (0x03b7, 2502), + (0x03b8, 2214), + (0x03b9, 1138), + (0x03ba, 2284), + (0x03bb, 2278), (0x03bc, 2506), + (0x03bd, 2278), + (0x03be, 1824), + (0x03bf, 2502), + (0x03c0, 3136), + (0x03c1, 2534), + (0x03c2, 2130), + (0x03c3, 2802), + (0x03c4, 1828), + (0x03c5, 2384), + (0x03c6, 2930), + (0x03c7, 2358), + (0x03c8, 3086), + (0x03c9, 3460), + (0x03ca, 1138), + (0x03cb, 2384), + (0x03cc, 2502), + (0x03cd, 2384), + (0x03ce, 3460), + (0x03cf, 2958), + (0x03d0, 2500), + (0x03d1, 2384), + (0x03d2, 3154), + (0x03d3, 4100), + (0x03d4, 3154), + (0x03d5, 3070), + (0x03d6, 3460), + (0x0400, 2732), + (0x0401, 2740), + (0x0402, 3626), + (0x0403, 2322), + (0x0404, 2914), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1130), + (0x0408, 2278), + (0x0409, 4480), + (0x040a, 4352), + (0x040b, 3584), + (0x040c, 2500), + (0x040d, 2944), + (0x040e, 2548), + (0x040f, 2944), + (0x0410, 2958), + (0x0411, 2944), + (0x0412, 2958), + (0x0413, 2322), + (0x0414, 2918), + (0x0415, 2732), + (0x0416, 3702), + (0x0417, 2566), + (0x0418, 2944), + (0x0419, 2944), + (0x041a, 2500), + (0x041b, 2874), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2944), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2548), + (0x0424, 3496), + (0x0425, 2732), + (0x0426, 2992), + (0x0427, 2878), + (0x0428, 4116), + (0x0429, 4174), + (0x042a, 3562), + (0x042b, 4010), + (0x042c, 2944), + (0x042d, 2914), + (0x042e, 4224), + (0x042f, 2944), + (0x0430, 2278), + (0x0431, 2530), + (0x0432, 2518), + (0x0433, 1706), + (0x0434, 2600), + (0x0435, 2278), + (0x0436, 2904), + (0x0437, 2036), + (0x0438, 2518), + (0x0439, 2518), + (0x043a, 2050), + (0x043b, 2602), + (0x043c, 3030), + (0x043d, 2474), + (0x043e, 2502), + (0x043f, 2474), + (0x0440, 2502), + (0x0441, 2278), + (0x0442, 2006), + (0x0443, 2278), + (0x0444, 3584), + (0x0445, 2278), + (0x0446, 2518), + (0x0447, 2378), + (0x0448, 3414), + (0x0449, 3456), + (0x044a, 2986), + (0x044b, 3498), + (0x044c, 2518), + (0x044d, 2262), + (0x044e, 3498), + (0x044f, 2390), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2502), + (0x0453, 1706), + (0x0454, 2262), + (0x0455, 2278), + (0x0456, 1138), + (0x0457, 1152), + (0x0458, 1138), + (0x0459, 3968), + (0x045a, 3712), + (0x045b, 2502), + (0x045c, 2050), + (0x045d, 2518), + (0x045e, 2278), + (0x045f, 2474), (0x2013, 2278), (0x2014, 4096), (0x2018, 1138), @@ -130,6 +479,7 @@ pub static SANS_BOLD_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -171,13 +521,187 @@ pub static SANS_ITALIC: [u16; 560] = [ 3186, 2278, 2732, 2048, 1204, 2576, 1424, 910, 3618, 3608, 2732, 2958, 2048, 2278, 2502, 2048, 2048, 2378, 1856, 2732, 2958, 2732, 2732, 2278, 2048, 910, 3092, 2278, 2958, 1364, 2732, 2048, ]; -pub static SANS_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SANS_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2732), + (0x0387, 1138), + (0x0388, 3230), + (0x0389, 3466), + (0x038a, 1592), + (0x038c, 3252), + (0x038e, 3544), + (0x038f, 3174), + (0x0390, 910), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2336), + (0x0394, 2748), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2732), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2656), + (0x039f, 3186), + (0x03a0, 2968), + (0x03a1, 2732), + (0x03a3, 2456), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3428), + (0x03a7, 2732), + (0x03a8, 3402), (0x03a9, 3116), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2336), + (0x03ad, 1800), + (0x03ae, 2274), + (0x03af, 910), + (0x03b0, 2252), + (0x03b1, 2336), + (0x03b2, 2340), + (0x03b3, 2048), + (0x03b4, 2276), + (0x03b5, 1800), + (0x03b6, 1898), + (0x03b7, 2274), + (0x03b8, 2222), + (0x03b9, 910), + (0x03ba, 2048), + (0x03bb, 2016), (0x03bc, 2244), + (0x03bd, 2048), + (0x03be, 1830), + (0x03bf, 2278), + (0x03c0, 2744), + (0x03c1, 2346), + (0x03c2, 1992), + (0x03c3, 2470), + (0x03c4, 1532), + (0x03c5, 2252), + (0x03c6, 2672), + (0x03c7, 2236), + (0x03c8, 2980), + (0x03c9, 3190), + (0x03ca, 910), + (0x03cb, 2252), + (0x03cc, 2278), + (0x03cd, 2252), + (0x03ce, 3190), + (0x03cf, 2732), + (0x03d0, 2068), + (0x03d1, 2252), + (0x03d2, 2932), + (0x03d3, 3612), + (0x03d4, 2932), + (0x03d5, 2278), + (0x03d6, 3190), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3452), + (0x0403, 2228), + (0x0404, 2898), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2048), + (0x0409, 4366), + (0x040a, 4024), + (0x040b, 3458), + (0x040c, 2414), + (0x040d, 2930), + (0x040e, 2618), + (0x040f, 2964), + (0x0410, 2732), + (0x0411, 2666), + (0x0412, 2732), + (0x0413, 2228), + (0x0414, 2884), + (0x0415, 2732), + (0x0416, 3756), + (0x0417, 2514), + (0x0418, 2930), + (0x0419, 2930), + (0x041a, 2414), + (0x041b, 2808), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2968), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2618), + (0x0424, 3256), + (0x0425, 2732), + (0x0426, 2978), + (0x0427, 2758), + (0x0428, 3770), + (0x0429, 3780), + (0x042a, 3298), + (0x042b, 3630), + (0x042c, 2666), + (0x042d, 2842), + (0x042e, 4188), + (0x042f, 2792), + (0x0430, 2278), + (0x0431, 2304), + (0x0432, 2138), + (0x0433, 2020), + (0x0434, 2264), + (0x0435, 2278), + (0x0436, 2818), + (0x0437, 1904), + (0x0438, 2278), + (0x0439, 2278), + (0x043a, 1934), + (0x043b, 2310), + (0x043c, 2808), + (0x043d, 2254), + (0x043e, 2278), + (0x043f, 2254), + (0x0440, 2278), + (0x0441, 2048), + (0x0442, 3412), + (0x0443, 2048), + (0x0444, 3422), + (0x0445, 2048), + (0x0446, 2342), + (0x0447, 2120), + (0x0448, 3400), + (0x0449, 3486), + (0x044a, 2544), + (0x044b, 3014), + (0x044c, 2154), + (0x044d, 2014), + (0x044e, 3080), + (0x044f, 2188), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2278), + (0x0453, 1548), + (0x0454, 2030), + (0x0455, 2048), + (0x0456, 910), + (0x0457, 910), + (0x0458, 910), + (0x0459, 3728), + (0x045a, 3390), + (0x045b, 2278), + (0x045c, 1934), + (0x045d, 2278), + (0x045e, 2048), + (0x045f, 2278), (0x2013, 2278), (0x2014, 4096), (0x2018, 910), @@ -191,6 +715,7 @@ pub static SANS_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -232,13 +757,187 @@ pub static SANS_BOLD_ITALIC: [u16; 560] = [ 3186, 2502, 2732, 2278, 1852, 3216, 2086, 1138, 3906, 3898, 2958, 2958, 2278, 2502, 2502, 2278, 2048, 2642, 1982, 2958, 2958, 2732, 2732, 2278, 2278, 1138, 3198, 2502, 2958, 1594, 2732, 2278, ]; -pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3498), + (0x0389, 3710), + (0x038a, 1938), + (0x038c, 3458), + (0x038e, 3810), + (0x038f, 3470), + (0x0390, 1138), + (0x0391, 2958), + (0x0392, 2958), + (0x0393, 2498), + (0x0394, 2750), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2958), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2692), + (0x039f, 3186), + (0x03a0, 2940), + (0x03a1, 2732), + (0x03a3, 2418), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3368), + (0x03a7, 2732), + (0x03a8, 3394), (0x03a9, 3198), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2540), + (0x03ad, 1960), + (0x03ae, 2502), + (0x03af, 1138), + (0x03b0, 2422), + (0x03b1, 2540), + (0x03b2, 2542), + (0x03b3, 2278), + (0x03b4, 2500), + (0x03b5, 1960), + (0x03b6, 2014), + (0x03b7, 2502), + (0x03b8, 2284), + (0x03b9, 1138), + (0x03ba, 2318), + (0x03bb, 2278), (0x03bc, 2470), + (0x03bd, 2278), + (0x03be, 1842), + (0x03bf, 2502), + (0x03c0, 2916), + (0x03c1, 2480), + (0x03c2, 2180), + (0x03c3, 2718), + (0x03c4, 1674), + (0x03c5, 2422), + (0x03c6, 2882), + (0x03c7, 2366), + (0x03c8, 3166), + (0x03c9, 3418), + (0x03ca, 1138), + (0x03cb, 2422), + (0x03cc, 2502), + (0x03cd, 2422), + (0x03ce, 3418), + (0x03cf, 2958), + (0x03d0, 2310), + (0x03d1, 2422), + (0x03d2, 3066), + (0x03d3, 4066), + (0x03d4, 3066), + (0x03d5, 3102), + (0x03d6, 3418), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3628), + (0x0403, 2514), + (0x0404, 2944), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2278), + (0x0409, 4482), + (0x040a, 4268), + (0x040b, 3496), + (0x040c, 2518), + (0x040d, 2946), + (0x040e, 2774), + (0x040f, 2946), + (0x0410, 2958), + (0x0411, 2900), + (0x0412, 2958), + (0x0413, 2514), + (0x0414, 2958), + (0x0415, 2732), + (0x0416, 3798), + (0x0417, 2632), + (0x0418, 2946), + (0x0419, 2946), + (0x041a, 2518), + (0x041b, 2814), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2946), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2774), + (0x0424, 3198), + (0x0425, 2732), + (0x0426, 2986), + (0x0427, 2900), + (0x0428, 4010), + (0x0429, 4050), + (0x042a, 3498), + (0x042b, 4096), + (0x042c, 2900), + (0x042d, 2944), + (0x042e, 4268), + (0x042f, 2986), + (0x0430, 2278), + (0x0431, 2536), + (0x0432, 2474), + (0x0433, 2186), + (0x0434, 2532), + (0x0435, 2278), + (0x0436, 3016), + (0x0437, 2090), + (0x0438, 2502), + (0x0439, 2502), + (0x043a, 2076), + (0x043b, 2548), + (0x043c, 3030), + (0x043d, 2474), + (0x043e, 2502), + (0x043f, 2502), + (0x0440, 2502), + (0x0441, 2278), + (0x0442, 3642), + (0x0443, 2278), + (0x0444, 3624), + (0x0445, 2278), + (0x0446, 2646), + (0x0447, 2388), + (0x0448, 3642), + (0x0449, 3830), + (0x044a, 2896), + (0x044b, 3498), + (0x044c, 2432), + (0x044d, 2260), + (0x044e, 3542), + (0x044f, 2412), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2502), + (0x0453, 1922), + (0x0454, 2306), + (0x0455, 2278), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 3970), + (0x045a, 3710), + (0x045b, 2502), + (0x045c, 2076), + (0x045d, 2502), + (0x045e, 2278), + (0x045f, 2502), (0x2013, 2278), (0x2014, 4096), (0x2018, 1138), @@ -252,6 +951,7 @@ pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -293,13 +993,187 @@ pub static SERIF: [u16; 560] = [ 2958, 2048, 2958, 2048, 1138, 2048, 1298, 1138, 3170, 3164, 2958, 2732, 2048, 2502, 2502, 1594, 1818, 2186, 1628, 2732, 2958, 2970, 2502, 1818, 1594, 1138, 2876, 2048, 2732, 1364, 2958, 2048, ]; -pub static SERIF_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1138), + (0x0388, 2842), + (0x0389, 3310), + (0x038a, 1684), + (0x038c, 2958), + (0x038e, 3342), + (0x038f, 3044), + (0x0390, 1102), + (0x0391, 2958), + (0x0392, 2732), + (0x0393, 2368), + (0x0394, 2634), + (0x0395, 2502), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 2958), + (0x0399, 1364), + (0x039a, 2958), + (0x039b, 2970), + (0x039c, 3642), + (0x039d, 2958), + (0x039e, 2634), + (0x039f, 2958), + (0x03a0, 2958), + (0x03a1, 2278), + (0x03a3, 2384), + (0x03a4, 2502), + (0x03a5, 2958), + (0x03a6, 2994), + (0x03a7, 2958), + (0x03a8, 3022), (0x03a9, 3044), + (0x03aa, 1364), + (0x03ab, 2958), + (0x03ac, 2146), + (0x03ad, 1720), + (0x03ae, 2142), + (0x03af, 1102), + (0x03b0, 2028), + (0x03b1, 2146), + (0x03b2, 2084), + (0x03b3, 1810), + (0x03b4, 1930), + (0x03b5, 1720), + (0x03b6, 1696), + (0x03b7, 2142), + (0x03b8, 1962), + (0x03b9, 1102), + (0x03ba, 2064), + (0x03bb, 1986), (0x03bc, 2196), + (0x03bd, 1852), + (0x03be, 1826), + (0x03bf, 2048), + (0x03c0, 2068), + (0x03c1, 2044), + (0x03c2, 1622), + (0x03c3, 2208), + (0x03c4, 1646), + (0x03c5, 2028), + (0x03c6, 2364), + (0x03c7, 1818), + (0x03c8, 2564), + (0x03c9, 2696), + (0x03ca, 1102), + (0x03cb, 2028), + (0x03cc, 2048), + (0x03cd, 2028), + (0x03ce, 2696), + (0x03cf, 2958), + (0x03d0, 2084), + (0x03d1, 2028), + (0x03d2, 2958), + (0x03d3, 3646), + (0x03d4, 2958), + (0x03d5, 2156), + (0x03d6, 2696), + (0x0400, 2502), + (0x0401, 2502), + (0x0402, 3080), + (0x0403, 2368), + (0x0404, 2704), + (0x0405, 2278), + (0x0406, 1364), + (0x0407, 1364), + (0x0408, 1594), + (0x0409, 3572), + (0x040a, 3572), + (0x040b, 3036), + (0x040c, 2732), + (0x040d, 2958), + (0x040e, 2900), + (0x040f, 2958), + (0x0410, 2958), + (0x0411, 2352), + (0x0412, 2732), + (0x0413, 2368), + (0x0414, 2794), + (0x0415, 2502), + (0x0416, 3670), + (0x0417, 2052), + (0x0418, 2958), + (0x0419, 2958), + (0x041a, 2732), + (0x041b, 2778), + (0x041c, 3642), + (0x041d, 2958), + (0x041e, 2958), + (0x041f, 2958), + (0x0420, 2278), + (0x0421, 2732), + (0x0422, 2502), + (0x0423, 2900), + (0x0424, 3236), + (0x0425, 2958), + (0x0426, 2958), + (0x0427, 2662), + (0x0428, 4132), + (0x0429, 4132), + (0x042a, 2892), + (0x042b, 3572), + (0x042c, 2352), + (0x042d, 2704), + (0x042e, 4210), + (0x042f, 2732), + (0x0430, 1818), + (0x0431, 2084), + (0x0432, 1934), + (0x0433, 1680), + (0x0434, 2084), + (0x0435, 1818), + (0x0436, 2830), + (0x0437, 1618), + (0x0438, 2192), + (0x0439, 2192), + (0x043a, 1990), + (0x043b, 2044), + (0x043c, 2592), + (0x043d, 2192), + (0x043e, 2048), + (0x043f, 2192), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 1790), + (0x0443, 2048), + (0x0444, 2654), + (0x0445, 2048), + (0x0446, 2192), + (0x0447, 2060), + (0x0448, 3154), + (0x0449, 3154), + (0x044a, 2118), + (0x044b, 2752), + (0x044c, 1868), + (0x044d, 1758), + (0x044e, 3060), + (0x044f, 1884), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 1978), + (0x0453, 1680), + (0x0454, 1758), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2978), + (0x045a, 2962), + (0x045b, 2048), + (0x045c, 1990), + (0x045d, 2192), + (0x045e, 2048), + (0x045f, 2192), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -313,6 +1187,7 @@ pub static SERIF_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2310), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -354,13 +1229,187 @@ pub static SERIF_BOLD: [u16; 560] = [ 3186, 2048, 2958, 2048, 1138, 2278, 1478, 1364, 3370, 3370, 2958, 2958, 2048, 2732, 2732, 1594, 1818, 2180, 1908, 2732, 2958, 2936, 2732, 1818, 2048, 1364, 3378, 2278, 2958, 1818, 2958, 2048, ]; -pub static SERIF_BOLD_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_BOLD_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3272), + (0x0389, 3724), + (0x038a, 2138), + (0x038c, 3186), + (0x038e, 3542), + (0x038f, 3280), + (0x0390, 1270), + (0x0391, 2958), + (0x0392, 2732), + (0x0393, 2606), + (0x0394, 2568), + (0x0395, 2732), + (0x0396, 2732), + (0x0397, 3186), + (0x0398, 3186), + (0x0399, 1594), + (0x039a, 3186), + (0x039b, 2936), + (0x039c, 3866), + (0x039d, 2958), + (0x039e, 2774), + (0x039f, 3186), + (0x03a0, 3186), + (0x03a1, 2502), + (0x03a3, 2678), + (0x03a4, 2732), + (0x03a5, 2958), + (0x03a6, 3396), + (0x03a7, 2958), + (0x03a8, 3194), (0x03a9, 3280), + (0x03aa, 1594), + (0x03ab, 2958), + (0x03ac, 2286), + (0x03ad, 1750), + (0x03ae, 2326), + (0x03af, 1270), + (0x03b0, 2126), + (0x03b1, 2286), + (0x03b2, 2158), + (0x03b3, 1916), + (0x03b4, 2126), + (0x03b5, 1750), + (0x03b6, 1692), + (0x03b7, 2326), + (0x03b8, 2138), + (0x03b9, 1270), + (0x03ba, 2274), + (0x03bb, 2012), (0x03bc, 2322), + (0x03bd, 1830), + (0x03be, 1826), + (0x03bf, 2048), + (0x03c0, 2244), + (0x03c1, 2192), + (0x03c2, 1728), + (0x03c3, 2228), + (0x03c4, 1888), + (0x03c5, 2126), + (0x03c6, 2556), + (0x03c7, 1892), + (0x03c8, 2818), + (0x03c9, 2994), + (0x03ca, 1270), + (0x03cb, 2126), + (0x03cc, 2048), + (0x03cd, 2126), + (0x03ce, 2994), + (0x03cf, 3186), + (0x03d0, 2158), + (0x03d1, 2454), + (0x03d2, 2958), + (0x03d3, 3782), + (0x03d4, 2958), + (0x03d5, 2556), + (0x03d6, 2994), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3276), + (0x0403, 2606), + (0x0404, 2778), + (0x0405, 2278), + (0x0406, 1594), + (0x0407, 1594), + (0x0408, 2048), + (0x0409, 4108), + (0x040a, 4104), + (0x040b, 3276), + (0x040c, 2970), + (0x040d, 3186), + (0x040e, 3006), + (0x040f, 3186), + (0x0410, 2958), + (0x0411, 2708), + (0x0412, 2732), + (0x0413, 2606), + (0x0414, 2818), + (0x0415, 2732), + (0x0416, 4050), + (0x0417, 2162), + (0x0418, 3186), + (0x0419, 3186), + (0x041a, 2970), + (0x041b, 3052), + (0x041c, 3866), + (0x041d, 3186), + (0x041e, 3186), + (0x041f, 3186), + (0x0420, 2502), + (0x0421, 2958), + (0x0422, 2732), + (0x0423, 3006), + (0x0424, 3518), + (0x0425, 2958), + (0x0426, 3186), + (0x0427, 3006), + (0x0428, 4498), + (0x0429, 4498), + (0x042a, 3134), + (0x042b, 4022), + (0x042c, 2708), + (0x042d, 2778), + (0x042e, 4612), + (0x042f, 2958), + (0x0430, 2048), + (0x0431, 2048), + (0x0432, 2212), + (0x0433, 1860), + (0x0434, 2072), + (0x0435, 1818), + (0x0436, 2970), + (0x0437, 1646), + (0x0438, 2360), + (0x0439, 2360), + (0x043a, 2360), + (0x043b, 2298), + (0x043c, 2790), + (0x043d, 2360), + (0x043e, 2048), + (0x043f, 2360), + (0x0440, 2278), + (0x0441, 1818), + (0x0442, 2012), + (0x0443, 2048), + (0x0444, 2834), + (0x0445, 2048), + (0x0446, 2360), + (0x0447, 2310), + (0x0448, 3458), + (0x0449, 3458), + (0x044a, 2388), + (0x044b, 3198), + (0x044c, 2166), + (0x044d, 1770), + (0x044e, 3130), + (0x044f, 2216), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 2200), + (0x0453, 1860), + (0x0454, 1770), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1364), + (0x0459, 3194), + (0x045a, 3264), + (0x045b, 2278), + (0x045c, 2360), + (0x045d, 2360), + (0x045e, 2048), + (0x045f, 2360), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -374,6 +1423,7 @@ pub static SERIF_BOLD_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2334), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -415,13 +1465,187 @@ pub static SERIF_ITALIC: [u16; 560] = [ 2958, 2048, 2278, 1818, 1138, 2048, 1138, 1138, 3082, 3088, 2502, 2732, 2048, 2278, 2278, 1594, 1594, 1994, 1786, 2502, 2958, 2474, 2502, 1818, 1818, 1138, 2900, 2048, 2502, 1594, 2278, 1818, ]; -pub static SERIF_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2502), + (0x0387, 1364), + (0x0388, 2782), + (0x0389, 3244), + (0x038a, 1646), + (0x038c, 2958), + (0x038e, 2704), + (0x038f, 2974), + (0x0390, 1138), + (0x0391, 2502), + (0x0392, 2502), + (0x0393, 2330), + (0x0394, 2404), + (0x0395, 2502), + (0x0396, 2278), + (0x0397, 2958), + (0x0398, 2958), + (0x0399, 1364), + (0x039a, 2732), + (0x039b, 2474), + (0x039c, 3412), + (0x039d, 2732), + (0x039e, 2618), + (0x039f, 2958), + (0x03a0, 2958), + (0x03a1, 2502), + (0x03a3, 2434), + (0x03a4, 2278), + (0x03a5, 2278), + (0x03a6, 3118), + (0x03a7, 2502), + (0x03a8, 2880), (0x03a9, 2962), + (0x03aa, 1364), + (0x03ab, 2278), + (0x03ac, 2150), + (0x03ad, 1614), + (0x03ae, 2032), + (0x03af, 1138), + (0x03b0, 1880), + (0x03b1, 2150), + (0x03b2, 2040), + (0x03b3, 1614), + (0x03b4, 1904), + (0x03b5, 1614), + (0x03b6, 1658), + (0x03b7, 2032), + (0x03b8, 2012), + (0x03b9, 1138), + (0x03ba, 1966), + (0x03bb, 1774), (0x03bc, 2056), + (0x03bd, 1818), + (0x03be, 1754), + (0x03bf, 2048), + (0x03c0, 2052), + (0x03c1, 1966), + (0x03c2, 1662), + (0x03c3, 2020), + (0x03c4, 1466), + (0x03c5, 1880), + (0x03c6, 2266), + (0x03c7, 1802), + (0x03c8, 2544), + (0x03c9, 2892), + (0x03ca, 1138), + (0x03cb, 1880), + (0x03cc, 2048), + (0x03cd, 1880), + (0x03ce, 2858), + (0x03cf, 2732), + (0x03d0, 2040), + (0x03d1, 2010), + (0x03d2, 2606), + (0x03d3, 3108), + (0x03d4, 2606), + (0x03d5, 2120), + (0x03d6, 2892), + (0x0400, 2502), + (0x0401, 2502), + (0x0402, 3264), + (0x0403, 2330), + (0x0404, 2740), + (0x0405, 2048), + (0x0406, 1364), + (0x0407, 1364), + (0x0408, 1818), + (0x0409, 3798), + (0x040a, 3772), + (0x040b, 3272), + (0x040c, 2720), + (0x040d, 2958), + (0x040e, 2756), + (0x040f, 2958), + (0x0410, 2502), + (0x0411, 2416), + (0x0412, 2502), + (0x0413, 2330), + (0x0414, 2606), + (0x0415, 2502), + (0x0416, 3752), + (0x0417, 2028), + (0x0418, 2958), + (0x0419, 2958), + (0x041a, 2720), + (0x041b, 2774), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 2958), + (0x041f, 2958), + (0x0420, 2502), + (0x0421, 2732), + (0x0422, 2278), + (0x0423, 2756), + (0x0424, 3294), + (0x0425, 2502), + (0x0426, 2958), + (0x0427, 2822), + (0x0428, 4272), + (0x0429, 4272), + (0x042a, 2768), + (0x042b, 3572), + (0x042c, 2416), + (0x042d, 2682), + (0x042e, 4228), + (0x042f, 2692), + (0x0430, 2048), + (0x0431, 2060), + (0x0432, 1778), + (0x0433, 1598), + (0x0434, 2072), + (0x0435, 1818), + (0x0436, 3810), + (0x0437, 1598), + (0x0438, 2048), + (0x0439, 2048), + (0x043a, 1916), + (0x043b, 1786), + (0x043c, 2614), + (0x043d, 2032), + (0x043e, 2048), + (0x043f, 2048), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 2958), + (0x0443, 1818), + (0x0444, 2908), + (0x0445, 1818), + (0x0446, 2048), + (0x0447, 1954), + (0x0448, 3092), + (0x0449, 3092), + (0x044a, 2126), + (0x044b, 2774), + (0x044c, 1864), + (0x044d, 1806), + (0x044e, 2888), + (0x044f, 1904), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 1962), + (0x0453, 1544), + (0x0454, 1786), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2782), + (0x045a, 2916), + (0x045b, 2048), + (0x045c, 1916), + (0x045d, 2048), + (0x045e, 1818), + (0x045f, 2048), (0x2013, 2048), (0x2014, 3642), (0x2018, 1364), @@ -435,6 +1659,7 @@ pub static SERIF_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2764), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -476,13 +1701,187 @@ pub static SERIF_BOLD_ITALIC: [u16; 560] = [ 2958, 2048, 2502, 1818, 1138, 2278, 1138, 1138, 2998, 3010, 2732, 2732, 2048, 2502, 2502, 1594, 1594, 2084, 1908, 2732, 2958, 2732, 2732, 1818, 2048, 1138, 3214, 2048, 2732, 1594, 2502, 1818, ]; -pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2732), + (0x0387, 1364), + (0x0388, 3182), + (0x0389, 3666), + (0x038a, 2072), + (0x038c, 2994), + (0x038e, 3072), + (0x038f, 3216), + (0x0390, 1138), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2474), + (0x0394, 2568), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 3186), + (0x0398, 3126), + (0x0399, 1594), + (0x039a, 2732), + (0x039b, 2732), + (0x039c, 3642), + (0x039d, 2958), + (0x039e, 2700), + (0x039f, 2958), + (0x03a0, 3186), + (0x03a1, 2502), + (0x03a3, 2536), + (0x03a4, 2502), + (0x03a5, 2502), + (0x03a6, 3232), + (0x03a7, 2732), + (0x03a8, 3276), (0x03a9, 3056), + (0x03aa, 1594), + (0x03ab, 2502), + (0x03ac, 2266), + (0x03ad, 1680), + (0x03ae, 2248), + (0x03af, 1138), + (0x03b0, 2006), + (0x03b1, 2266), + (0x03b2, 2098), + (0x03b3, 1704), + (0x03b4, 2098), + (0x03b5, 1680), + (0x03b6, 1806), + (0x03b7, 2248), + (0x03b8, 2126), + (0x03b9, 1138), + (0x03ba, 2170), + (0x03bb, 1818), (0x03bc, 2278), + (0x03bd, 1818), + (0x03be, 1840), + (0x03bf, 2048), + (0x03c0, 2236), + (0x03c1, 2110), + (0x03c2, 1826), + (0x03c3, 2208), + (0x03c4, 1818), + (0x03c5, 2006), + (0x03c6, 2396), + (0x03c7, 1904), + (0x03c8, 2736), + (0x03c9, 2962), + (0x03ca, 1138), + (0x03cb, 2006), + (0x03cc, 2048), + (0x03cd, 2006), + (0x03ce, 2962), + (0x03cf, 2732), + (0x03d0, 2098), + (0x03d1, 2160), + (0x03d2, 2968), + (0x03d3, 3444), + (0x03d4, 2968), + (0x03d5, 2370), + (0x03d6, 3098), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3350), + (0x0403, 2474), + (0x0404, 2692), + (0x0405, 2278), + (0x0406, 1594), + (0x0407, 1594), + (0x0408, 2048), + (0x0409, 3720), + (0x040a, 3998), + (0x040b, 3322), + (0x040c, 2778), + (0x040d, 3186), + (0x040e, 2850), + (0x040f, 3186), + (0x0410, 2732), + (0x0411, 2602), + (0x0412, 2732), + (0x0413, 2474), + (0x0414, 2798), + (0x0415, 2732), + (0x0416, 3760), + (0x0417, 2084), + (0x0418, 3186), + (0x0419, 3186), + (0x041a, 2778), + (0x041b, 2810), + (0x041c, 3642), + (0x041d, 3186), + (0x041e, 2958), + (0x041f, 3186), + (0x0420, 2502), + (0x0421, 2732), + (0x0422, 2502), + (0x0423, 2850), + (0x0424, 3482), + (0x0425, 2732), + (0x0426, 3186), + (0x0427, 3064), + (0x0428, 4612), + (0x0429, 4612), + (0x042a, 3114), + (0x042b, 3970), + (0x042c, 2596), + (0x042d, 2666), + (0x042e, 4326), + (0x042f, 2822), + (0x0430, 2048), + (0x0431, 2044), + (0x0432, 1830), + (0x0433, 1512), + (0x0434, 2032), + (0x0435, 1818), + (0x0436, 3900), + (0x0437, 1598), + (0x0438, 2278), + (0x0439, 2278), + (0x043a, 2076), + (0x043b, 2102), + (0x043c, 2806), + (0x043d, 2274), + (0x043e, 2048), + (0x043f, 2278), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 3186), + (0x0443, 1818), + (0x0444, 2932), + (0x0445, 2048), + (0x0446, 2278), + (0x0447, 2150), + (0x0448, 3412), + (0x0449, 3412), + (0x044a, 2158), + (0x044b, 2842), + (0x044c, 1872), + (0x044d, 1700), + (0x044e, 2990), + (0x044f, 2110), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 2142), + (0x0453, 1610), + (0x0454, 1700), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2700), + (0x045a, 2920), + (0x045b, 2278), + (0x045c, 2076), + (0x045d, 2278), + (0x045e, 1818), + (0x045f, 2278), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -496,6 +1895,7 @@ pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2482), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -537,13 +1937,186 @@ pub static MONO: [u16; 560] = [ 2458, 2458, 2458, 2458, 2458, 2458, 2458, 0, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, ]; -pub static MONO_SPARSE: [(u32, u16); 22] = [ +pub static MONO_SPARSE: [(u32, u16); 196] = [ (0x00b0, 2458), (0x00b1, 2458), (0x00b2, 2458), (0x00b3, 2458), + (0x0384, 2458), + (0x0385, 2458), + (0x0386, 2458), + (0x0387, 2458), + (0x0388, 2458), + (0x0389, 2458), + (0x038a, 2458), + (0x038c, 2458), + (0x038e, 2458), + (0x038f, 2458), + (0x0390, 2458), + (0x0391, 2458), + (0x0392, 2458), + (0x0393, 2458), + (0x0394, 2458), + (0x0395, 2458), + (0x0396, 2458), + (0x0397, 2458), + (0x0398, 2458), + (0x0399, 2458), + (0x039a, 2458), + (0x039b, 2458), + (0x039c, 2458), + (0x039d, 2458), + (0x039e, 2458), + (0x039f, 2458), + (0x03a0, 2458), + (0x03a1, 2458), + (0x03a3, 2458), + (0x03a4, 2458), + (0x03a5, 2458), + (0x03a6, 2458), + (0x03a7, 2458), + (0x03a8, 2458), (0x03a9, 2458), + (0x03aa, 2458), + (0x03ab, 2458), + (0x03ac, 2458), + (0x03ad, 2458), + (0x03ae, 2458), + (0x03af, 2458), + (0x03b0, 2458), + (0x03b1, 2458), + (0x03b2, 2458), + (0x03b3, 2458), + (0x03b4, 2458), + (0x03b5, 2458), + (0x03b6, 2458), + (0x03b7, 2458), + (0x03b8, 2458), + (0x03b9, 2458), + (0x03ba, 2458), + (0x03bb, 2458), (0x03bc, 2458), + (0x03bd, 2458), + (0x03be, 2458), + (0x03bf, 2458), + (0x03c0, 2458), + (0x03c1, 2458), + (0x03c2, 2458), + (0x03c3, 2458), + (0x03c4, 2458), + (0x03c5, 2458), + (0x03c6, 2458), + (0x03c7, 2458), + (0x03c8, 2458), + (0x03c9, 2458), + (0x03ca, 2458), + (0x03cb, 2458), + (0x03cc, 2458), + (0x03cd, 2458), + (0x03ce, 2458), + (0x03d0, 2458), + (0x03d1, 2458), + (0x03d2, 2458), + (0x03d3, 2458), + (0x03d4, 2458), + (0x03d5, 2458), + (0x03d6, 2458), + (0x0400, 2458), + (0x0401, 2458), + (0x0402, 2458), + (0x0403, 2458), + (0x0404, 2458), + (0x0405, 2458), + (0x0406, 2458), + (0x0407, 2458), + (0x0408, 2458), + (0x0409, 2458), + (0x040a, 2458), + (0x040b, 2458), + (0x040c, 2458), + (0x040d, 2458), + (0x040e, 2458), + (0x040f, 2458), + (0x0410, 2458), + (0x0411, 2458), + (0x0412, 2458), + (0x0413, 2458), + (0x0414, 2458), + (0x0415, 2458), + (0x0416, 2458), + (0x0417, 2458), + (0x0418, 2458), + (0x0419, 2458), + (0x041a, 2458), + (0x041b, 2458), + (0x041c, 2458), + (0x041d, 2458), + (0x041e, 2458), + (0x041f, 2458), + (0x0420, 2458), + (0x0421, 2458), + (0x0422, 2458), + (0x0423, 2458), + (0x0424, 2458), + (0x0425, 2458), + (0x0426, 2458), + (0x0427, 2458), + (0x0428, 2458), + (0x0429, 2458), + (0x042a, 2458), + (0x042b, 2458), + (0x042c, 2458), + (0x042d, 2458), + (0x042e, 2458), + (0x042f, 2458), + (0x0430, 2458), + (0x0431, 2458), + (0x0432, 2458), + (0x0433, 2458), + (0x0434, 2458), + (0x0435, 2458), + (0x0436, 2458), + (0x0437, 2458), + (0x0438, 2458), + (0x0439, 2458), + (0x043a, 2458), + (0x043b, 2458), + (0x043c, 2458), + (0x043d, 2458), + (0x043e, 2458), + (0x043f, 2458), + (0x0440, 2458), + (0x0441, 2458), + (0x0442, 2458), + (0x0443, 2458), + (0x0444, 2458), + (0x0445, 2458), + (0x0446, 2458), + (0x0447, 2458), + (0x0448, 2458), + (0x0449, 2458), + (0x044a, 2458), + (0x044b, 2458), + (0x044c, 2458), + (0x044d, 2458), + (0x044e, 2458), + (0x044f, 2458), + (0x0450, 2458), + (0x0451, 2458), + (0x0452, 2458), + (0x0453, 2458), + (0x0454, 2458), + (0x0455, 2458), + (0x0456, 2458), + (0x0457, 2458), + (0x0458, 2458), + (0x0459, 2458), + (0x045a, 2458), + (0x045b, 2458), + (0x045c, 2458), + (0x045d, 2458), + (0x045e, 2458), + (0x045f, 2458), (0x2013, 2458), (0x2014, 2458), (0x2018, 2458), @@ -557,6 +2130,7 @@ pub static MONO_SPARSE: [(u32, u16); 22] = [ (0x2192, 2458), (0x2193, 2458), (0x2206, 2458), + (0x2212, 2458), (0x2260, 2458), (0x2264, 2458), (0x2265, 2458), diff --git a/crates/exav-render/src/formats/dwg/mod.rs b/crates/exav-render/src/formats/dwg/mod.rs index 5916d10..1a76103 100644 --- a/crates/exav-render/src/formats/dwg/mod.rs +++ b/crates/exav-render/src/formats/dwg/mod.rs @@ -18,6 +18,7 @@ mod clip; mod curves; +mod dimension; mod fill; mod font; mod gradient; @@ -31,6 +32,7 @@ pub mod stroke_font; mod tessellate; mod text; mod tiles; +mod wide; pub use curves::{flatten_arc, flatten_circle, flatten_ellipse}; pub use fill::fill_even_odd; @@ -39,8 +41,10 @@ pub use font::{ }; pub use hatch::nesting_depths; pub use palette::Palette; -pub use scene::{Affine, Scene, FILL_BYTES, STROKE_BYTES}; -pub use tessellate::{expand_bulges, LayerInfo, LayoutInfo, Tessellator, Warnings, SCENE_BUDGET}; +pub use scene::{Affine, Scene, FILL_BYTES, FILL_EDGE, STROKE_BYTES}; +pub use tessellate::{ + expand_bulges, LayerInfo, LayoutInfo, Sheet, Tessellator, Warnings, SCENE_BUDGET, +}; pub use text::{mtext_lines, HAlign, TextRun, VAlign, TEXT_RECORD_BYTES}; /// Why a file did not parse. @@ -198,24 +202,31 @@ impl Document { String::new() } }; + // Everything read from the scene before `buffers`, which empties it. let (texts, text_strings) = t.scene.text_buffers(); + let (origin, extents) = (t.scene.origin(), t.scene.extents_local()); + let clips = t.scene.clip_buffer(); + let b = t.scene.buffers(); Drawing { layout: drawn, - strokes: t.scene.stroke_buffer(), - fills: t.scene.fill_buffer(), + strokes: b.strokes, + fills: b.fills, texts, text_strings, - origin: t.scene.origin(), - extents: t.scene.extents_local(), + origin, + extents, max_order: t.scene.max_order(), - opaque_strokes: t.scene.opaque_strokes(), - opaque_fills: t.scene.opaque_fills(), - stroke_tiles: t.scene.stroke_tiles(), - fill_tiles: t.scene.fill_tiles(), - opaque_stroke_tiles: t.scene.opaque_stroke_tiles(), - opaque_fill_tiles: t.scene.opaque_fill_tiles(), + opaque_strokes: b.opaque_strokes, + opaque_fills: b.opaque_fills, + clips, + stroke_tiles: b.stroke_tiles, + fill_tiles: b.fill_tiles, + opaque_stroke_tiles: b.opaque_stroke_tiles, + opaque_fill_tiles: b.opaque_fill_tiles, + translucent_runs: b.translucent_runs, layers: t.layers, warnings: t.warnings, + sheet: t.sheet, } } } @@ -236,8 +247,9 @@ pub struct Drawing { /// for does not exist. pub layout: String, /// Stroke instances, [`STROKE_BYTES`] each: `f32 x0, y0, x1, y1 | u32 rgba - /// | u32 attr | u32 order`, little-endian. `attr` packs the layer id, the - /// lineweight and the flags (see `scene.rs`). + /// | u32 attr | u32 order | f32 bulge`, little-endian. `attr` packs the + /// layer id, the lineweight and the flags (see `scene.rs`); `bulge` is 0 + /// for a line, else the stroke is a circular arc (`Stroke::bulge`). pub strokes: Vec, /// Fill vertices, [`FILL_BYTES`] each: `f32 x, y | u32 rgba | u32 attr | /// u32 order`. @@ -245,12 +257,16 @@ pub struct Drawing { /// Text placement records, [`TEXT_RECORD_BYTES`] each, indexing into /// `text_strings`: `f32 x, y, height, rotation, widthFactor, oblique | u32 /// rgba, attr, strOffset | u16 strLen | u8 hAlign, vAlign | u32 order | u8 - /// face | 3 bytes padding`. Only runs in an outline face appear here; a + /// face | u8 padding | u16 clip`. Only runs in an outline face appear here; a /// stroke-font run is drawn as strokes, so that the drawing's lineweight /// decides how thick its letters are. pub texts: Vec, /// UTF-8, every run's characters back to back. pub text_strings: Vec, + /// The regions text runs are clipped to (a viewport's window, an XCLIP + /// boundary), which a run's `clip` numbers from 1: for each, `u32` + /// triangle count, then `f32 x, y` per corner relative to `origin`. + pub clips: Vec, /// World coordinates the buffers are relative to. pub origin: [f64; 2], /// `[min_x, min_y, max_x, max_y]`, relative to `origin`. @@ -258,22 +274,29 @@ pub struct Drawing { /// Highest draw-order index, for normalising depth. pub max_order: u32, /// Leading strokes and fill vertices that are opaque; the rest are - /// translucent and are drawn after, without writing depth. + /// translucent, in draw order, and are drawn after. pub opaque_strokes: usize, pub opaque_fills: usize, - /// Spatial buckets: `u32 start, count | f32 min_x, min_y, max_x, max_y`, - /// 24 bytes each, `start` and `count` in strokes (in vertices for fills). - /// The box is the true extent of the contents, which can reach outside - /// the bucket's grid square. + /// Tiles: `u32 start, count | f32 min_x, min_y, max_x, max_y`, 24 bytes + /// each, `start` and `count` in strokes (in vertices for fills). The + /// opaque half's are spatial buckets, the translucent half's runs of it + /// in draw order. The box is the true extent of the contents, which can + /// reach outside the bucket's grid square. pub stroke_tiles: Vec, pub fill_tiles: Vec, /// Leading entries of each table covering the opaque half. pub opaque_stroke_tiles: usize, pub opaque_fill_tiles: usize, + /// The order of the translucent pass: for each run, `u32` 1 for fill + /// tiles or 0 for stroke tiles, then the first tile and the one past the + /// last, indices into that kind's table. + pub translucent_runs: Vec, /// The layer table; a vertex's layer id indexes it. pub layers: Vec, /// What could not be drawn. pub warnings: Warnings, + /// What the tab is drawn on. + pub sheet: Sheet, } impl Drawing { @@ -310,11 +333,38 @@ impl Drawing { format!("[{}]", items.join(",")) } + /// [`Sheet`] as JSON: `{"mmPerUnit", "lineweights"}`. + pub fn sheet_json(&self) -> String { + let s = &self.sheet; + // The paper relative to `origin`, as the buffers are. + let paper = match s.paper { + Some(p) => format!( + "[{},{},{},{}]", + json_number(p[0] - self.origin[0]), + json_number(p[1] - self.origin[1]), + json_number(p[2] - self.origin[0]), + json_number(p[3] - self.origin[1]) + ), + None => "null".to_string(), + }; + format!( + r#"{{"mmPerUnit":{},"lineweights":{},"paper":{paper}}}"#, + json_number(s.mm_per_unit), + s.lineweights + ) + } + /// [`Warnings`] as JSON. All zeroes means everything was drawn. pub fn warnings_json(&self) -> String { let w = &self.warnings; + let not_drawn: Vec = w + .not_drawn + .iter() + .map(|(k, n)| format!("{}:{n}", json_string(k))) + .collect(); + let files: Vec = w.external_files.iter().map(|f| json_string(f)).collect(); format!( - r#"{{"unknownEntities":{},"missingBlocks":{},"textRuns":{},"strokeGlyphs":{},"hatchPatternsMissing":{},"hatchPatternsTruncated":{},"externalReferences":{},"depthExceeded":{},"sceneTruncated":{},"proxyWithoutGraphics":{}}}"#, + r#"{{"unknownEntities":{},"missingBlocks":{},"textRuns":{},"strokeGlyphs":{},"hatchPatternsMissing":{},"hatchPatternsTruncated":{},"externalReferences":{},"depthExceeded":{},"sceneTruncated":{},"proxyWithoutGraphics":{},"notDrawn":{{{}}},"externalFiles":[{}]}}"#, w.unknown_entities, w.missing_blocks, w.text_runs, @@ -324,11 +374,22 @@ impl Drawing { w.external_references, w.depth_exceeded, w.scene_truncated, - w.proxy_without_graphics + w.proxy_without_graphics, + not_drawn.join(","), + files.join(",") ) } } +/// A number JSON can hold: a non-finite one is 0. +fn json_number(v: f64) -> String { + if v.is_finite() { + format!("{v}") + } else { + "0".to_string() + } +} + /// JSON string escaping. Layer and layout names are file data and routinely /// hold quotes, backslashes and control characters. fn json_string(s: &str) -> String { diff --git a/crates/exav-render/src/formats/dwg/scene.rs b/crates/exav-render/src/formats/dwg/scene.rs index ce74c9d..13a41f9 100644 --- a/crates/exav-render/src/formats/dwg/scene.rs +++ b/crates/exav-render/src/formats/dwg/scene.rs @@ -4,6 +4,8 @@ //! precision, then re-emitted as f32 relative to a per-drawing origin. See //! DESIGN.md section 4.4. +use super::tiles::Tile; + /// 2D affine transform, applied as the block/insert stack is walked. /// /// x' = a*x + c*y + e @@ -127,7 +129,8 @@ impl Affine { } } -/// One line segment, pre-transform-applied, still in drawing coordinates. +/// One line segment, or circular arc, pre-transform-applied, still in drawing +/// coordinates. #[derive(Clone, Copy)] pub struct Stroke { pub x0: f64, @@ -139,6 +142,32 @@ pub struct Stroke { pub attr: u32, /// Position in the drawing's draw order. See `Scene::order`. pub order: u32, + /// 0 for a line; for an arc from the first point to the second, the tan + /// of a quarter of its sweep, positive counter-clockwise (DXF's bulge), + /// at most [`MAX_ARC_BULGE`]: the renderer draws it exactly at any zoom. + pub bulge: f32, +} + +/// The largest bulge a stroke carries, a quarter turn's: an arc stroke then +/// lies within the box of its ends widened by a fifth of its chord, and +/// within its sector the shader's test is two cross products. +pub const MAX_ARC_BULGE: f64 = 0.414_213_562_373_095_1; + +impl Stroke { + /// How far an arc stroke's curve stands off its chord, at most. + pub fn sagitta(&self) -> f64 { + (self.bulge.abs() as f64) * (self.x1 - self.x0).hypot(self.y1 - self.y0) / 2.0 + } + + /// The stroke as straight segments: itself, or its arc in `n` pieces. + pub fn flatten(&self, n: usize) -> Vec<[[f64; 2]; 2]> { + let (a, b) = ([self.x0, self.y0], [self.x1, self.y1]); + if self.bulge == 0.0 { + return vec![[a, b]]; + } + let pts = super::curves::bulge_points(a, b, self.bulge as f64, n); + pts.windows(2).map(|w| [w[0], w[1]]).collect() + } } /// One triangle corner. @@ -151,7 +180,7 @@ pub struct FillVertex { pub order: u32, } -pub const STROKE_BYTES: usize = 28; +pub const STROKE_BYTES: usize = 32; pub const FILL_BYTES: usize = 20; /// Fill flag: paint the viewer's background colour instead of the vertex's. @@ -160,6 +189,10 @@ pub const FILL_BYTES: usize = 20; /// background, which the tessellator does not know. The renderer substitutes it. pub const FILL_FLAG_BACKGROUND: u8 = 1; +/// The lineweight code of a fill's edge, a stroke of no width whose fringe is +/// the fill's anti-aliasing (`shaders.ts`). Lineweights stop at 211. +pub const FILL_EDGE: u8 = 255; + /// Flag: this colour is AutoCAD's colour 7, so it flips with the background. /// /// Colour 7 means "white or black, whichever contrasts". A true colour that @@ -178,15 +211,22 @@ pub const FLAG_CONTRAST: u8 = 0x80; /// should average out to. 0 means "do not fade". #[inline] pub fn encode_fade_spacing(spacing: f64) -> u8 { - if !spacing.is_finite() || spacing <= 1.0 { + if !spacing.is_finite() || spacing <= 0.0 { return 0; } - // Logarithmic, eight steps to the octave, which covers millimetres through - // to tens of metres in the seven bits left beside the contrast flag. - let code = (spacing.log2() * 8.0).round(); + // Logarithmic, five steps to the octave from 2^-9: in the seven bits left + // beside the contrast flag, 0.002 to 86,000 units, a pattern a few + // millimetres apart in a drawing in metres to one metres apart in a + // drawing in millimetres. `spacingFade` in shaders.ts decodes it. + let code = ((spacing.log2() + FADE_OCTAVES_BELOW_ONE) * FADE_STEPS_PER_OCTAVE).round(); code.clamp(1.0, 127.0) as u8 } +/// Octaves below a unit `encode_fade_spacing` reaches, and its steps per +/// octave. +const FADE_OCTAVES_BELOW_ONE: f64 = 9.0; +const FADE_STEPS_PER_OCTAVE: f64 = 5.0; + #[inline] pub fn pack_attr(layer: u16, lineweight: u8, flags: u8) -> u32 { (layer as u32) | ((lineweight as u32) << 16) | ((flags as u32) << 24) @@ -217,11 +257,15 @@ pub struct Scene { pub strokes: Vec, pub fills: Vec, pub texts: Vec, + /// Clip regions as triangles, a text run's `clip` indexing them from 1. + pub clips: Vec>, /// Draw order counter. AutoCAD paints entities in this sequence, and later /// entities cover earlier ones: a small solid fill placed over a hatch is /// how a roof vent masks the tiles beneath it. Without it a viewer that /// batches all fills before all strokes draws the hatch back on top. pub order: u32, + /// Fill vertices `edge_fills` has outlined. + edged: usize, min_x: f64, min_y: f64, max_x: f64, @@ -234,7 +278,9 @@ impl Scene { strokes: Vec::new(), fills: Vec::new(), texts: Vec::new(), + clips: Vec::new(), order: 0, + edged: 0, min_x: f64::INFINITY, min_y: f64::INFINITY, max_x: f64::NEG_INFINITY, @@ -245,7 +291,10 @@ impl Scene { /// Record a text run. Only the anchor grows the extents: the rendered width /// depends on font metrics the renderer has and this side does not. pub fn push_text(&mut self, run: super::text::TextRun) { - if !(run.x.is_finite() && run.y.is_finite() && run.height.is_finite()) { + if ![run.x, run.y, run.u[0], run.u[1], run.v[0], run.v[1]] + .iter() + .all(|c| c.is_finite()) + { return; } if run.text.is_empty() { @@ -286,9 +335,65 @@ impl Scene { rgba, attr, order, + bulge: 0.0, }); } + /// Push a circular arc as arc strokes of at most a quarter turn each: + /// centre, radius, start angle and signed sweep (positive + /// counter-clockwise), in drawing coordinates. + #[allow(clippy::too_many_arguments)] + pub fn push_arc( + &mut self, + cx: f64, + cy: f64, + r: f64, + start: f64, + sweep: f64, + rgba: u32, + attr: u32, + ) { + if ![cx, cy, r, start, sweep].iter().all(|v| v.is_finite()) || r <= 0.0 || sweep == 0.0 { + return; + } + let n = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize; + let step = sweep / n as f64; + let bulge = (step / 4.0).tan() as f32; + debug_assert!(f64::from(bulge.abs()) <= MAX_ARC_BULGE + 1e-6); + let at = |a: f64| [cx + r * a.cos(), cy + r * a.sin()]; + // The extents: the arc's ends and the quadrant points it passes. + let (lo, hi) = if sweep > 0.0 { + (start, start + sweep) + } else { + (start + sweep, start) + }; + let first = (lo / std::f64::consts::FRAC_PI_2).ceil() as i64; + let last = (hi / std::f64::consts::FRAC_PI_2).floor() as i64; + for k in first..=last.min(first + 4) { + let p = at(k as f64 * std::f64::consts::FRAC_PI_2); + self.grow(p[0], p[1]); + } + let order = self.order; + for i in 0..n { + let (a, b) = ( + at(start + step * i as f64), + at(start + step * (i + 1) as f64), + ); + self.grow(a[0], a[1]); + self.grow(b[0], b[1]); + self.strokes.push(Stroke { + x0: a[0], + y0: a[1], + x1: b[0], + y1: b[1], + rgba, + attr, + order, + bulge, + }); + } + } + /// Push a flattened polyline as a run of segments. pub fn push_polyline(&mut self, pts: &[[f64; 2]], closed: bool, rgba: u32, attr: u32) { if pts.len() < 2 { @@ -355,6 +460,60 @@ impl Scene { } /// Where each buffer currently ends, for a later `clip_since`. + /// Outline the opaque fills of one colour pushed since the last call with + /// `FILL_EDGE` strokes, whose fringe anti-aliases the fill's edge: drawn + /// at the fill's depth, each is hidden where the fill is and shows only + /// outside it. An edge two triangles share is left out; a translucent, + /// shaded or background fill is not outlined. + pub fn edge_fills(&mut self) { + let from = self.edged.min(self.fills.len()); + self.edged = self.fills.len(); + let tris = self.fills[from..].as_chunks::<3>().0; + if tris.is_empty() { + return; + } + let key = |a: &FillVertex, b: &FillVertex| { + let (pa, pb) = ( + [a.x.to_bits(), a.y.to_bits()], + [b.x.to_bits(), b.y.to_bits()], + ); + let (lo, hi) = if pa <= pb { (pa, pb) } else { (pb, pa) }; + (a.rgba, a.attr, a.order, lo, hi) + }; + let mut edges = Vec::new(); + let mut uses: std::collections::HashMap<_, u32> = std::collections::HashMap::new(); + for t in tris { + let one_colour = t[1].rgba == t[0].rgba && t[2].rgba == t[0].rgba; + let opaque = t[0].rgba >> 24 == 255; + let background = (t[0].attr >> 24) as u8 & FILL_FLAG_BACKGROUND != 0; + if !one_colour || !opaque || background { + continue; + } + for (a, b) in [(&t[0], &t[1]), (&t[1], &t[2]), (&t[2], &t[0])] { + *uses.entry(key(a, b)).or_insert(0) += 1; + edges.push((*a, *b)); + } + } + for (a, b) in edges { + if uses[&key(&a, &b)] != 1 { + continue; + } + let attr = (a.attr & 0xFFFF) + | u32::from(FILL_EDGE) << 16 + | (a.attr & (u32::from(FLAG_CONTRAST) << 24)); + self.strokes.push(Stroke { + x0: a.x, + y0: a.y, + x1: b.x, + y1: b.y, + rgba: a.rgba, + attr, + order: a.order, + bulge: 0.0, + }); + } + } + pub fn mark(&self) -> Mark { Mark { strokes: self.strokes.len(), @@ -381,15 +540,30 @@ impl Scene { } continue; } - shape.clip_segment(a, b, &mut spans); - for (t0, t1) in spans.iter().copied() { - kept.push(Stroke { - x0: a[0] + (b[0] - a[0]) * t0, - y0: a[1] + (b[1] - a[1]) * t0, - x1: a[0] + (b[0] - a[0]) * t1, - y1: a[1] + (b[1] - a[1]) * t1, - ..s - }); + // An arc wholly inside stays one; one crossing the edge is cut as + // the straight pieces of its curve. + if s.bulge != 0.0 { + let [x0, y0, x1, y1] = stroke_box(&s); + if [[x0, y0], [x1, y0], [x1, y1], [x0, y1]] + .iter() + .all(|&c| shape.contains(c)) + { + kept.push(s); + continue; + } + } + for [a, b] in s.flatten(16) { + shape.clip_segment(a, b, &mut spans); + for (t0, t1) in spans.iter().copied() { + kept.push(Stroke { + x0: a[0] + (b[0] - a[0]) * t0, + y0: a[1] + (b[1] - a[1]) * t0, + x1: a[0] + (b[0] - a[0]) * t1, + y1: a[1] + (b[1] - a[1]) * t1, + bulge: 0.0, + ..s + }); + } } } self.strokes.extend(kept); @@ -411,12 +585,87 @@ impl Scene { } } self.fills.extend(kept_fills); - - // A run is kept or dropped whole: its rendered width lives on the other - // side of the worker boundary, so there is no box here to cut against. + // Outlined before they were cut, and the outlines cut with the + // strokes. + self.edged = self.fills.len(); + + // A run's glyphs are laid out on the other side of the worker + // boundary, so it cannot be cut here: one that may cross the shape's + // edge is drawn through a clip region, which the renderer masks it + // with. One wholly outside is dropped, one wholly inside is left be. let tail: Vec = self.texts.drain(mark.texts..).collect(); - self.texts - .extend(tail.into_iter().filter(|r| shape.contains([r.x, r.y]))); + let (min, max) = shape.bounds(); + let mut regions: std::collections::HashMap> = Default::default(); + for mut r in tail { + let reach = text_reach(&r); + if r.x + reach < min[0] + || r.x - reach > max[0] + || r.y + reach < min[1] + || r.y - reach > max[1] + { + continue; + } + let corners = [ + [r.x - reach, r.y - reach], + [r.x + reach, r.y - reach], + [r.x + reach, r.y + reach], + [r.x - reach, r.y + reach], + ]; + if !corners.iter().all(|c| shape.contains(*c)) { + let region = *regions + .entry(r.clip) + .or_insert_with(|| self.clip_region(r.clip, shape)); + match region { + Some(id) => r.clip = id, + // Nothing of the earlier region is inside this one. + None => continue, + } + } + self.texts.push(r); + } + } + + /// A new clip region: the shape, or the part of region `within` it, as + /// its id; None when that is empty, or when ids have run out (the run is + /// then kept or dropped by its anchor, as before regions). + fn clip_region(&mut self, within: u16, shape: &super::clip::ClipShape) -> Option { + let triangles = if within == 0 { + shape.triangles() + } else { + let mut out = Vec::new(); + let mut piece = Vec::new(); + for t in self + .clips + .get(within as usize - 1) + .cloned() + .unwrap_or_default() + { + shape.clip_triangle(t, &mut piece); + out.extend_from_slice(&piece); + } + out + }; + if triangles.is_empty() || self.clips.len() >= u16::MAX as usize { + return None; + } + self.clips.push(triangles); + Some(self.clips.len() as u16) + } + + /// The clip regions as the renderer takes them: for each, a `u32` + /// triangle count, then `f32 x, y` for each corner, relative to + /// `origin()`. + pub fn clip_buffer(&self) -> Vec { + let o = self.origin(); + let mut out = Vec::new(); + for region in &self.clips { + out.extend_from_slice(&(region.len() as u32).to_le_bytes()); + for p in region.iter().flatten() { + out.extend_from_slice(&((p[0] - o[0]) as f32).to_le_bytes()); + out.extend_from_slice(&((p[1] - o[1]) as f32).to_le_bytes()); + } + } + out } /// Declare the extents instead of deriving them from the geometry. @@ -452,6 +701,48 @@ impl Scene { } } + /// Narrow the extents to where the drawing is when a few strays lie far + /// off it (a point left at the origin of a drawing in national grid + /// coordinates): the f32 origin, the fitted view and the culling grid + /// then serve the drawing, not the empty space between it and them, + /// which cost it centimetres of precision and showed it as a speck. + /// The core is what lies between the 0.5th and 99.5th percentiles on + /// each axis, with a margin; the strays are still drawn. + pub fn focus_on_core(&mut self) { + let points = self.strokes.len() * 2 + self.fills.len() + self.texts.len(); + if points < 200 || !self.min_x.is_finite() { + return; + } + let stride = (points / 50_000).max(1); + let all = self + .strokes + .iter() + .flat_map(|s| [[s.x0, s.y0], [s.x1, s.y1]]) + .chain(self.fills.iter().map(|v| [v.x, v.y])) + .chain(self.texts.iter().map(|r| [r.x, r.y])); + let (mut xs, mut ys): (Vec, Vec) = + all.step_by(stride).map(|p| (p[0], p[1])).unzip(); + let span = |v: &mut Vec| -> (f64, f64) { + let n = v.len(); + let (lo, hi) = (n / 200, (n - 1) - n / 200); + let a = *v.select_nth_unstable_by(lo, f64::total_cmp).1; + let b = *v.select_nth_unstable_by(hi, f64::total_cmp).1; + (a, b) + }; + let ((x0, x1), (y0, y1)) = (span(&mut xs), span(&mut ys)); + let margin = (x1 - x0).max(y1 - y0) * 0.25; + let core = [x0 - margin, y0 - margin, x1 + margin, y1 + margin]; + let (full_w, full_h) = (self.max_x - self.min_x, self.max_y - self.min_y); + let (core_w, core_h) = (core[2] - core[0], core[3] - core[1]); + if !(core_w > 0.0 && core_h > 0.0) || (full_w <= 4.0 * core_w && full_h <= 4.0 * core_h) { + return; + } + self.min_x = self.min_x.max(core[0]); + self.min_y = self.min_y.max(core[1]); + self.max_x = self.max_x.min(core[2]); + self.max_y = self.max_y.min(core[3]); + } + /// Highest draw-order value used, for normalising depth. pub fn max_order(&self) -> u32 { self.order @@ -492,173 +783,285 @@ impl Scene { [self.min_x, self.min_y, self.max_x, self.max_y] } - /// Interleaved stroke instance buffer, `STROKE_BYTES` per segment. + /// The geometry as the renderer takes it (see [`Buffers`]). /// - /// Opaque segments come first, then translucent ones, and each half is - /// sorted into spatial buckets so the renderer can skip what a view does - /// not touch. The halves stay whole: the opaque pass writes depth and the - /// translucent pass only tests it, so interleaving them by bucket would let - /// one bucket's opaque geometry paint over another's translucent. - pub fn stroke_buffer(&self) -> Vec { + /// Opaque geometry comes first, sorted into spatial buckets so the + /// renderer can skip what a view does not touch, then translucent + /// geometry in draw order: blending is drawn in order, and the opaque + /// pass must be done before any of it, so the halves stay whole. + /// + /// It takes the strokes and fills out of the scene as it encodes them, + /// so each list is freed once its bytes are made: read the origin, the + /// extents, the text and the clip regions first. + pub fn buffers(&mut self) -> Buffers { let o = self.origin(); - let mut out = Vec::with_capacity(self.strokes.len() * STROKE_BYTES); - for half in self.stroke_halves() { - let (order, _) = self.bucket_strokes(&half); - for i in order { - let s = half[i as usize]; - out.extend_from_slice(&((s.x0 - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.y0 - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.x1 - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.y1 - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&s.rgba.to_le_bytes()); - out.extend_from_slice(&s.attr.to_le_bytes()); - out.extend_from_slice(&s.order.to_le_bytes()); + // Indices rather than copies: a stroke is 48 bytes, its index 4. + let [opaque_strokes, strokes] = self.stroke_halves(); + let [opaque_fills, fills] = self.fill_halves(); + let (stroke_order, mut stroke_tiles) = self.bucket_strokes(&opaque_strokes); + let (fill_order, mut fill_tiles) = self.bucket_fills(&opaque_fills); + let translucent = self.translucent(&strokes, &fills); + + let mut stroke_bytes = Vec::with_capacity(self.strokes.len() * STROKE_BYTES); + let ordered = stroke_order + .iter() + .map(|&i| opaque_strokes[i as usize]) + .chain(strokes.iter().copied()); + for s in ordered.map(|i| &self.strokes[i as usize]) { + stroke_bytes.extend_from_slice(&((s.x0 - o[0]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.y0 - o[1]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.x1 - o[0]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.y1 - o[1]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&s.rgba.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.attr.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.order.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.bulge.to_le_bytes()); + } + drop((stroke_order, strokes)); + self.strokes = Vec::new(); + let mut fill_bytes = Vec::with_capacity(self.fills.len() * FILL_BYTES); + let ordered = fill_order + .iter() + .map(|&i| opaque_fills[i as usize]) + .chain(fills.iter().copied()); + for t in ordered { + for v in &self.fills[t as usize * 3..t as usize * 3 + 3] { + fill_bytes.extend_from_slice(&((v.x - o[0]) as f32).to_le_bytes()); + fill_bytes.extend_from_slice(&((v.y - o[1]) as f32).to_le_bytes()); + fill_bytes.extend_from_slice(&v.rgba.to_le_bytes()); + fill_bytes.extend_from_slice(&v.attr.to_le_bytes()); + fill_bytes.extend_from_slice(&v.order.to_le_bytes()); } } - out - } + drop((fill_order, fills)); + self.fills = Vec::new(); + + // Tiles count strokes, and fill vertices (for drawArrays); the + // translucent ones start past the opaque half. + for tile in &mut fill_tiles { + tile.start *= 3; + tile.count *= 3; + } + let (opaque_stroke_tiles, opaque_fill_tiles) = (stroke_tiles.len(), fill_tiles.len()); + for mut tile in translucent.strokes { + tile.start += opaque_strokes.len() as u32; + stroke_tiles.push(tile); + } + for mut tile in translucent.fills { + tile.start = (opaque_fills.len() as u32 + tile.start) * 3; + tile.count *= 3; + fill_tiles.push(tile); + } + let mut runs = Vec::with_capacity(translucent.runs.len() * 12); + for (fill, from, to) in translucent.runs { + let base = if fill { + opaque_fill_tiles + } else { + opaque_stroke_tiles + } as u32; + runs.extend_from_slice(&u32::from(fill).to_le_bytes()); + runs.extend_from_slice(&(base + from).to_le_bytes()); + runs.extend_from_slice(&(base + to).to_le_bytes()); + } - /// Interleaved fill vertex buffer, `FILL_BYTES` per vertex. - pub fn fill_buffer(&self) -> Vec { - let o = self.origin(); - let mut out = Vec::with_capacity(self.fills.len() * FILL_BYTES); - for half in self.fill_halves() { - let (order, _) = self.bucket_fills(&half); - for i in order { - for v in half[i as usize] { - out.extend_from_slice(&((v.x - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((v.y - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&v.rgba.to_le_bytes()); - out.extend_from_slice(&v.attr.to_le_bytes()); - out.extend_from_slice(&v.order.to_le_bytes()); - } - } + Buffers { + strokes: stroke_bytes, + fills: fill_bytes, + opaque_strokes: opaque_strokes.len(), + opaque_fills: opaque_fills.len() * 3, + stroke_tiles: super::tiles::encode(&stroke_tiles, o), + fill_tiles: super::tiles::encode(&fill_tiles, o), + opaque_stroke_tiles, + opaque_fill_tiles, + translucent_runs: runs, } - out } - /// Strokes split into the opaque half and the translucent half, in that - /// order. Both buffer emission and the tile table walk this, so they cannot - /// disagree about what went where. - fn stroke_halves(&self) -> [Vec; 2] { - let (a, b): (Vec, Vec) = - self.strokes.iter().partition(|s| is_opaque(s.rgba)); + /// The strokes of the opaque half and of the translucent half, by + /// index, the translucent one in draw order. Both buffer emission and + /// the tile table walk this, so they cannot disagree about what went + /// where. + fn stroke_halves(&self) -> [Vec; 2] { + let all = 0..self.strokes.len() as u32; + let (a, mut b): (Vec, Vec) = + all.partition(|&i| is_opaque(self.strokes[i as usize].rgba)); + b.sort_by_key(|&i| self.strokes[i as usize].order); [a, b] } - /// The same for fills, kept as whole triangles. A gradient gives its - /// corners different colours but the same alpha, so one corner decides. - fn fill_halves(&self) -> [Vec<[FillVertex; 3]>; 2] { - let tris: Vec<[FillVertex; 3]> = self.fills.as_chunks::<3>().0.to_vec(); - let (a, b): (Vec<_>, Vec<_>) = tris.into_iter().partition(|t| is_opaque(t[0].rgba)); + /// The same for fills, by triangle. A gradient gives its corners + /// different colours but the same alpha, so one corner decides. + fn fill_halves(&self) -> [Vec; 2] { + let all = 0..(self.fills.len() / 3) as u32; + let (a, mut b): (Vec, Vec) = + all.partition(|&t| is_opaque(self.fills[t as usize * 3].rgba)); + b.sort_by_key(|&t| self.fills[t as usize * 3].order); [a, b] } - fn bucket_strokes(&self, half: &[Stroke]) -> (Vec, Vec) { + fn triangle(&self, t: u32) -> &[FillVertex; 3] { + let at = t as usize * 3; + self.fills[at..at + 3].try_into().expect("three vertices") + } + + /// The translucent halves' tiles, and the order to draw them in. A tile + /// is a run of the half in draw order, so it is no spatial bucket, but + /// neighbours in draw order are often neighbours on the sheet too. Fills + /// and strokes interleave by draw order, a run of tiles of one kind at a + /// time; past [`MAX_TRANSLUCENT_RUNS`] runs, the fills then the strokes. + fn translucent(&self, strokes: &[u32], fills: &[u32]) -> Translucent { + let stroke = |i: u32| &self.strokes[i as usize]; + let mut t = Translucent::default(); + let (mut i, mut j) = (0, 0); + while i < strokes.len() || j < fills.len() { + let fill = match (strokes.get(i), fills.get(j)) { + (Some(&s), Some(&f)) => self.triangle(f)[0].order <= stroke(s).order, + (None, _) => true, + (Some(_), None) => false, + }; + if fill { + t.push(true, j, fill_box(self.triangle(fills[j]))); + j += 1; + } else { + t.push(false, i, stroke_box(stroke(strokes[i]))); + i += 1; + } + } + if t.runs.len() > MAX_TRANSLUCENT_RUNS { + t = Translucent::default(); + for (j, &f) in fills.iter().enumerate() { + t.push(true, j, fill_box(self.triangle(f))); + } + for (i, &s) in strokes.iter().enumerate() { + t.push(false, i, stroke_box(stroke(s))); + } + } + t + } + + fn bucket_strokes(&self, half: &[u32]) -> (Vec, Vec) { + let stroke = |i: &u32| &self.strokes[*i as usize]; super::tiles::bucket( half, self.world_extent(), - |s| [(s.x0 + s.x1) / 2.0, (s.y0 + s.y1) / 2.0], - |s| { - [ - s.x0.min(s.x1), - s.y0.min(s.y1), - s.x0.max(s.x1), - s.y0.max(s.y1), - ] + |i| { + let s = stroke(i); + [(s.x0 + s.x1) / 2.0, (s.y0 + s.y1) / 2.0] }, + |i| stroke_box(stroke(i)), ) } - fn bucket_fills(&self, half: &[[FillVertex; 3]]) -> (Vec, Vec) { + fn bucket_fills(&self, half: &[u32]) -> (Vec, Vec) { super::tiles::bucket( half, self.world_extent(), - |t| { + |&i| { + let t = self.triangle(i); [ (t[0].x + t[1].x + t[2].x) / 3.0, (t[0].y + t[1].y + t[2].y) / 3.0, ] }, - |t| { - [ - t[0].x.min(t[1].x).min(t[2].x), - t[0].y.min(t[1].y).min(t[2].y), - t[0].x.max(t[1].x).max(t[2].x), - t[0].y.max(t[1].y).max(t[2].y), - ] - }, + |&i| fill_box(self.triangle(i)), ) } +} - /// Tile table for `stroke_buffer`, counted in strokes. The translucent - /// half's tiles are offset past the opaque half, so a start index addresses - /// the whole buffer. - pub fn stroke_tiles(&self) -> Vec { - let halves = self.stroke_halves(); - let mut tiles = Vec::new(); - let mut base = 0u32; - for half in &halves { - let (_, mut t) = self.bucket_strokes(half); - for tile in &mut t { - tile.start += base; - } - base += half.len() as u32; - tiles.extend(t); - } - super::tiles::encode(&tiles, self.origin()) - } - - /// Tile table for `fill_buffer`, counted in vertices, so it can be handed - /// straight to `drawArrays`. - pub fn fill_tiles(&self) -> Vec { - let halves = self.fill_halves(); - let mut tiles = Vec::new(); - let mut base = 0u32; - for half in &halves { - let (_, mut t) = self.bucket_fills(half); - for tile in &mut t { - tile.start = base + tile.start * 3; - tile.count *= 3; - } - base += half.len() as u32 * 3; - tiles.extend(t); - } - super::tiles::encode(&tiles, self.origin()) - } +/// What [`Scene::buffers`] hands the renderer; see [`super::Drawing`]. +pub struct Buffers { + pub strokes: Vec, + pub fills: Vec, + pub opaque_strokes: usize, + pub opaque_fills: usize, + pub stroke_tiles: Vec, + pub fill_tiles: Vec, + pub opaque_stroke_tiles: usize, + pub opaque_fill_tiles: usize, + pub translucent_runs: Vec, +} - /// Where the translucent tiles begin, as an index into the tile table. - pub fn opaque_stroke_tiles(&self) -> usize { - self.bucket_strokes(&self.stroke_halves()[0]).1.len() - } +/// Elements in one tile of a translucent half. +const TRANSLUCENT_TILE: u32 = 4096; +/// Most runs the translucent pass alternates fills and strokes in, each a +/// few draw calls. +const MAX_TRANSLUCENT_RUNS: usize = 1024; - pub fn opaque_fill_tiles(&self) -> usize { - self.bucket_fills(&self.fill_halves()[0]).1.len() - } +/// The translucent halves' tiles, `start` and `count` in elements of the +/// half, and the runs of tiles to draw: (fills, first tile, end tile). +#[derive(Default)] +struct Translucent { + strokes: Vec, + fills: Vec, + runs: Vec<(bool, u32, u32)>, +} - /// How many leading strokes of `stroke_buffer` are opaque. - pub fn opaque_strokes(&self) -> usize { - self.strokes.iter().filter(|s| is_opaque(s.rgba)).count() +impl Translucent { + /// Element `index` of a half, next in draw order. + fn push(&mut self, fill: bool, index: usize, b: [f64; 4]) { + let tiles = if fill { + &mut self.fills + } else { + &mut self.strokes + }; + let same_run = self.runs.last().is_some_and(|r| r.0 == fill); + if let Some(t) = tiles + .last_mut() + .filter(|t| same_run && t.count < TRANSLUCENT_TILE) + { + t.count += 1; + t.min_x = t.min_x.min(b[0]); + t.min_y = t.min_y.min(b[1]); + t.max_x = t.max_x.max(b[2]); + t.max_y = t.max_y.max(b[3]); + return; + } + tiles.push(Tile { + start: index as u32, + count: 1, + min_x: b[0], + min_y: b[1], + max_x: b[2], + max_y: b[3], + }); + let n = tiles.len() as u32; + match self.runs.last_mut() { + Some(r) if same_run => r.2 = n, + _ => self.runs.push((fill, n - 1, n)), + } } +} - /// How many leading vertices of `fill_buffer` are opaque. - pub fn opaque_fills(&self) -> usize { - self.fills - .as_chunks::<3>() - .0 - .iter() - .filter(|t| is_opaque(t[0].rgba)) - .count() - * 3 - } +fn stroke_box(s: &Stroke) -> [f64; 4] { + let h = s.sagitta(); + [ + s.x0.min(s.x1) - h, + s.y0.min(s.y1) - h, + s.x0.max(s.x1) + h, + s.y0.max(s.y1) + h, + ] } -/// Fully opaque geometry can write depth; translucent geometry cannot. -/// -/// The draw order is resolved through the depth buffer, so a translucent -/// entity that writes depth rejects everything drawn behind it afterwards -/// instead of letting it blend through. Splitting the buffers lets the -/// renderer draw the translucent tail with depth writes off. +fn fill_box(t: &[FillVertex; 3]) -> [f64; 4] { + [ + t[0].x.min(t[1].x).min(t[2].x), + t[0].y.min(t[1].y).min(t[2].y), + t[0].x.max(t[1].x).max(t[2].x), + t[0].y.max(t[1].y).max(t[2].y), + ] +} + +/// How far from its anchor a run can reach, whatever its alignment and +/// rotation: every character an em wide, which no bundled face exceeds, and +/// a line's height. +fn text_reach(r: &super::text::TextRun) -> f64 { + let chars = r.text.chars().count() as f64; + r.unit() * (chars + 1.0) +} + +/// Fully opaque geometry can be drawn in any order, the depth buffer +/// deciding what covers what; translucent geometry blends over what is +/// already drawn, so it comes after, in draw order. #[inline] fn is_opaque(rgba: u32) -> bool { rgba >> 24 == 255 @@ -736,23 +1139,119 @@ mod tests { s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); s.push_triangle([2.0, 0.0], [3.0, 0.0], [2.0, 1.0], solid, 0); - assert_eq!(s.opaque_strokes(), 1); - assert_eq!(s.opaque_fills(), 3); + let b = s.buffers(); + assert_eq!(b.opaque_strokes, 1); + assert_eq!(b.opaque_fills, 3); // The opaque one comes first in the buffer whatever order it arrived in. - let buf = s.stroke_buffer(); - let first_rgba = u32::from_le_bytes(buf[16..20].try_into().unwrap()); + let first_rgba = u32::from_le_bytes(b.strokes[16..20].try_into().unwrap()); assert_eq!(first_rgba, solid); - let fills = s.fill_buffer(); - let first_fill_rgba = u32::from_le_bytes(fills[8..12].try_into().unwrap()); + let first_fill_rgba = u32::from_le_bytes(b.fills[8..12].try_into().unwrap()); assert_eq!(first_fill_rgba, solid); } + fn u32s(b: &[u8]) -> Vec { + b.as_chunks::<4>() + .0 + .iter() + .map(|c| u32::from_le_bytes(*c)) + .collect() + } + + /// An opaque fill is outlined by edges of no width, which anti-alias it: + /// a square of two triangles by its four sides, not their diagonal. A + /// translucent fill and a background one are not. + #[test] + fn opaque_fills_are_outlined_for_their_edges() { + let mut s = Scene::new(); + let red = pack_rgba(255, 0, 0, 255); + let attr = pack_attr(3, 25, FLAG_CONTRAST); + s.push_triangle([0.0, 0.0], [1.0, 0.0], [1.0, 1.0], red, attr); + s.push_triangle([0.0, 0.0], [1.0, 1.0], [0.0, 1.0], red, attr); + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + for k in &s.strokes { + assert_eq!(k.attr, pack_attr(3, FILL_EDGE, FLAG_CONTRAST)); + assert_eq!(k.rgba, red); + assert!(k.x0 == k.x1 || k.y0 == k.y1, "a side, not the diagonal"); + } + // Each fill once: a second call outlines nothing more. + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + s.push_triangle( + [5.0, 0.0], + [6.0, 0.0], + [6.0, 1.0], + pack_rgba(255, 0, 0, 128), + attr, + ); + s.push_triangle( + [7.0, 0.0], + [8.0, 0.0], + [8.0, 1.0], + red, + pack_attr(3, 25, FILL_FLAG_BACKGROUND), + ); + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + } + + /// Translucent geometry is drawn in draw order, fills and strokes + /// interleaved, wherever it was pushed from. + #[test] + fn translucent_geometry_keeps_draw_order() { + let mut s = Scene::new(); + let clear = pack_rgba(255, 0, 0, 128); + for (order, fill) in [(3, false), (1, true), (2, false), (4, true), (0, false)] { + s.order = order; + if fill { + s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); + } else { + s.push_stroke(0.0, 0.0, 1.0, 0.0, clear, 0); + } + } + // An opaque stroke elsewhere gives the stroke table a bucket first. + s.order = 9; + s.push_stroke(50.0, 50.0, 60.0, 50.0, pack_rgba(0, 0, 0, 255), 0); + let b = s.buffers(); + let orders: Vec = b + .strokes + .chunks(STROKE_BYTES) + .map(|c| u32s(&c[24..28])[0]) + .collect(); + assert_eq!(orders, [9, 0, 2, 3]); + // Strokes 0, fill 1, strokes 2 and 3, fill 4: stroke tiles from 1 + // (past the opaque bucket), fill tiles from 0. + assert_eq!( + u32s(&b.translucent_runs), + [0, 1, 2, 1, 0, 1, 0, 2, 3, 1, 1, 2] + ); + let tiles = u32s(&b.stroke_tiles); + let starts: Vec = tiles.chunks(6).map(|t| t[0]).collect(); + let counts: Vec = tiles.chunks(6).map(|t| t[1]).collect(); + assert_eq!((starts, counts), (vec![0, 1, 2], vec![1, 1, 2])); + } + + /// Past the most runs, the fills then the strokes. + #[test] + fn translucent_runs_have_a_ceiling() { + let mut s = Scene::new(); + let clear = pack_rgba(255, 0, 0, 128); + for i in 0..MAX_TRANSLUCENT_RUNS as u32 { + s.order = 2 * i; + s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); + s.order = 2 * i + 1; + s.push_stroke(0.0, 0.0, 1.0, 0.0, clear, 0); + } + let runs = u32s(&s.buffers().translucent_runs); + assert_eq!(runs, [1, 0, 1, 0, 0, 1]); + } + #[test] fn the_contrast_flag_and_the_fade_code_share_one_byte() { // Both live in the flags byte, so a hatch on colour 7 has to carry its // spacing and its flip without either eating the other. - for spacing in [1.5f64, 16.0, 500.0, 50_000.0] { + for spacing in [0.01, 0.5, 1.5f64, 16.0, 500.0, 50_000.0] { let fade = encode_fade_spacing(spacing); assert!( fade > 0 && fade < FLAG_CONTRAST, @@ -765,18 +1264,18 @@ mod tests { assert_eq!(a & 0xFFFF, 7); assert_eq!((a >> 16) & 0xFF, 25); } - assert_eq!(encode_fade_spacing(0.5), 0); + assert_eq!(encode_fade_spacing(0.0), 0); } #[test] fn the_fade_code_still_resolves_a_useful_spacing_range() { - // Seven bits, eight steps to the octave: a millimetre up to tens of - // metres, which is every hatch spacing a drawing uses. - let decode = |code: u8| (code as f64 / 8.0).exp2(); - for spacing in [2.0f64, 40.0, 5000.0] { + // Seven bits, five steps to the octave from 2^-9: a pattern + // millimetres apart in metres, or metres apart in millimetres. + let decode = |code: u8| (code as f64 / 5.0 - 9.0).exp2(); + for spacing in [0.003f64, 0.05, 0.5, 2.0, 40.0, 5000.0, 80_000.0] { let back = decode(encode_fade_spacing(spacing)); assert!( - (back / spacing - 1.0).abs() < 0.05, + (back / spacing - 1.0).abs() < 0.075, "{spacing} came back as {back}" ); } @@ -796,7 +1295,7 @@ mod tests { let o = s.origin(); assert!((o[0] - 6_500_000.5).abs() < 1e-6); - let buf = s.stroke_buffer(); + let buf = s.buffers().strokes; let x0 = f32::from_le_bytes(buf[0..4].try_into().unwrap()); let x1 = f32::from_le_bytes(buf[8..12].try_into().unwrap()); // Relative coordinates keep the 1-unit separation intact. @@ -814,13 +1313,66 @@ mod tests { ); } + /// A circle is four quarter arcs, its extents its own box exactly; an + /// arc inside a clip stays one, one across it is cut along its curve. + #[test] + fn arcs_are_strokes_of_a_quarter_turn() { + let mut s = Scene::new(); + s.push_arc(10.0, 0.0, 2.0, 0.0, std::f64::consts::TAU, 0, 0); + assert_eq!(s.strokes.len(), 4); + let quarter = (std::f64::consts::PI / 8.0).tan() as f32; + assert!(s.strokes.iter().all(|k| (k.bulge - quarter).abs() < 1e-6)); + let o = s.origin(); + assert_eq!( + s.extents_local().map(|v| (v * 1e4).round() / 1e4), + [-2.0, -2.0, 2.0, 2.0] + ); + assert_eq!(o, [10.0, 0.0]); + // The encoding carries the bulge last. + let b = s.buffers(); + let at = STROKE_BYTES - 4; + assert_eq!( + f32::from_le_bytes(b.strokes[at..at + 4].try_into().unwrap()), + quarter + ); + + let mut s = Scene::new(); + s.push_arc(0.0, 0.0, 1.0, 0.0, std::f64::consts::FRAC_PI_2, 0, 0); + let mark = Mark { + strokes: 0, + fills: 0, + texts: 0, + }; + s.clip_since( + mark, + &super::super::clip::ClipShape::rect([0.0, 0.0], 10.0, 10.0), + ); + assert_eq!(s.strokes.len(), 1, "inside: still one arc"); + s.clip_since( + mark, + &super::super::clip::ClipShape::rect([1.0, 0.0], 1.0, 10.0), + ); + assert!( + s.strokes.len() > 1 + && s.strokes + .iter() + .all(|k| k.bulge == 0.0 && k.x0 >= 0.5 - 1e-9) + ); + // On the curve, to the flattening's sagitta. + assert!(s + .strokes + .iter() + .all(|k| (k.x0.hypot(k.y0) - 1.0).abs() < 2e-3)); + } + #[test] fn buffers_have_the_documented_stride() { let mut s = Scene::new(); s.push_stroke(0.0, 0.0, 1.0, 1.0, 0, 0); s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], 0, 0); - assert_eq!(s.stroke_buffer().len(), STROKE_BYTES); - assert_eq!(s.fill_buffer().len(), 3 * FILL_BYTES); + let b = s.buffers(); + assert_eq!(b.strokes.len(), STROKE_BYTES); + assert_eq!(b.fills.len(), 3 * FILL_BYTES); } #[test] diff --git a/crates/exav-render/src/formats/dwg/tessellate.rs b/crates/exav-render/src/formats/dwg/tessellate.rs index c83f158..bedfe8d 100644 --- a/crates/exav-render/src/formats/dwg/tessellate.rs +++ b/crates/exav-render/src/formats/dwg/tessellate.rs @@ -1,6 +1,6 @@ //! Walk a drawing model and turn drawable entities into GPU buffers. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::cad::{ Color, Drawing as CadDrawing, Edge, Entity, EntityKind, HAlign, Handle, LineWeight, @@ -9,6 +9,7 @@ use crate::cad::{ use super::clip::ClipShape; use super::curves; +use super::dimension; use super::fill; use super::font; use super::gradient::{self, Gradient}; @@ -20,6 +21,7 @@ use super::scene::{ encode_fade_spacing, pack_attr, pack_rgba, Affine, Scene, FILL_FLAG_BACKGROUND, FLAG_CONTRAST, }; use super::text; +use super::wide::{self, WideVertex}; fn h_align_of(a: HAlign) -> text::HAlign { match a { @@ -94,6 +96,230 @@ fn island_style(h: &crate::cad::Hatch) -> fill::IslandStyle { /// step AutoCAD's own editor appears to use and is the width of a typical list /// indent; it is a guess, and it is only ever reached by text that tabs without /// declaring where to. +/// An arrowhead one unit long, its tip at the origin pointing along +x: +/// triangles to fill and lines to stroke. +struct ArrowShape { + filled: Vec<[f64; 2]>, + lines: Vec>, +} + +/// AutoCAD's built-in arrowheads by block name (`_OPEN`...); an empty or +/// unknown name is the closed filled one. +fn arrow_shape(name: &str) -> ArrowShape { + let circle = |r: f64, cx: f64| -> Vec<[f64; 2]> { + let mut c = curves::flatten_circle(cx, 0.0, r); + if let Some(&first) = c.first() { + c.push(first); + } + c + }; + let disc = |r: f64| -> Vec<[f64; 2]> { + let ring = curves::flatten_circle(0.0, 0.0, r); + (0..ring.len()) + .flat_map(|i| [[0.0, 0.0], ring[i], ring[(i + 1) % ring.len()]]) + .collect() + }; + let triangle = |w: f64, l: f64| vec![[0.0, 0.0], [-l, w], [-l, -w]]; + let (filled, lines) = match name { + "_NONE" => (vec![], vec![]), + "_OPEN" => ( + vec![], + vec![vec![[-1.0, 1.0 / 6.0], [0.0, 0.0], [-1.0, -1.0 / 6.0]]], + ), + "_OPEN30" => ( + vec![], + vec![vec![[-1.0, 0.268], [0.0, 0.0], [-1.0, -0.268]]], + ), + "_OPEN90" => (vec![], vec![vec![[-0.5, 0.5], [0.0, 0.0], [-0.5, -0.5]]]), + "_CLOSEDBLANK" => ( + vec![], + vec![{ + let mut t = triangle(1.0 / 6.0, 1.0); + t.push([0.0, 0.0]); + t + }], + ), + "_CLOSED" => ( + vec![], + vec![ + { + let mut t = triangle(1.0 / 6.0, 1.0); + t.push([0.0, 0.0]); + t + }, + vec![[-1.0, 0.0], [0.0, 0.0]], + ], + ), + "_SMALL" => (triangle(1.0 / 12.0, 0.5), vec![]), + "_DOT" => (disc(0.25), vec![]), + "_DOTSMALL" => (disc(1.0 / 16.0), vec![]), + "_DOTBLANK" => (vec![], vec![circle(0.25, 0.0)]), + "_ORIGIN" => (vec![], vec![circle(0.5, 0.0)]), + "_ORIGIN2" => (vec![], vec![circle(0.5, 0.0), circle(0.25, 0.0)]), + "_OBLIQUE" | "_ARCHTICK" => (vec![], vec![vec![[-0.5, -0.5], [0.5, 0.5]]]), + "_BOXFILLED" => ( + vec![ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + ], + vec![], + ), + "_BOXBLANK" => ( + vec![], + vec![vec![ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + [-0.5, -0.5], + ]], + ), + "_DATUMFILLED" => (vec![[0.0, 0.577], [0.0, -0.577], [-1.0, 0.0]], vec![]), + "_DATUMBLANK" => ( + vec![], + vec![vec![[0.0, 0.577], [0.0, -0.577], [-1.0, 0.0], [0.0, 0.577]]], + ), + _ => (triangle(1.0 / 6.0, 1.0), vec![]), + }; + ArrowShape { filled, lines } +} + +/// A tolerance box's text with its GD&T font runs (`{\Fgdt;j}`, the font +/// code in any case) as the symbols they stand for, the braces gone. +fn gdt_codes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.to_ascii_lowercase().find("\\fgdt;") { + let before = &rest[..at]; + out.push_str(before.strip_suffix('{').unwrap_or(before)); + let after = &rest[at + "\\fgdt;".len()..]; + let end = after.find('}').unwrap_or(after.len()); + out.push_str(&font::map_symbols(&after[..end], font::Symbols::Gdt)); + rest = after.get(end + 1..).unwrap_or(""); + } + out.push_str(rest); + out +} + +/// A polygon mesh's lines: along M and along N, closed as its flags say +/// (1 in M, 32 in N). +fn polygon_mesh_edges(p: &crate::cad::Polyline) -> Vec> { + let (m, n) = (p.m_count.max(0) as usize, p.n_count.max(0) as usize); + let pts: Vec<[f64; 2]> = p + .vertices + .iter() + .map(|v| [v.location.x, v.location.y]) + .collect(); + if m < 1 || n < 1 || pts.len() < m * n { + return Vec::new(); + } + let at = |i: usize, j: usize| pts[i * n + j]; + let mut out = Vec::new(); + for i in 0..m { + let mut row: Vec<[f64; 2]> = (0..n).map(|j| at(i, j)).collect(); + if p.flags & 32 != 0 && n > 2 { + row.push(at(i, 0)); + } + out.push(row); + } + for j in 0..n { + let mut column: Vec<[f64; 2]> = (0..m).map(|i| at(i, j)).collect(); + if p.flags & 1 != 0 && m > 2 { + column.push(at(0, j)); + } + out.push(column); + } + out +} + +/// A polyface mesh's visible edges: each face's sides, but those whose +/// starting index is negative, each side once. +fn polyface_edges(p: &crate::cad::Polyline) -> Vec> { + let corners: Vec<[f64; 2]> = p + .vertices + .iter() + .filter(|v| v.flags & 64 != 0) + .map(|v| [v.location.x, v.location.y]) + .collect(); + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for face in p + .vertices + .iter() + .filter(|v| v.flags & 64 == 0 && v.flags & 128 != 0) + { + let ids: Vec = face + .indices + .iter() + .copied() + .take_while(|&i| i != 0) + .collect(); + for k in 0..ids.len() { + let (a, b) = (ids[k], ids[(k + 1) % ids.len()]); + if a < 0 || ids.len() < 2 { + continue; + } + let (a, b) = (a.unsigned_abs() as usize, b.unsigned_abs() as usize); + let (Some(&pa), Some(&pb)) = (corners.get(a - 1), corners.get(b - 1)) else { + continue; + }; + if seen.insert((a.min(b), a.max(b))) { + out.push(vec![pa, pb]); + } + } + } + out +} + +/// Opacity of the fill drawn for a pattern too dense to draw: about what +/// sub-pixel lines, faded by their spacing, add up to on screen. +const TRUNCATED_PATTERN_TONE: f64 = 0.5; + +/// The scale, turn and mirroring of a transform that keeps circles round +/// (rotation, uniform scale, perhaps a mirror); None for any other. +fn similarity(xf: &Affine) -> Option<(f64, f64, bool)> { + let scale = xf.a.hypot(xf.b); + let tolerance = 1e-9 * scale.max(1e-300); + let turn = xf.b.atan2(xf.a); + if (xf.a - xf.d).abs() <= tolerance && (xf.b + xf.c).abs() <= tolerance { + return (scale > 0.0).then_some((scale, turn, false)); + } + if (xf.a + xf.d).abs() <= tolerance && (xf.b - xf.c).abs() <= tolerance { + return (scale > 0.0).then_some((scale, turn, true)); + } + None +} + +/// A stack's parts against the text height: AutoCAD's default (`TSTACKSIZE`). +const STACK_SIZE: f64 = 0.7; + +/// A run's height: the entity's, times a `\H...x` factor, or a `\H` height. +fn span_height(s: &text::Span, base: f64) -> f64 { + match s.height { + text::SpanHeight::Factor(f) if f > 0.0 => base * f, + text::SpanHeight::Absolute(h) if h > 0.0 => h, + _ => base, + } +} + +/// How wide a stack is drawn: its wider part, or both parts and the slash +/// of a diagonal one. +fn stack_width(s: &text::Stack, metrics: &font::Metrics, height: f64, w: f64) -> f64 { + let part = height * STACK_SIZE; + let (upper, lower) = ( + metrics.measure(&s.upper) * part * w, + metrics.measure(&s.lower) * part * w, + ); + match s.kind { + text::StackKind::Diagonal => upper + 0.5 * part + lower, + _ => upper.max(lower), + } +} + fn next_tab_stop(cursor: f64, paragraph: &text::Paragraph, height: f64) -> f64 { if let Some(stop) = paragraph.tabs.iter().find(|s| **s > cursor + 1e-9) { return *stop; @@ -130,41 +356,69 @@ struct LineTypeDef { labels: Vec, } -/// The text a complex linetype embeds, ready to scale. +/// The text and shapes a complex linetype embeds, ready to scale. /// -/// Shape elements are skipped: they name a glyph in an SHX shape file, which -/// cannot be shipped any more than an SHX font can (DESIGN.md section 5.3). +/// A shape names a glyph in an SHX shape file, which cannot be shipped any +/// more than an SHX font can (DESIGN.md section 5.3): only ltypeshp.shx's, +/// which AutoCAD's own linetypes use, are drawn, redrawn here. fn embedded_labels(doc: &CadDrawing, lt: &crate::cad::Linetype) -> Vec { let mut out = Vec::new(); for (run, e) in lt.elements.iter().enumerate() { - if !e.has_text() || e.text.is_empty() || !e.scale.is_finite() || e.scale <= 0.0 { + if !e.scale.is_finite() || e.scale <= 0.0 { continue; } - // The element's scale multiplies the style's fixed height, or is the - // height itself when the style leaves it free, which is the usual case. let style = doc .text_styles .iter() .find(|s| !e.style.is_null() && s.handle == e.style); - let style_height = style.map(|s| s.height).unwrap_or(0.0); - let height = if style_height > 0.0 { - style_height * e.scale + let (mark, height) = if e.has_text() && !e.text.is_empty() { + // The element's scale multiplies the style's fixed height, or is + // the height itself when the style leaves it free, which is the + // usual case. + let style_height = style.map(|s| s.height).unwrap_or(0.0); + let height = if style_height > 0.0 { + style_height * e.scale + } else { + e.scale + }; + let face = style.map(font::resolve_style).unwrap_or(font::Face::Stroke); + ( + linetype::Mark::Text { + text: text::decode_special(&e.text), + face, + }, + height, + ) + } else if e.has_shape() { + let Some(shape) = style + .filter(|s| is_ltypeshp(&s.font_file)) + .and_then(|_| linetype::ltypeshp_shape(e.shape_number)) + else { + continue; + }; + (linetype::Mark::Shape(shape), e.scale) } else { - e.scale + continue; }; out.push(linetype::Label { run, - text: text::decode_special(&e.text), + mark, height, offset: [e.offset.x, e.offset.y], rotation: e.rotation, absolute_rotation: e.absolute_rotation(), - face: style.map(font::resolve_style).unwrap_or(font::Face::Stroke), }); } out } +/// A style's font file is ltypeshp.shx, under any path or case. +fn is_ltypeshp(file: &str) -> bool { + let name = file.rsplit(['/', '\\']).next().unwrap_or(file); + let stem = name.split('.').next().unwrap_or(name); + stem.eq_ignore_ascii_case("ltypeshp") +} + /// A RAY or XLINE, held back until the drawing's extents are known. struct InfiniteLine { base: [f64; 2], @@ -219,6 +473,32 @@ pub struct LayoutInfo { pub tab_order: i16, /// The sheet, as `[min_x, min_y, max_x, max_y]` in paper units. pub limits: Option<[f64; 4]>, + /// Millimetres per paper unit: 25.4 for a sheet set up in inches. + pub mm_per_unit: f64, +} + +/// What a tab is drawn on, beyond its geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct Sheet { + /// Millimetres per drawing unit, which a lineweight on a layout is a + /// width in: 25.4 for a sheet in inches. 0 for model space, where a + /// lineweight is a width on the screen. + pub mm_per_unit: f64, + /// `$LWDISPLAY`: the drawing is shown with its lineweights. + pub lineweights: bool, + /// A layout's paper, `[min_x, min_y, max_x, max_y]` in drawing units: + /// its limits. None for model space. + pub paper: Option<[f64; 4]>, +} + +impl Default for Sheet { + fn default() -> Self { + Sheet { + mm_per_unit: 0.0, + lineweights: true, + paper: None, + } + } } pub struct LayerInfo { @@ -259,6 +539,33 @@ pub struct Warnings { /// without proxy graphics, or with graphics that draw nothing: only the /// application that made them can show them. pub proxy_without_graphics: u32, + /// What `unknown_entities` counts, by DXF type, at most + /// [`MAX_NAMED`] types. + pub not_drawn: BTreeMap, + /// The files external references name (xrefs, images, underlays), at + /// most [`MAX_NAMED`]. + pub external_files: BTreeSet, +} + +/// Most names a warning lists. +pub const MAX_NAMED: usize = 16; + +impl Warnings { + fn not_drawn(&mut self, kind: &str) { + self.unknown_entities = self.unknown_entities.saturating_add(1); + if let Some(n) = self.not_drawn.get_mut(kind) { + *n = n.saturating_add(1); + } else if self.not_drawn.len() < MAX_NAMED { + self.not_drawn.insert(kind.to_string(), 1); + } + } + + fn external(&mut self, file: &str) { + self.external_references = self.external_references.saturating_add(1); + if !file.is_empty() && self.external_files.len() < MAX_NAMED { + self.external_files.insert(file.to_string()); + } + } } /// Default for [`Tessellator::budget`]: strokes and fill vertices one layout @@ -318,13 +625,14 @@ pub struct Tessellator<'a> { /// because a drawing names a handful of styles and uses them thousands of /// times. style_faces: HashMap, - /// The same, by style handle: entities that reference a style by name are - /// the common case, but leaders and dimensions point at one by handle. - style_faces_by_handle: HashMap, + /// Styles whose font draws symbols for letters (symbol.ttf, gdt.shx). + style_symbols: HashMap, /// Which ACI table indexed colours resolve through. palette: Palette, /// Block names currently being expanded, to break reference cycles. stack: Vec, + /// What the tab drawn last is drawn on. + pub sheet: Sheet, } fn normalize(name: &str) -> String { @@ -449,13 +757,17 @@ impl<'a> Tessellator<'a> { .iter() .map(|s| (normalize(&s.name), font::resolve_style(s))) .collect(), - style_faces_by_handle: doc + style_symbols: doc .text_styles .iter() - .map(|s| (s.handle.0, font::resolve_style(s))) + .filter_map(|s| Some((normalize(&s.name), font::symbols_of(&s.font_file)?))) .collect(), palette, stack: Vec::new(), + sheet: Sheet { + lineweights: doc.header.lwdisplay, + ..Sheet::default() + }, } } @@ -504,60 +816,74 @@ impl<'a> Tessellator<'a> { maxy += pad; for line in std::mem::take(&mut self.pending_infinite) { - let len = (line.dir[0] * line.dir[0] + line.dir[1] * line.dir[1]).sqrt(); - if !len.is_finite() || len < 1e-12 { - continue; - } - let u = [line.dir[0] / len, line.dir[1] / len]; + self.push_infinite(&line, [minx, miny], [maxx, maxy]); + } + } - // Slab clip: the parameter range where the line is inside the box. - let mut t0 = if line.both_ways { - f64::NEG_INFINITY - } else { - 0.0 - }; - let mut t1 = f64::INFINITY; - let mut outside = false; - for (p, d, lo, hi) in [ - (line.base[0], u[0], minx, maxx), - (line.base[1], u[1], miny, maxy), - ] { - if d.abs() < 1e-12 { - if p < lo || p > hi { - outside = true; - } - continue; + /// The part of a ray or xline inside a box, as a stroke. + fn push_infinite(&mut self, line: &InfiniteLine, min: [f64; 2], max: [f64; 2]) { + let len = (line.dir[0] * line.dir[0] + line.dir[1] * line.dir[1]).sqrt(); + if !len.is_finite() || len < 1e-12 { + return; + } + let u = [line.dir[0] / len, line.dir[1] / len]; + + // Slab clip: the parameter range where the line is inside the box. + let mut t0 = if line.both_ways { + f64::NEG_INFINITY + } else { + 0.0 + }; + let mut t1 = f64::INFINITY; + for (p, d, lo, hi) in [ + (line.base[0], u[0], min[0], max[0]), + (line.base[1], u[1], min[1], max[1]), + ] { + if d.abs() < 1e-12 { + if p < lo || p > hi { + return; } - let (a, b) = ((lo - p) / d, (hi - p) / d); - let (a, b) = if a <= b { (a, b) } else { (b, a) }; - t0 = t0.max(a); - t1 = t1.min(b); - } - if outside || !(t1 > t0) || !t0.is_finite() || !t1.is_finite() { continue; } - - self.scene.push_stroke( - line.base[0] + u[0] * t0, - line.base[1] + u[1] * t0, - line.base[0] + u[0] * t1, - line.base[1] + u[1] * t1, - line.rgba, - line.attr, - ); + let (a, b) = ((lo - p) / d, (hi - p) / d); + let (a, b) = if a <= b { (a, b) } else { (b, a) }; + t0 = t0.max(a); + t1 = t1.min(b); + } + if !(t1 > t0) || !t0.is_finite() || !t1.is_finite() { + return; } + + self.scene.push_stroke( + line.base[0] + u[0] * t0, + line.base[1] + u[1] * t0, + line.base[0] + u[0] * t1, + line.base[1] + u[1] * t1, + line.rgba, + line.attr, + ); } /// Leader lines plus the label they point at. fn multi_leader(&mut self, m: &crate::cad::MultiLeader, ctx: &Ctx, rgba: u32, attr: u32) { let c = &m.context; - // Every leader root contributes one or more polylines. + // Every leader root contributes one or more polylines, each with + // its arrowhead at its first vertex. + let scale = if c.scale > 0.0 { c.scale } else { 1.0 }; + let size = m.arrowhead_size * scale; for root in &c.leaders { for line in &root.lines { - let pts: Vec<[f64; 2]> = line.vertices.iter().map(|p| [p.x, p.y]).collect(); + // From the arrow's tip through the line's vertices to where + // the root lands. + let mut pts: Vec<[f64; 2]> = line.vertices.iter().map(|p| [p.x, p.y]).collect(); + let landing = [root.connection_point.x, root.connection_point.y]; + if pts.last() != Some(&landing) && (root.has_connection_point || pts.len() < 2) { + pts.push(landing); + } if pts.len() >= 2 { self.push_transformed(&pts, false, &ctx.xf, rgba, attr, None); + self.arrowhead(m.arrowhead, pts[0], pts[1], size, ctx, rgba, attr); } } // The landing runs from the last leader point to the content. @@ -582,47 +908,32 @@ impl<'a> Tessellator<'a> { } else { 2.5 }; - let spacing = height - * text::MTEXT_LINE_SPACING - * if c.line_spacing_factor > 0.0 { - c.line_spacing_factor - } else { - 1.0 - }; - let lines = text::mtext_lines(&c.text); - let face = self.style_face_by_handle(c.text_style); - // Attachment codes match MTEXT's. - let (h_align, dy0) = - text::mtext_layout(c.text_attachment as u8, lines.len(), height, spacing); - let (sin_r, cos_r) = rotation.sin_cos(); let text_rgba = match c.text_color { Color::ByLayer | Color::ByBlock => rgba, ref col => colour_rgb(col, self.palette) - .map(|(r, g, b)| pack_rgba(r, g, b, 255)) + .map(|(r, g, b)| pack_rgba(r, g, b, (rgba >> 24) as u8)) .unwrap_or(rgba), }; - - for (i, line) in lines.iter().enumerate() { - if line.is_empty() { - continue; - } - let dy = dy0 - i as f64 * spacing; - self.push_text_run( - line.clone(), - c.text_location.x - dy * sin_r, - c.text_location.y + dy * cos_r, - height, - rotation, - 1.0, - 0.0, - h_align, - text::VAlign::Baseline, - ctx, - text_rgba, - attr, - face, - ); - } + // The content is MTEXT, drawn as one (attachment codes match). + let style = self + .doc + .text_styles + .iter() + .find(|s| s.handle == c.text_style && !c.text_style.is_null()) + .map(|s| s.name.clone()) + .unwrap_or_default(); + let content = crate::cad::MText { + insertion: c.text_location, + height, + reference_width: c.text_width, + attachment: c.text_attachment, + style, + rotation, + line_spacing_factor: c.line_spacing_factor, + text: c.text.clone(), + ..crate::cad::MText::default() + }; + self.mtext(&content, ctx, text_rgba, attr); } } @@ -717,13 +1028,14 @@ impl<'a> Tessellator<'a> { .unwrap_or(font::Face::Stroke) } - /// The same, for an entity that points at its style by handle. - fn style_face_by_handle(&self, handle: Handle) -> font::Face { - self.style_faces_by_handle - .get(&handle.0) - .copied() - .filter(|_| !handle.is_null()) - .unwrap_or(font::Face::Stroke) + /// A TEXT's or ATTRIB's string as its style shows it: the `%%` codes + /// decoded, then a symbol font's letters as the symbols they draw. + fn style_text(&self, style: &str, raw: &str) -> String { + let s = text::decode_special(raw); + match self.style_symbols.get(&normalize(style)) { + Some(&set) => font::map_symbols(&s, set), + None => s, + } } /// MTEXT content as the lines it is drawn on: the explicit `\P` breaks, @@ -755,138 +1067,338 @@ impl<'a> Tessellator<'a> { .collect() } - /// Draw one laid-out MTEXT line, honouring per-run colour, height and rules. + /// A style's width factor and oblique angle, which MTEXT takes unless + /// `\W` and `\Q` say otherwise. + fn style_shape(&self, style: &str) -> (f64, f64) { + self.doc + .text_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(style)) + .map(|s| { + let w = if s.width_factor > 0.0 { + s.width_factor + } else { + 1.0 + }; + (w, s.oblique) + }) + .unwrap_or((1.0, 0.0)) + } + + /// An MTEXT, or a multiline attribute: its lines wrapped to its box and + /// stacked from the attachment point, each aligned as its paragraph + /// says, over its background mask when it has one. + fn mtext(&mut self, m: &crate::cad::MText, ctx: &Ctx, rgba: u32, attr: u32) { + let height = if m.height > 0.0 { m.height } else { 1.0 }; + let face = self.style_face(&m.style); + let shape = self.style_shape(&m.style); + let mut lines = self.wrap_mtext(&m.text, m.reference_width / shape.0, height, face); + if let Some(&set) = self.style_symbols.get(&normalize(&m.style)) { + for s in lines + .iter_mut() + .flat_map(|l| l.spans.iter_mut()) + .filter(|s| s.face.is_none()) + { + s.text = font::map_symbols(&s.text, set); + } + } + let spacing = height + * text::MTEXT_LINE_SPACING + * if m.line_spacing_factor > 0.0 { + m.line_spacing_factor + } else { + 1.0 + }; + + // DWG stores the text direction as a vector when it has one. + let rotation = match m.x_direction { + Some(d) if d.x != 0.0 || d.y != 0.0 => d.y.atan2(d.x), + _ => m.rotation, + }; + let (h_align, dy0) = text::mtext_layout(m.attachment as u8, lines.len(), height, spacing); + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + m.insertion.x + along * cos_r - up * sin_r, + m.insertion.y + along * sin_r + up * cos_r, + ] + }; + + // The box lines align in and the mask covers: the entity's width, + // else its widest line. + let realigned = lines + .iter() + .any(|l| l.paragraph.align.is_some_and(|a| a != h_align)); + let masked = m.background_fill & 0x13 != 0; + let box_width = if m.reference_width > 0.0 { + m.reference_width + } else if realigned || masked { + lines + .iter() + .map(|l| self.mtext_line_layout(l, height, face, shape.0).1) + .fold(0.0, f64::max) + } else { + 0.0 + }; + let left = match h_align { + text::HAlign::Center | text::HAlign::Middle => -box_width / 2.0, + text::HAlign::Right => -box_width, + _ => 0.0, + }; + + if masked { + // A border of (scale - 1) heights around the lines, from the + // first line's capitals to the last line's baseline. + let scale = if m.background_scale > 0.0 { + m.background_scale + } else { + 1.5 + }; + let border = height * (scale - 1.0).max(0.0); + let top = dy0 + height + border; + let bottom = dy0 - (lines.len().max(1) - 1) as f64 * spacing - border; + let corners: Vec<[f64; 2]> = [ + (left - border, bottom), + (left + box_width + border, bottom), + (left + box_width + border, top), + (left - border, top), + ] + .iter() + .map(|&(a, b)| { + let p = at(a, b); + ctx.xf.apply(p[0], p[1]) + }) + .collect(); + self.mtext_background(m, &corners, rgba, attr); + // The text over its own mask: one step later in draw order. + self.scene.order = self.scene.order.saturating_add(1); + if m.background_fill & 0x10 != 0 { + self.scene.push_polyline(&corners, true, rgba, attr); + } + } + + for (i, line) in lines.iter().enumerate() { + if line.spans.is_empty() { + continue; + } + // Lines stack along the text's own down direction. + let dy = dy0 - i as f64 * spacing; + let (align, along) = match line.paragraph.align { + Some(a) if a != h_align && box_width > 0.0 => ( + a, + left + match a { + text::HAlign::Center => box_width / 2.0, + text::HAlign::Right => box_width, + _ => 0.0, + }, + ), + _ => (h_align, 0.0), + }; + let p = at(along, dy); + self.mtext_line( + line, height, p, rotation, align, shape, ctx, rgba, attr, face, + ); + } + } + + /// An MTEXT's background: its colour (group 90 bit 1) or the drawing + /// area's (bit 2), as a WIPEOUT paints it. + fn mtext_background( + &mut self, + m: &crate::cad::MText, + corners: &[[f64; 2]], + rgba: u32, + attr: u32, + ) { + let base = attr & 0x00FF_FFFF; + let colour = (m.background_fill & 2 == 0) + .then(|| colour_rgb(&m.background_color, self.palette)) + .flatten(); + let (fill, fill_attr) = match colour { + Some((r, g, b)) => (pack_rgba(r, g, b, 255), base), + None => (rgba, base | u32::from(FILL_FLAG_BACKGROUND) << 24), + }; + for t in fill::fill_even_odd(&[corners.to_vec()]).chunks(3) { + if t.len() == 3 { + self.scene.push_triangle(t[0], t[1], t[2], fill, fill_attr); + } + } + } + + /// Where each run of an MTEXT line goes, from the line's left edge: the + /// runs as (span, text, start, width), and the line's width. A tab stop + /// is measured from that edge, so alignment comes after. + fn mtext_line_layout( + &self, + line: &text::Line, + base_height: f64, + face: font::Face, + base_width: f64, + ) -> (Vec<(usize, String, f64, f64)>, f64) { + let mut placed = Vec::new(); + // The paragraph's indents; a first line can be indented differently + // from the rest, which is what makes a numbered list hang. + let mut cursor = line.paragraph.indent + + if line.first { + line.paragraph.first_indent + } else { + 0.0 + }; + for (i, span) in line.spans.iter().enumerate() { + let height = span_height(span, base_height); + let w = span.width.unwrap_or(base_width); + // Each run is measured in its own face, since a `\f` in the + // middle of a line changes how wide the rest of it sits. + let metrics = font::Metrics::for_face(span.face.unwrap_or(face)); + if let Some(stack) = &span.stack { + let width = stack_width(stack, &metrics, height, w); + placed.push((i, String::new(), cursor, width)); + cursor += width; + continue; + } + for (n, piece) in span.text.split('\t').enumerate() { + if n > 0 { + cursor = next_tab_stop(cursor, &line.paragraph, base_height); + } + if piece.is_empty() { + continue; + } + let width = metrics.measure(piece) * height * w * span.tracking.unwrap_or(1.0); + placed.push((i, piece.to_string(), cursor, width)); + cursor += width; + } + } + (placed, cursor) + } + + /// Draw one laid-out MTEXT line, honouring per-run colour, height, width + /// factor, slant, tracking, stacks and rules. /// - /// A line of one run is handed to the renderer to align, which measures the - /// string with the real font. A line of several has to be placed here, - /// because each run starts where the one before it ended. + /// A line of plain text is handed to the renderer to align, which + /// measures the string with the real font. Any other has to be placed + /// here, because each run starts where the one before it ended. #[allow(clippy::too_many_arguments)] - fn push_mtext_line( + fn mtext_line( &mut self, line: &text::Line, base_height: f64, - x: f64, - y: f64, + [x, y]: [f64; 2], rotation: f64, h_align: text::HAlign, + (base_width, base_oblique): (f64, f64), ctx: &Ctx, rgba: u32, attr: u32, face: font::Face, ) { let spans = &line.spans; - let height_of = |s: &text::Span| match s.height { - text::SpanHeight::Factor(f) if f > 0.0 => base_height * f, - text::SpanHeight::Absolute(h) if h > 0.0 => h, - _ => base_height, + let plain = |s: &text::Span| { + s.color.is_none() + && !s.underline + && !s.overline + && !s.strike + && s.width.is_none() + && s.oblique.is_none() + && s.tracking.is_none() + && s.stack.is_none() + && !s.text.contains('\t') }; - // A `\f` run draws in the face it names; the rest take the entity's. - let face_of = |s: &text::Span| s.face.unwrap_or(face); - - // Where the line starts, from its paragraph's indents. The first line - // of a paragraph can be indented differently from the rest, which is - // what makes a numbered list hang. - let indent = line.paragraph.indent - + if line.first { - line.paragraph.first_indent - } else { - 0.0 - }; - let tabbed = spans.iter().any(|s| s.text.contains('\t')); - - let styled = tabbed - || indent != 0.0 - || spans.iter().any(|s| { - s.color.is_some() - || s.face.is_some_and(|f| f != face) - || s.underline - || s.overline - || s.strike - || height_of(s) != base_height - }); + let indented = + line.paragraph.indent != 0.0 || line.first && line.paragraph.first_indent != 0.0; + let first = &spans[0]; + let one_style = spans.iter().all(|s| { + s.face.unwrap_or(face) == first.face.unwrap_or(face) + && span_height(s, base_height) == span_height(first, base_height) + }); // Nothing to place: hand the whole line over as one run. - if !styled - || spans.len() == 1 - && !spans[0].underline - && !spans[0].overline - && !spans[0].strike - && spans[0].color.is_none() - { + if !indented && one_style && spans.iter().all(plain) { let text: String = spans.iter().map(|s| s.text.as_str()).collect(); - let height = spans.first().map(height_of).unwrap_or(base_height); - let face = spans.first().map(face_of).unwrap_or(face); self.push_text_run( text, x, y, - height, - rotation, - 1.0, - 0.0, + text::frame( + span_height(first, base_height), + rotation, + base_width, + base_oblique, + ), h_align, text::VAlign::Baseline, ctx, rgba, attr, - face, + first.face.unwrap_or(face), ); return; } - // Lay the line out from its own left edge first. A tab stop is measured - // from there, not from the anchor, so alignment can only be applied - // once the whole line has been placed. - let mut placed: Vec<(usize, String, f64, f64)> = Vec::new(); - let mut cursor = indent; - for (i, span) in spans.iter().enumerate() { - let height = height_of(span); - for (n, piece) in span.text.split('\t').enumerate() { - if n > 0 { - cursor = next_tab_stop(cursor, &line.paragraph, base_height); - } - if piece.is_empty() { - continue; - } - // Each run is measured in its own face, since a `\f` in the - // middle of a line changes how wide the rest of it sits. - let width = font::Metrics::for_face(face_of(span)).measure(piece) * height; - placed.push((i, piece.to_string(), cursor, width)); - cursor += width; - } - } - + let (placed, total) = self.mtext_line_layout(line, base_height, face, base_width); let offset = match h_align { - text::HAlign::Center | text::HAlign::Middle => -cursor / 2.0, - text::HAlign::Right => -cursor, + text::HAlign::Center | text::HAlign::Middle => -total / 2.0, + text::HAlign::Right => -total, _ => 0.0, }; let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + x + along * cos_r - up * sin_r, + y + along * sin_r + up * cos_r, + ] + }; for (i, piece, start, width) in placed { let span = &spans[i]; let along = start + offset; - let width = &width; - let height = height_of(span); + let height = span_height(span, base_height); let (rgba, attr) = match span.color { Some(c) => self.span_colour(c, rgba, attr), None => (rgba, attr), }; - self.push_text_run( - piece, - x + along * cos_r, - y + along * sin_r, - height, - rotation, - 1.0, - 0.0, - text::HAlign::Left, - text::VAlign::Baseline, - ctx, - rgba, - attr, - face_of(span), - ); + let face = span.face.unwrap_or(face); + let w = span.width.unwrap_or(base_width); + let frame = text::frame(height, rotation, w, span.oblique.unwrap_or(base_oblique)); + if let Some(stack) = &span.stack { + let shape = (w, span.oblique.unwrap_or(base_oblique)); + self.mtext_stack( + stack, + at(along, 0.0), + rotation, + height, + shape, + width, + ctx, + rgba, + attr, + face, + ); + } else if let Some(t) = span.tracking.filter(|t| *t != 1.0) { + // Each character at its advance times the tracking. + let metrics = font::Metrics::for_face(face); + let mut pen = along; + for c in piece.chars() { + let p = at(pen, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run( + c.to_string(), + p[0], + p[1], + frame, + l, + b, + ctx, + rgba, + attr, + face, + ); + pen += metrics.advance(c) * height * w * t; + } + } else { + let p = at(along, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(piece, p[0], p[1], frame, l, b, ctx, rgba, attr, face); + } // The three rules, as strokes along the run at the usual offsets. for (on, dy) in [ @@ -897,19 +1409,76 @@ impl<'a> Tessellator<'a> { if !on { continue; } - let a = ctx.xf.apply( - x + along * cos_r - dy * sin_r, - y + along * sin_r + dy * cos_r, - ); - let b = ctx.xf.apply( - x + (along + width) * cos_r - dy * sin_r, - y + (along + width) * sin_r + dy * cos_r, - ); + let (a, b) = (at(along, dy), at(along + width, dy)); + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); } } } + /// A stack on the baseline at `p`, `width` wide (`stack_width`): its + /// parts at [`STACK_SIZE`] of the height, one above the other about the + /// capitals' middle (across a bar for a fraction, left-aligned for a + /// tolerance), or side by side across a slash. + #[allow(clippy::too_many_arguments)] + fn mtext_stack( + &mut self, + s: &text::Stack, + p: [f64; 2], + rotation: f64, + height: f64, + (w, oblique): (f64, f64), + width: f64, + ctx: &Ctx, + rgba: u32, + attr: u32, + face: font::Face, + ) { + let part = height * STACK_SIZE; + let metrics = font::Metrics::for_face(face); + let wide = |t: &str| metrics.measure(t) * part * w; + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + p[0] + along * cos_r - up * sin_r, + p[1] + along * sin_r + up * cos_r, + ] + }; + let frame = text::frame(part, rotation, w, oblique); + // Where each part's baseline starts, and the bar or slash. + let (upper, lower, line) = match s.kind { + text::StackKind::Diagonal => { + let slant = 0.5 * part; + let wu = wide(&s.upper); + ( + (0.0, height - part), + (wu + slant, 0.0), + Some(((wu, 0.0), (wu + slant, height))), + ) + } + kind => { + let (bar, gap) = (0.5 * height, 0.2 * part); + let centre = kind == text::StackKind::Fraction; + let x = |t: &str| if centre { (width - wide(t)) / 2.0 } else { 0.0 }; + ( + (x(&s.upper), bar + gap), + (x(&s.lower), bar - gap - part), + centre.then_some(((0.0, bar), (width, bar))), + ) + } + }; + for (t, (along, up)) in [(&s.upper, upper), (&s.lower, lower)] { + let q = at(along, up); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(t.clone(), q[0], q[1], frame, l, b, ctx, rgba, attr, face); + } + if let Some(((a0, a1), (b0, b1))) = line { + let (a, b) = (at(a0, a1), at(b0, b1)); + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + } + /// Resolve an MTEXT colour escape against the entity's own colour. fn span_colour(&self, c: text::SpanColor, rgba: u32, attr: u32) -> (u32, u32) { let alpha = (rgba >> 24) as u8; @@ -970,8 +1539,17 @@ impl<'a> Tessellator<'a> { && limits[2] > limits[0] && limits[3] > limits[1]) .then_some(limits), + // Group 72: 0 inches, 1 millimetres, 2 pixels (no physical + // size, taken as millimetres). + mm_per_unit: if l.plot.paper_units == 0 { 25.4 } else { 1.0 }, }); } + // Without LAYOUT objects, the sheet is in the drawing's own system. + let mm_per_unit = if self.doc.header.measurement == 0 { + 25.4 + } else { + 1.0 + }; // Some drawings carry no LAYOUT objects at all (R13 and R14 DWG // saved by AutoCAD before layouts): model space still draws, and so @@ -984,6 +1562,7 @@ impl<'a> Tessellator<'a> { is_model: true, tab_order: 0, limits: None, + mm_per_unit, }); } let paper = self @@ -1002,6 +1581,7 @@ impl<'a> Tessellator<'a> { && limits[2] > limits[0] && limits[3] > limits[1]) .then_some(limits), + mm_per_unit, }); } out.sort_by_key(|l| (!l.is_model, l.tab_order)); @@ -1018,10 +1598,16 @@ impl<'a> Tessellator<'a> { return false; }; let block = layout.block.clone(); + if !layout.is_model { + self.sheet.mm_per_unit = layout.mm_per_unit; + } self.run_block(&block, !layout.is_model); // The sheet, not the contents: see `Scene::set_extents`. if let Some(l) = layout.limits.filter(|_| !layout.is_model) { self.scene.set_extents([l[0], l[1]], [l[2], l[3]]); + if l[2] > l[0] && l[3] > l[1] { + self.sheet.paper = Some(l); + } } true } @@ -1071,6 +1657,7 @@ impl<'a> Tessellator<'a> { if paper { self.scene.recompute_extents(); } + self.scene.focus_on_core(); } fn root_ctx(&self) -> Ctx { @@ -1292,19 +1879,21 @@ impl<'a> Tessellator<'a> { // Colour 7 flips with the background; every other colour, including a // true colour that happens to be white, is drawn as stored. - let (rgba, contrast) = match common.color { - Color::ByLayer => (layer.rgba, layer.contrast), - Color::ByBlock => (ctx.rgba, ctx.contrast), + let (rgb, contrast) = match common.color { + Color::ByLayer => (layer.rgba & 0x00ff_ffff, layer.contrast), + Color::ByBlock => (ctx.rgba & 0x00ff_ffff, ctx.contrast), ref c => { let (r, g, b) = colour_rgb(c, self.palette)?; - let alpha = match common.transparency { - Transparency::Alpha(a) => a, - Transparency::ByBlock => (ctx.rgba >> 24) as u8, - Transparency::ByLayer => (layer.rgba >> 24) as u8, - }; - (pack_rgba(r, g, b, alpha), is_contrast_color(c)) + (pack_rgba(r, g, b, 0), is_contrast_color(c)) } }; + // Transparency resolves on its own, whatever the colour does. + let alpha = match common.transparency { + Transparency::Alpha(a) => a, + Transparency::ByBlock => (ctx.rgba >> 24) as u8, + Transparency::ByLayer => (layer.rgba >> 24) as u8, + }; + let rgba = rgb | u32::from(alpha) << 24; let flags = if contrast { FLAG_CONTRAST } else { 0 }; let lw = match common.lineweight { @@ -1317,6 +1906,11 @@ impl<'a> Tessellator<'a> { } fn entity(&mut self, e: &Entity, outer: &Ctx) { + self.entity_geometry(e, outer); + self.scene.edge_fills(); + } + + fn entity_geometry(&mut self, e: &Entity, outer: &Ctx) { // Advance the draw order for every entity, drawn or not, so the // sequence matches the document's own. self.scene.order = self.scene.order.saturating_add(1); @@ -1361,19 +1955,22 @@ impl<'a> Tessellator<'a> { } EntityKind::Circle(c) => { - let pts = curves::flatten_circle(c.center.x, c.center.y, c.radius); - self.push_transformed(&pts, false, xf, rgba, attr, pattern); + let tau = std::f64::consts::TAU; + self.push_curve( + c.center.x, c.center.y, c.radius, 0.0, tau, xf, rgba, attr, pattern, + ); } EntityKind::Arc(a) => { - let pts = curves::flatten_arc( - a.center.x, - a.center.y, - a.radius, - a.start_angle, - a.end_angle, - ); - self.push_transformed(&pts, false, xf, rgba, attr, pattern); + // DWG arcs always run counter-clockwise from start to end. + let sweep = (a.end_angle - a.start_angle).rem_euclid(std::f64::consts::TAU); + let sweep = if sweep > 0.0 { + sweep + } else { + std::f64::consts::TAU + }; + let (c, r) = (a.center, a.radius); + self.push_curve(c.x, c.y, r, a.start_angle, sweep, xf, rgba, attr, pattern); } EntityKind::Ellipse(el) => { @@ -1390,25 +1987,49 @@ impl<'a> Tessellator<'a> { } EntityKind::LwPolyline(p) => { - let pts: Vec<([f64; 2], f64)> = p + // Group 43, the constant width, stands for every vertex's. + let wide: Vec = p .vertices .iter() - .map(|v| ([v.point.x, v.point.y], v.bulge)) + .map(|v| { + let (w0, w1) = if p.constant_width > 0.0 { + (p.constant_width, p.constant_width) + } else { + (v.start_width, v.end_width) + }; + WideVertex { + p: [v.point.x, v.point.y], + bulge: v.bulge, + start_width: w0, + end_width: w1, + } + }) .collect(); - let closed = p.is_closed(); - let flat = expand_bulges(&pts, closed); - self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + let pattern = pattern.map(|q| (q, p.flags & 128 != 0)); + self.polyline(&wide, p.is_closed(), xf, rgba, attr, pattern); } EntityKind::Polyline(p) if p.kind() == PolylineKind::Polyline2D => { - let pts: Vec<([f64; 2], f64)> = p + // A vertex without widths of its own takes the polyline's. + let wide: Vec = p .vertices .iter() - .map(|v| ([v.location.x, v.location.y], v.bulge)) + .map(|v| { + let (w0, w1) = if v.start_width == 0.0 && v.end_width == 0.0 { + (p.default_start_width, p.default_end_width) + } else { + (v.start_width, v.end_width) + }; + WideVertex { + p: [v.location.x, v.location.y], + bulge: v.bulge, + start_width: w0, + end_width: w1, + } + }) .collect(); - let closed = p.is_closed(); - let flat = expand_bulges(&pts, closed); - self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + let pattern = pattern.map(|q| (q, p.flags & 128 != 0)); + self.polyline(&wide, p.is_closed(), xf, rgba, attr, pattern); } EntityKind::Polyline(p) if p.kind() == PolylineKind::Polyline3D => { @@ -1425,11 +2046,7 @@ impl<'a> Tessellator<'a> { self.push_transformed(&pts, false, xf, rgba, attr, pattern); } - EntityKind::Point(p) => { - // Zero-length stroke; the renderer draws it as a round dot. - let a = xf.apply(p.location.x, p.location.y); - self.scene.push_stroke(a[0], a[1], a[0], a[1], rgba, attr); - } + EntityKind::Point(p) => self.point(p, xf, rgba, attr), EntityKind::Solid(s) | EntityKind::Trace(s) => { // SOLID and TRACE store their corners in Z order, so corners 3 @@ -1453,8 +2070,50 @@ impl<'a> Tessellator<'a> { xf.apply(c[2].x, c[2].y), xf.apply(c[3].x, c[3].y), ]; - // 3DFACE is an outline in a 2D view, not a filled region. - self.push_path(&q, true, rgba, attr, pattern); + // 3DFACE is an outline in a 2D view, not a filled region, + // without the edges its flags hide. + if f.invisible_edges & 0x0F == 0 { + self.push_path(&q, true, rgba, attr, pattern); + } else { + for i in 0..4 { + if f.invisible_edges & (1 << i) == 0 { + self.push_path(&[q[i], q[(i + 1) % 4]], false, rgba, attr, pattern); + } + } + } + } + + // Meshes in plan: their edges. + EntityKind::Mesh(m) => { + let mut seen = HashSet::new(); + for f in &m.faces { + for (k, &a) in f.iter().enumerate() { + let b = f[(k + 1) % f.len()]; + if !seen.insert((a.min(b), a.max(b))) { + continue; + } + let (pa, pb) = (m.vertices[a as usize], m.vertices[b as usize]); + self.push_transformed( + &[[pa.x, pa.y], [pb.x, pb.y]], + false, + xf, + rgba, + attr, + pattern, + ); + } + } + } + EntityKind::Tolerance(t) => self.tolerance(t, ctx, rgba, attr), + EntityKind::Polyline(p) if p.kind() == PolylineKind::PolygonMesh => { + for edge in polygon_mesh_edges(p) { + self.push_transformed(&edge, false, xf, rgba, attr, pattern); + } + } + EntityKind::Polyline(p) if p.kind() == PolylineKind::PolyfaceMesh => { + for edge in polyface_edges(p) { + self.push_transformed(&edge, false, xf, rgba, attr, pattern); + } } EntityKind::MLine(m) => { @@ -1521,6 +2180,12 @@ impl<'a> Tessellator<'a> { EntityKind::Insert(ins) => self.insert(ins, ctx, lid, rgba, contrast, lw, lt), + EntityKind::Dimension(d) + if d.block_name.is_empty() || self.block(&d.block_name).is_none() => + { + self.dimension(d, ctx, rgba, attr); + } + EntityKind::Dimension(d) => { // AutoCAD bakes dimension graphics into an anonymous block. // Render that instead of reimplementing the dimension engine. @@ -1555,49 +2220,23 @@ impl<'a> Tessellator<'a> { EntityKind::Text(t) => self.text(t, ctx, rgba, attr), - EntityKind::Attribute(a) => self.attribute_text(&a.text, ctx, rgba, attr), - - EntityKind::MText(m) => { - let height = if m.height > 0.0 { m.height } else { 1.0 }; - let face = self.style_face(&m.style); - let lines = self.wrap_mtext(&m.text, m.reference_width, height, face); - let spacing = height - * text::MTEXT_LINE_SPACING - * if m.line_spacing_factor > 0.0 { - m.line_spacing_factor - } else { - 1.0 - }; - - // DWG stores the text direction as a vector when it has one. - let rotation = match m.x_direction { - Some(d) if d.x != 0.0 || d.y != 0.0 => d.y.atan2(d.x), - _ => m.rotation, - }; - let (h_align, dy0) = - text::mtext_layout(m.attachment as u8, lines.len(), height, spacing); + // A multiline attribute (2018 on) is an MTEXT. + EntityKind::Attribute(a) => match &a.mtext { + Some(m) => self.mtext(m, ctx, rgba, attr), + None => self.attribute_text(&a.text, ctx, rgba, attr), + }, - let (sin_r, cos_r) = rotation.sin_cos(); - for (i, line) in lines.iter().enumerate() { - if line.spans.is_empty() { - continue; - } - // Lines stack along the text's own down direction. - let dy = dy0 - i as f64 * spacing; - let x = m.insertion.x - dy * sin_r; - let y = m.insertion.y + dy * cos_r; - self.push_mtext_line( - line, height, x, y, rotation, h_align, ctx, rgba, attr, face, - ); - } - } + EntityKind::MText(m) => self.mtext(m, ctx, rgba, attr), // An ordinary ATTDEF is the template for an ATTRIB carried by the // insert, so drawing it too would double the text. A *constant* // attribute has no ATTRIB: the definition is the only copy, and - // AutoCAD draws it. - EntityKind::AttributeDefinition(a) if a.is_constant() => { - self.attribute_text(&a.text, ctx, rgba, attr) + // AutoCAD draws it, unless it is invisible too. + EntityKind::AttributeDefinition(a) if a.is_constant() && !a.is_invisible() => { + match &a.mtext { + Some(m) => self.mtext(m, ctx, rgba, attr), + None => self.attribute_text(&a.text, ctx, rgba, attr), + } } EntityKind::AttributeDefinition(_) => {} @@ -1695,26 +2334,17 @@ impl<'a> Tessellator<'a> { } if l.arrowhead { - // A filled triangle at the first vertex, pointing back - // along the first segment, as long as the dimension - // style's arrow size (DIMASZ times DIMSCALE) and a third - // as wide: AutoCAD's default closed filled arrowhead. + // At the first vertex, pointing back along the first + // segment, the dimension style's arrow size (DIMASZ + // times DIMSCALE) long, in its leader arrow (DIMLDRBLK). let size = self.arrow_size(&l.style); - let d = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]]; - let len = d[0].hypot(d[1]); - if size > 0.0 && len > 1e-9 { - let u = [d[0] / len, d[1] / len]; - let n = [-u[1], u[0]]; - let base = [pts[0][0] + u[0] * size, pts[0][1] + u[1] * size]; - let half = size / 6.0; - self.scene.push_triangle( - xf.apply(pts[0][0], pts[0][1]), - xf.apply(base[0] + n[0] * half, base[1] + n[1] * half), - xf.apply(base[0] - n[0] * half, base[1] - n[1] * half), - rgba, - attr, - ); - } + let block = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&l.style)) + .map_or(Handle::NULL, |s| s.dimldrblk); + self.arrowhead(block, pts[0], pts[1], size, ctx, rgba, attr); } } @@ -1722,8 +2352,13 @@ impl<'a> Tessellator<'a> { // that can be drawn is the frame, which is what AutoCAD shows for // an image it cannot load, and the count so the host can say so. EntityKind::Image(img) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + let file = self + .doc + .image_defs + .iter() + .find(|d| d.handle == img.image_def); + self.warnings + .external(file.map_or("", |d| d.file_name.as_str())); let u = [img.u_vector.x, img.u_vector.y]; let v = [img.v_vector.x, img.v_vector.y]; let o = [img.insertion.x, img.insertion.y]; @@ -1744,8 +2379,13 @@ impl<'a> Tessellator<'a> { } EntityKind::Underlay(u) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + let file = self + .doc + .underlay_defs + .iter() + .find(|d| d.handle == u.definition); + self.warnings + .external(file.map_or("", |d| d.file_name.as_str())); if u.clip_vertices.len() >= 2 { let pts: Vec<[f64; 2]> = u.clip_vertices.iter().map(|p| [p.x, p.y]).collect(); // Two points are the corners of a rectangle, more are a path. @@ -1771,8 +2411,7 @@ impl<'a> Tessellator<'a> { } EntityKind::Ole2Frame(o) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + self.warnings.external(""); let (a, b) = (o.upper_left, o.lower_right); let frame = [[a.x, a.y], [b.x, a.y], [b.x, b.y], [a.x, b.y]]; self.push_transformed(&frame, true, xf, rgba, attr, pattern); @@ -1796,11 +2435,11 @@ impl<'a> Tessellator<'a> { self.warnings.proxy_without_graphics.saturating_add(1); } - // Meshes, SHAPE (a glyph of an SHX file, which cannot be shipped) - // and every other type the model does not hold. - EntityKind::Polyline(_) | EntityKind::Shape(_) | EntityKind::Unknown(_) => { - self.warnings.unknown_entities = self.warnings.unknown_entities.saturating_add(1); - } + // SHAPE (a glyph of an SHX file, which cannot be shipped) and + // every other type the model does not hold. + EntityKind::Polyline(_) => self.warnings.not_drawn("POLYLINE"), + EntityKind::Shape(_) => self.warnings.not_drawn("SHAPE"), + EntityKind::Unknown(u) => self.warnings.not_drawn(&u.type_name), } } @@ -1841,7 +2480,7 @@ impl<'a> Tessellator<'a> { } else { t.alignment_point.unwrap_or(t.insertion) }; - let value = text::decode_special(&t.value); + let value = self.style_text(&t.style, &t.value); // Aligned and Fit stretch the string between two points rather than // anchoring it at one, so they need its width. Aligned scales the @@ -1849,24 +2488,38 @@ impl<'a> Tessellator<'a> { // instead. let stretched = matches!(t.h_align, HAlign::Aligned | HAlign::Fit); let face = self.style_face(&t.style); + let width_factor = if t.width_factor > 0.0 { + t.width_factor + } else { + 1.0 + }; if stretched { if let Some(second) = t.alignment_point { let first = t.insertion; let span = (second.x - first.x).hypot(second.y - first.y); - let natural = font::Metrics::for_face(face).measure(&value) * t.height; + let natural = + font::Metrics::for_face(face).measure(&value) * t.height * width_factor; if span > 1e-9 && natural > 1e-9 { let factor = span / natural; let fit = t.h_align == HAlign::Fit; + let frame = text::generated_frame( + if fit { t.height } else { t.height * factor }, + (second.y - first.y).atan2(second.x - first.x), + if fit { + width_factor * factor + } else { + width_factor + }, + t.oblique, + t.generation, + ); self.push_text_run( value, first.x, first.y, - if fit { t.height } else { t.height * factor }, - (second.y - first.y).atan2(second.x - first.x), - if fit { factor } else { 1.0 }, - t.oblique, + frame, text::HAlign::Left, - v_align_of(t.v_align), + text::VAlign::Baseline, ctx, rgba, attr, @@ -1881,10 +2534,13 @@ impl<'a> Tessellator<'a> { value, anchor.x, anchor.y, - t.height, - t.rotation, - t.width_factor, - t.oblique, + text::generated_frame( + t.height, + t.rotation, + t.width_factor, + t.oblique, + t.generation, + ), h_align_of(t.h_align), v_align_of(t.v_align), ctx, @@ -1905,13 +2561,16 @@ impl<'a> Tessellator<'a> { }; let face = self.style_face(&t.style); self.push_text_run( - text::decode_special(&t.value), + self.style_text(&t.style, &t.value), anchor.x, anchor.y, - t.height, - t.rotation, - t.width_factor, - t.oblique, + text::generated_frame( + t.height, + t.rotation, + t.width_factor, + t.oblique, + t.generation, + ), h_align_of(t.h_align), v_align_of(t.v_align), ctx, @@ -1932,10 +2591,7 @@ impl<'a> Tessellator<'a> { value: String, x: f64, y: f64, - height: f64, - rotation: f64, - width_factor: f64, - oblique: f64, + (u, v): ([f64; 2], [f64; 2]), h_align: text::HAlign, v_align: text::VAlign, ctx: &Ctx, @@ -1946,22 +2602,30 @@ impl<'a> Tessellator<'a> { if value.trim().is_empty() { return; } + // The stroke font has no CJK and few symbols: a run with characters + // it lacks goes to an outline face, which the browser completes from + // the system's fonts where the bundle has none. + let stroke = super::stroke_font::newstroke(); + let face = if face == font::Face::Stroke + && value + .chars() + .any(|c| !c.is_whitespace() && stroke.glyph(c).is_none()) + { + font::Face::default() + } else { + face + }; let p = ctx.xf.apply(x, y); - // Blocks rotate and scale their contents, so the text follows. - let block_rotation = ctx.xf.b.atan2(ctx.xf.a); - let scale = ctx.xf.scale_magnitude(); + // Blocks rotate, scale and mirror their contents, so the text + // follows, its frame with them. + let xf = &ctx.xf; + let linear = |w: [f64; 2]| [xf.a * w[0] + xf.c * w[1], xf.b * w[0] + xf.d * w[1]]; self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); let run = text::TextRun { x: p[0], y: p[1], - height: if height > 0.0 { height * scale } else { scale }, - rotation: rotation + block_rotation, - width_factor: if width_factor > 0.0 { - width_factor - } else { - 1.0 - }, - oblique, + u: linear(u), + v: linear(v), rgba, attr, h_align, @@ -1969,6 +2633,7 @@ impl<'a> Tessellator<'a> { text: value, face, order: self.scene.order, + clip: 0, }; match face { font::Face::Stroke => self.push_stroke_text(&run), @@ -1985,19 +2650,14 @@ impl<'a> Tessellator<'a> { /// order, layer visibility and viewport clipping all come from `attr`. fn push_stroke_text(&mut self, run: &text::TextRun) { let stroke = super::stroke_font::newstroke(); - let height = run.height; - if !(height > 0.0) { + if !(run.height() > 0.0) { return; } - let advance_scale = height * run.width_factor; - let width: f64 = run - .text - .chars() - .map(|c| stroke.advance(c) as f64 * advance_scale) - .sum(); + // Everything is in cap heights until the frame places it. + let width: f64 = run.text.chars().map(|c| stroke.advance(c) as f64).sum(); - // Aligned and Fit stretch between two points the record does not carry, - // so they centre, as the host's path does. + // Aligned and Fit arrive stretched and left-aligned (`text`); here + // they centre, as the host's path does. let dx = match run.h_align { text::HAlign::Center | text::HAlign::Middle @@ -2006,17 +2666,12 @@ impl<'a> Tessellator<'a> { text::HAlign::Right => -width, text::HAlign::Left => 0.0, }; - let dy = match run.v_align { - text::VAlign::Middle => -height / 2.0, - text::VAlign::Top => -height, - text::VAlign::Baseline | text::VAlign::Bottom => 0.0, - }; - - let (sin_r, cos_r) = run.rotation.sin_cos(); - let tan_o = if run.oblique.abs() < std::f64::consts::FRAC_PI_2 { - run.oblique.tan() - } else { - 0.0 + let dy = text::baseline_offset(run.h_align, run.v_align, font::Face::Stroke); + let at = |a: f64, b: f64| { + [ + run.x + a * run.u[0] + b * run.v[0], + run.y + a * run.u[1] + b * run.v[1], + ] }; let mut pen = dx; @@ -2031,22 +2686,142 @@ impl<'a> Tessellator<'a> { for polyline in glyph.polylines() { points.clear(); for p in polyline.points() { - // Glyph units are cap heights, so the text height is - // the only scale there is. - let gx = pen + p[0] as f64 * advance_scale; - let gy = dy + p[1] as f64 * height; - // Oblique leans the vertical, shifting x by height. - let sx = gx + gy * tan_o; - points.push([ - run.x + sx * cos_r - gy * sin_r, - run.y + sx * sin_r + gy * cos_r, - ]); + points.push(at(pen + p[0] as f64, dy + p[1] as f64)); } self.scene.push_polyline(&points, false, run.rgba, run.attr); } } - pen += stroke.advance(c) as f64 * advance_scale; + pen += stroke.advance(c) as f64; + } + } + + /// An LWPOLYLINE or 2D POLYLINE: a line through its bulges, or, where its + /// segments have width, the band they fill (outlined under `$FILLMODE` + /// 0). The band is in object coordinates and transformed corner by + /// corner, so a scaled block widens it too. + /// + /// A dashed one carries whether its linetype runs on across its vertices + /// (group 70 bit 128, PLINEGEN); without it each segment starts its + /// pattern afresh, as AutoCAD draws it. + fn polyline( + &mut self, + verts: &[WideVertex], + closed: bool, + xf: &Affine, + rgba: u32, + attr: u32, + dashes: Option<(&Pattern, bool)>, + ) { + let pattern = dashes.map(|(p, _)| p); + if !verts + .iter() + .any(|v| v.start_width > 0.0 || v.end_width > 0.0) + { + let pts: Vec<([f64; 2], f64)> = verts.iter().map(|v| (v.p, v.bulge)).collect(); + if pattern.is_none() + && pts.iter().any(|p| p.1 != 0.0) + && self.push_bulged(&pts, closed, xf, rgba, attr) + { + return; + } + if let Some((p, false)) = dashes { + let segments = if closed { + pts.len() + } else { + pts.len().saturating_sub(1) + }; + for i in 0..segments { + let piece = expand_bulges(&[pts[i], pts[(i + 1) % pts.len()]], false); + self.push_transformed(&piece, false, xf, rgba, attr, Some(p)); + } + return; + } + let flat = expand_bulges(&pts, closed); + self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + return; + } + let band = wide::band(verts, closed); + for run in &band.thin { + self.push_transformed(run, false, xf, rgba, attr, pattern); + } + if self.doc.header.fillmode { + for [a, b, c] in &band.triangles { + self.scene.push_triangle( + xf.apply(a[0], a[1]), + xf.apply(b[0], b[1]), + xf.apply(c[0], c[1]), + rgba, + attr, + ); + } + } else { + for outline in &band.outlines { + self.push_transformed(outline, true, xf, rgba, attr, None); + } + } + } + + /// A circular arc in object coordinates (centre, radius, start angle, + /// signed sweep): arc strokes, exact at any zoom, when nothing dashes it + /// and the transform keeps circles round; else its flattened points. + #[allow(clippy::too_many_arguments)] + fn push_curve( + &mut self, + cx: f64, + cy: f64, + r: f64, + start: f64, + sweep: f64, + xf: &Affine, + rgba: u32, + attr: u32, + pattern: Option<&Pattern>, + ) { + if pattern.is_none() { + if let Some((scale, turn, mirrored)) = similarity(xf) { + let c = xf.apply(cx, cy); + let (start, sweep) = if mirrored { + (turn - start, -sweep) + } else { + (start + turn, sweep) + }; + self.scene + .push_arc(c[0], c[1], r * scale, start, sweep, rgba, attr); + return; + } + } + let pts = curves::arc_points(cx, cy, r, start, sweep); + self.push_transformed(&pts, false, xf, rgba, attr, pattern); + } + + /// A polyline's straight and bulged segments, the bulged ones as arc + /// strokes, under a transform that keeps circles round; false, drawing + /// nothing, under any other. + fn push_bulged( + &mut self, + pts: &[([f64; 2], f64)], + closed: bool, + xf: &Affine, + rgba: u32, + attr: u32, + ) -> bool { + let Some((_, _, mirrored)) = similarity(xf) else { + return false; + }; + let n = pts.len(); + let segments = if closed { n } else { n.saturating_sub(1) }; + for i in 0..segments { + let ((a, bulge), (b, _)) = (pts[i], pts[(i + 1) % n]); + let (wa, wb) = (xf.apply(a[0], a[1]), xf.apply(b[0], b[1])); + let bulge = if mirrored { -bulge } else { bulge }; + if bulge == 0.0 || !bulge.is_finite() { + self.scene + .push_stroke(wa[0], wa[1], wb[0], wb[1], rgba, attr); + } else if let Some((cx, cy, r, start, sweep)) = curves::bulge_arc(wa, wb, bulge) { + self.scene.push_arc(cx, cy, r, start, sweep, rgba, attr); + } } + true } fn push_transformed( @@ -2089,7 +2864,8 @@ impl<'a> Tessellator<'a> { self.scene.push_polyline(pts, closed, rgba, attr); } - /// Stamp a complex linetype's text along a path it has just dashed. + /// Stamp a complex linetype's text and shapes along a path it has just + /// dashed. fn push_line_labels( &mut self, pts: &[[f64; 2]], @@ -2103,29 +2879,306 @@ impl<'a> Tessellator<'a> { } for place in linetype::dash_labels(pts, closed, pattern) { let label = &pattern.labels[place.label]; - let run = text::TextRun { - x: place.x, - y: place.y, - height: label.height, - rotation: place.angle, - width_factor: 1.0, - oblique: 0.0, + match &label.mark { + linetype::Mark::Text { text, face } => { + let (u, v) = text::frame(label.height, place.angle, 1.0, 0.0); + let run = text::TextRun { + x: place.x, + y: place.y, + u, + v, + rgba, + attr, + h_align: text::HAlign::Left, + v_align: text::VAlign::Baseline, + text: text.clone(), + face: *face, + order: self.scene.order, + clip: 0, + }; + self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); + match face { + font::Face::Stroke => self.push_stroke_text(&run), + font::Face::TrueType(_) => self.scene.push_text(run), + } + } + linetype::Mark::Shape(parts) => { + let (sin, cos) = place.angle.sin_cos(); + let k = label.height; + let at = |p: [f64; 2]| { + [ + place.x + k * (p[0] * cos - p[1] * sin), + place.y + k * (p[0] * sin + p[1] * cos), + ] + }; + for part in *parts { + match part { + linetype::ShapePart::Line(ps) => { + for w in ps.windows(2) { + let (a, b) = (at(w[0]), at(w[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + } + linetype::ShapePart::Arc(c, r, start, sweep) => { + let c = at(*c); + self.scene.push_arc( + c[0], + c[1], + r * k, + start + place.angle, + *sweep, + rgba, + attr, + ); + } + } + } + } + } + } + } + + /// An arrowhead `size` long at `tip`, pointing away from `from`: the + /// arrow block `block` names, drawn from the drawing's block of that + /// name when it has one (AutoCAD saves `_Open` and the like there), else + /// the built-in shape of that name, else the closed filled default. + #[allow(clippy::too_many_arguments)] + fn arrowhead( + &mut self, + block: Handle, + tip: [f64; 2], + from: [f64; 2], + size: f64, + ctx: &Ctx, + rgba: u32, + attr: u32, + ) { + let d = [tip[0] - from[0], tip[1] - from[1]]; + if !(size > 0.0) || !(d[0].hypot(d[1]) > 1e-9) { + return; + } + let angle = d[1].atan2(d[0]); + let place = ctx.xf.mul(&Affine::new(tip[0], tip[1], size, size, angle)); + let name = (!block.is_null()) + .then(|| self.doc.blocks.iter().find(|b| b.record == block)) + .flatten(); + if let Some(b) = name.filter(|b| !b.entities.is_empty()) { + let (lid, lw) = (attr as u16, (attr >> 16) as u8); + let contrast = (attr >> 24) as u8 & FLAG_CONTRAST != 0; + let name = b.name.clone(); + self.expand_block( + &name, + &place, + lid, rgba, - attr, - h_align: text::HAlign::Left, - v_align: text::VAlign::Baseline, - text: label.text.clone(), - face: label.face, - order: self.scene.order, + contrast, + lw, + 0, + ctx.lt_scale, + ctx.depth, + ); + return; + } + let kind = name.map_or(String::new(), |b| b.name.to_ascii_uppercase()); + let shape = arrow_shape(&kind); + let world = |p: &[f64; 2]| place.apply(p[0], p[1]); + for t in shape.filled.chunks(3) { + if let [a, b, c] = t { + self.scene + .push_triangle(world(a), world(b), world(c), rgba, attr); + } + } + for line in shape.lines { + let pts: Vec<[f64; 2]> = line.iter().map(world).collect(); + self.scene.push_polyline(&pts, false, rgba, attr); + } + } + + /// A dimension saved without its block, drawn from its definition points + /// and its style (`dimension.rs`). A style the drawing does not have + /// falls back to the header's variables. + fn dimension(&mut self, d: &crate::cad::Dimension, ctx: &Ctx, rgba: u32, attr: u32) { + let h = &self.doc.header; + let style = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&d.style)) + .cloned() + .unwrap_or_else(|| crate::cad::DimStyle { + dimscale: h.dimscale, + dimasz: h.dimasz, + dimtxt: h.dimtxt, + dimgap: h.dimgap, + ..crate::cad::DimStyle::default() + }); + let text_style = self + .doc + .text_styles + .iter() + .find(|ts| ts.handle == style.dimtxsty && !style.dimtxsty.is_null()) + .map(|ts| ts.name.clone()) + .unwrap_or_default(); + let face = self.style_face(&text_style); + let metrics = font::Metrics::for_face(face); + let s = dimension::Style::new(&style); + let measure = |t: &str| metrics.measure(&text::decode_special(t)); + let Some(g) = dimension::graphics(d, &s, &measure) else { + return; + }; + for [a, b] in &g.lines { + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + for (c, r, start, sweep) in &g.arcs { + self.push_curve(c[0], c[1], *r, *start, *sweep, &ctx.xf, rgba, attr, None); + } + for (tip, from) in &g.arrows { + self.arrowhead(style.dimblk, *tip, *from, s.asz, ctx, rgba, attr); + } + if let Some(label) = g.label { + let content = crate::cad::MText { + insertion: crate::cad::Vec3::new(label.at[0], label.at[1], 0.0), + height: s.txt, + attachment: 5, + style: text_style, + rotation: label.rotation, + text: label.text, + ..crate::cad::MText::default() }; - self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); - match label.face { - font::Face::Stroke => self.push_stroke_text(&run), - font::Face::TrueType(_) => self.scene.push_text(run), + self.mtext(&content, ctx, rgba, attr); + } + } + + /// A TOLERANCE's feature control frame: a row per `^J`, a box per + /// `%%v`, each its text and the style's gap around it, the GD&T codes as + /// the symbols they stand for; hung from the middle of the first row's + /// left side, along its direction. + fn tolerance(&mut self, t: &crate::cad::Tolerance, ctx: &Ctx, rgba: u32, attr: u32) { + let style = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&t.dimstyle)); + let header = &self.doc.header; + let (txt, gap, scale) = match style { + Some(s) => (s.dimtxt, s.dimgap, s.dimscale), + None => (header.dimtxt, header.dimgap, header.dimscale), + }; + let scale = if scale > 0.0 && scale.is_finite() { + scale + } else { + 1.0 + }; + let height = if txt > 0.0 { txt * scale } else { 0.18 }; + let gap = gap.abs() * scale; + let face = style + .and_then(|s| { + self.doc + .text_styles + .iter() + .find(|ts| ts.handle == s.dimtxsty && !s.dimtxsty.is_null()) + }) + .map_or(font::Face::Stroke, |ts| self.style_face(&ts.name)); + let metrics = font::Metrics::for_face(face); + let rotation = t.x_direction.y.atan2(t.x_direction.x); + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |a: f64, b: f64| { + ctx.xf.apply( + t.insertion.x + a * cos_r - b * sin_r, + t.insertion.y + a * sin_r + b * cos_r, + ) + }; + let row_height = height + 2.0 * gap; + for (r, row) in t.text.split("^J").enumerate() { + // The first row's middle is on the insertion point. + let top = row_height / 2.0 - r as f64 * row_height; + let mut left = 0.0; + for cell in row.split("%%v") { + let shown = text::decode_special(&gdt_codes(cell)); + if shown.is_empty() { + continue; + } + let width = metrics.measure(&shown) * height + 2.0 * gap; + let corners = [ + (left, top), + (left + width, top), + (left + width, top - row_height), + (left, top - row_height), + ] + .map(|(a, b)| at(a, b)); + self.scene.push_polyline(&corners, true, rgba, attr); + let (x, y) = ( + t.insertion.x + (left + gap) * cos_r - (top - gap - height) * sin_r, + t.insertion.y + (left + gap) * sin_r + (top - gap - height) * cos_r, + ); + let frame = text::frame(height, rotation, 1.0, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(shown, x, y, frame, l, b, ctx, rgba, attr, face); + left += width; } } } + /// A POINT in the drawing's `$PDMODE`: a dot (0), nothing (1), a plus + /// (2), a cross (3), a tick up (4), and around it a circle (+32) or a + /// square (+64), `$PDSIZE` across: units when above 0, else a share of + /// the drawing's height (5% at 0, -n for n%), as AutoCAD takes the + /// window's. + fn point(&mut self, p: &crate::cad::Point, xf: &Affine, rgba: u32, attr: u32) { + let h = &self.doc.header; + let mode = h.pdmode; + let at = xf.apply(p.location.x, p.location.y); + if mode & 7 == 0 { + // A zero-length stroke, which the renderer draws as a round dot. + self.scene + .push_stroke(at[0], at[1], at[0], at[1], rgba, attr); + } + if mode & !(7 | 32 | 64) != 0 || matches!(mode & 7, 0 | 1) && mode & 96 == 0 { + return; + } + let tall = h.extmax.y - h.extmin.y; + let tall = if tall.is_finite() && tall > 0.0 { + tall + } else { + 20.0 + }; + let size = match h.pdsize { + s if s > 0.0 => s, + s if s < 0.0 => tall * -s / 100.0, + _ => tall * 0.05, + }; + let r = size / 2.0; + let (sin_a, cos_a) = p.x_axis_angle.sin_cos(); + let local = |x: f64, y: f64| { + xf.apply( + p.location.x + x * cos_a - y * sin_a, + p.location.y + x * sin_a + y * cos_a, + ) + }; + let segments: &[[(f64, f64); 2]] = match mode & 7 { + 2 => &[[(-r, 0.0), (r, 0.0)], [(0.0, -r), (0.0, r)]], + 3 => &[[(-r, -r), (r, r)], [(-r, r), (r, -r)]], + 4 => &[[(0.0, 0.0), (0.0, r)]], + _ => &[], + }; + for [(x0, y0), (x1, y1)] in segments { + let (a, b) = (local(*x0, *y0), local(*x1, *y1)); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + if mode & 32 != 0 { + let ring: Vec<[f64; 2]> = curves::flatten_circle(0.0, 0.0, r) + .into_iter() + .map(|q| local(q[0], q[1])) + .collect(); + self.scene.push_polyline(&ring, true, rgba, attr); + } + if mode & 64 != 0 { + let square = [(-r, -r), (r, -r), (r, r), (-r, r)].map(|(x, y)| local(x, y)); + self.scene.push_polyline(&square, true, rgba, attr); + } + } + #[allow(clippy::too_many_arguments)] fn insert( &mut self, @@ -2168,9 +3221,12 @@ impl<'a> Tessellator<'a> { ins.insertion.y + dx * sin_r + dy * cos_r, ]; let child = Affine::new(p[0], p[1], sx, sy, ins.rotation); + let xf = ctx.xf.mul(&child); + let mark = self.scene.mark(); + let infinite = self.pending_infinite.len(); self.expand_block( &ins.block_name, - &ctx.xf.mul(&child), + &xf, lid, rgba, contrast, @@ -2179,6 +3235,22 @@ impl<'a> Tessellator<'a> { ctx.lt_scale, ctx.depth, ); + if let Some(outline) = self.xclip_outline(ins, &xf) { + if let Some(shape) = ClipShape::polygon(outline.clone()) { + // The block's rays and xlines end at the boundary rather + // than at the drawing's extents. + let (min, max) = shape.bounds(); + for line in self.pending_infinite.split_off(infinite) { + self.push_infinite(&line, min, max); + } + self.scene.clip_since(mark, &shape); + } + if self.doc.header.xclipframe != 0 { + let flags = if contrast { FLAG_CONTRAST } else { 0 }; + let attr = pack_attr(lid, lw, flags); + self.push_path(&outline, true, rgba, attr, None); + } + } } // The insert's attributes (title-block fields, room tags) are not in @@ -2203,6 +3275,29 @@ impl<'a> Tessellator<'a> { } } + /// An insert's XCLIP boundary in world coordinates, when clipping is on: + /// it is in block coordinates, so the block's base point comes off as + /// for its entities. Two points are the corners of a rectangle. + fn xclip_outline(&self, ins: &crate::cad::Insert, xf: &Affine) -> Option> { + let clip = ins.clip.as_ref().filter(|c| c.enabled)?; + let base = self.block(&ins.block_name)?.base_point; + let local: Vec<[f64; 2]> = match clip.boundary.as_slice() { + [a, b] => { + let (x0, x1) = (a.x.min(b.x), a.x.max(b.x)); + let (y0, y1) = (a.y.min(b.y), a.y.max(b.y)); + vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]] + } + ps if ps.len() >= 3 => ps.iter().map(|p| [p.x, p.y]).collect(), + _ => return None, + }; + Some( + local + .iter() + .map(|p| xf.apply(p[0] - base.x, p[1] - base.y)) + .collect(), + ) + } + #[allow(clippy::too_many_arguments)] fn expand_block( &mut self, @@ -2235,6 +3330,43 @@ impl<'a> Tessellator<'a> { return; }; + // An xref's content is in another file, which this viewer was not + // given: its path is drawn at the insertion point, as AutoCAD shows + // an xref it cannot find. + if block.is_xref() && block.entities.is_empty() { + let file = if block.xref_path.is_empty() { + &block.name + } else { + &block.xref_path + }; + self.warnings.external(file); + let at = Ctx { + xf: *xf, + layer: lid, + rgba, + contrast, + lw, + lt, + lt_scale, + depth, + }; + let flags = if contrast { FLAG_CONTRAST } else { 0 }; + let face = self.style_face(&self.doc.header.textstyle); + self.push_text_run( + file.clone(), + 0.0, + 0.0, + text::frame(self.doc.header.textsize, 0.0, 1.0, 0.0), + text::HAlign::Left, + text::VAlign::Baseline, + &at, + rgba, + pack_attr(lid, lw, flags), + face, + ); + return; + } + // Block geometry is defined relative to the block's base point. let base = block.base_point; let local = xf.mul(&Affine { @@ -2463,6 +3595,17 @@ impl<'a> Tessellator<'a> { if truncated { self.warnings.hatch_patterns_truncated = self.warnings.hatch_patterns_truncated.saturating_add(1); + // Too dense to draw line by line: the region in the tone so many + // lines average out to, rather than some of them and a gap. + let alpha = ((rgba >> 24) as f64 * TRUNCATED_PATTERN_TONE).round() as u32; + let tone = (rgba & 0x00FF_FFFF) | (alpha << 24); + for t in fill::fill_region(&loops, island_style(h)).chunks(3) { + if t.len() == 3 { + let [a, b, c] = [t[0], t[1], t[2]].map(|p| ctx.xf.apply(p[0], p[1])); + self.scene.push_triangle(a, b, c, tone, attr); + } + } + return; } // Record the pattern's spacing so the renderer can fade these lines @@ -2537,12 +3680,16 @@ fn spline_points(s: &crate::cad::Spline) -> Vec<[f64; 2]> { ) } +/// What "Default" stands for: AutoCAD's `LWDEFAULT`, 0.25 mm unless a user +/// changed it, which is a setting of their AutoCAD, not of the drawing. +const DEFAULT_LINEWEIGHT: u8 = 25; + /// Resolve a lineweight to hundredths of a millimetre, 0 meaning hairline. fn lineweight_value(lw: LineWeight, inherited: u8) -> u8 { match lw { LineWeight::Value(v) if v > 0 => v.min(211) as u8, LineWeight::Value(_) => 0, - LineWeight::Default => 0, + LineWeight::Default => DEFAULT_LINEWEIGHT, LineWeight::ByLayer | LineWeight::ByBlock => inherited, } } @@ -2650,8 +3797,11 @@ mod tests { #[test] fn a_minsert_asking_past_the_budget_is_drawn_up_to_it() { - // 160,000 rings of about seventy strokes: eleven million asked for. - let doc = minsert(400, 400); + // 810,000 cells of three rings of four arc strokes: 9.7 million + // asked for, under the cell cap. + let mut doc = minsert(900, 900); + let ring = doc.blocks[1].entities[0].clone(); + doc.blocks[1].entities.extend([ring.clone(), ring]); let mut t = Tessellator::new(&doc); t.run(); // Stopped between entities: over by one circle at most, and not short. @@ -2715,6 +3865,80 @@ mod tests { t.scene } + fn fill_area(s: &Scene) -> f64 { + s.fills + .chunks(3) + .map(|t| { + ((t[1].x - t[0].x) * (t[2].y - t[0].y) - (t[2].x - t[0].x) * (t[1].y - t[0].y)) + .abs() + / 2.0 + }) + .sum() + } + + /// A polyline with width fills its band, constant width standing for + /// every vertex's; one without stays strokes. + #[test] + fn a_wide_polyline_is_a_filled_band() { + use crate::cad::{LwPolyline, LwVertex, Vec2}; + let line = |w: f64| LwPolyline { + constant_width: w, + vertices: vec![ + LwVertex { + point: Vec2::new(0.0, 0.0), + ..LwVertex::default() + }, + LwVertex { + point: Vec2::new(10.0, 0.0), + ..LwVertex::default() + }, + ], + ..LwPolyline::default() + }; + let wide = tessellated(EntityKind::LwPolyline(line(2.0))); + // Only the band's four edges, which anti-alias it. + assert_eq!(wide.strokes.len(), 4); + assert!(wide + .strokes + .iter() + .all(|s| (s.attr >> 16) as u8 == super::super::scene::FILL_EDGE)); + assert!( + (fill_area(&wide) - 20.0).abs() < 1e-9, + "{}", + fill_area(&wide) + ); + let thin = tessellated(EntityKind::LwPolyline(line(0.0))); + assert_eq!((thin.strokes.len(), thin.fills.len()), (1, 0)); + } + + /// `$FILLMODE` 0 draws the band's edges instead. + #[test] + fn without_fillmode_a_wide_polyline_is_its_outline() { + use crate::cad::{LwPolyline, LwVertex, Vec2}; + let mut doc = CadDrawing::default(); + doc.header.fillmode = false; + let mut t = Tessellator::new(&doc); + t.tessellate_entity(&Entity { + kind: EntityKind::LwPolyline(LwPolyline { + constant_width: 2.0, + vertices: vec![ + LwVertex { + point: Vec2::new(0.0, 0.0), + ..LwVertex::default() + }, + LwVertex { + point: Vec2::new(10.0, 0.0), + ..LwVertex::default() + }, + ], + ..LwPolyline::default() + }), + ..Entity::default() + }); + assert!(t.scene.fills.is_empty()); + assert_eq!(t.scene.strokes.len(), 4); + } + /// TRACE is filled like SOLID, its corners in the same Z order. #[test] fn a_trace_is_filled_like_a_solid() { @@ -2800,7 +4024,7 @@ mod tests { #[test] fn lineweight_resolution() { assert_eq!(lineweight_value(LineWeight::Value(50), 9), 50); - assert_eq!(lineweight_value(LineWeight::Default, 9), 0); + assert_eq!(lineweight_value(LineWeight::Default, 9), DEFAULT_LINEWEIGHT); assert_eq!(lineweight_value(LineWeight::ByLayer, 9), 9); assert_eq!(lineweight_value(LineWeight::ByBlock, 13), 13); // Out-of-range values clamp rather than wrapping through the u8 cast. diff --git a/crates/exav-render/src/formats/dwg/text.rs b/crates/exav-render/src/formats/dwg/text.rs index 26efbbe..d64ae48 100644 --- a/crates/exav-render/src/formats/dwg/text.rs +++ b/crates/exav-render/src/formats/dwg/text.rs @@ -39,10 +39,11 @@ pub enum VAlign { pub struct TextRun { pub x: f64, pub y: f64, - pub height: f64, - pub rotation: f64, - pub width_factor: f64, - pub oblique: f64, + /// One cap height of advance along the baseline, width factor applied. + pub u: [f64; 2], + /// One cap height up the glyphs, oblique applied. With `u`, it carries + /// rotation, mirroring and a block's non-uniform scale. + pub v: [f64; 2], pub rgba: u32, pub attr: u32, pub h_align: HAlign, @@ -52,6 +53,101 @@ pub struct TextRun { pub face: super::font::Face, /// Position in the drawing's draw order. pub order: u32, + /// The clip region the run is drawn through, from 1 (`Scene::clips`), + /// or 0: a run that crosses a viewport's edge or an XCLIP boundary is + /// cut there by the renderer, which knows how wide it is. + pub clip: u16, +} + +impl TextRun { + /// Cap height on the sheet. + pub fn height(&self) -> f64 { + self.v[0].hypot(self.v[1]) + } + + /// The longer of the frame's two units, for bounding what the run covers. + pub fn unit(&self) -> f64 { + self.u[0].hypot(self.u[1]).max(self.height()) + } +} + +/// A run's frame (`TextRun::u`, `TextRun::v`) from a height, a rotation, a +/// width factor and an oblique angle (radians, leaning right). +pub fn frame(height: f64, rotation: f64, width_factor: f64, oblique: f64) -> ([f64; 2], [f64; 2]) { + let (s, c) = rotation.sin_cos(); + let height = if height > 0.0 { height } else { 1.0 }; + let w = if width_factor > 0.0 { + width_factor + } else { + 1.0 + }; + let t = if oblique.abs() < std::f64::consts::FRAC_PI_2 { + oblique.tan() + } else { + 0.0 + }; + ( + [c * height * w, s * height * w], + [(c * t - s) * height, (s * t + c) * height], + ) +} + +/// A TEXT's or ATTRIB's frame, mirrored as its generation flags say: 2 +/// backward (mirrored along the baseline), 4 upside down. A mirror turns the +/// slant the other way too. +pub fn generated_frame( + height: f64, + rotation: f64, + width_factor: f64, + oblique: f64, + generation: i16, +) -> ([f64; 2], [f64; 2]) { + let backward = generation & 2 != 0; + let upside_down = generation & 4 != 0; + let lean = if backward != upside_down { + -oblique + } else { + oblique + }; + let (mut u, mut v) = frame(height, rotation, width_factor, lean); + if backward { + u = [-u[0], -u[1]]; + } + if upside_down { + v = [-v[0], -v[1]]; + } + (u, v) +} + +/// How far below the baseline a face's descenders reach, in cap heights: +/// where Bottom alignment puts the baseline. The outline faces' are their +/// OS/2 descenders over their cap heights; the stroke font stands in for +/// SHX fonts, whose usual one (txt.shx: 2 below 6 above) is a third. +pub fn descent(face: Face) -> f64 { + match face { + Face::Stroke => 1.0 / 3.0, + Face::TrueType(f) => match f.family { + super::font::Family::Sans => 434.0 / 1409.0, + super::font::Family::Serif => 442.0 / 1341.0, + super::font::Family::Mono => 615.0 / 1349.0, + }, + } +} + +/// Where the baseline sits from the anchor, in cap heights up: Bottom puts +/// the descenders on it, Middle centres the capitals, Top their tops, and +/// the Middle horizontal alignment (TEXT's MIDDLE, unlike Middle Center) +/// centres capitals and descenders together. +pub fn baseline_offset(h: HAlign, v: VAlign, face: Face) -> f64 { + if h == HAlign::Middle { + return -(1.0 - descent(face)) / 2.0; + } + match v { + VAlign::Baseline => 0.0, + VAlign::Bottom => descent(face), + VAlign::Middle => -0.5, + VAlign::Top => -1.0, + } } pub const TEXT_RECORD_BYTES: usize = 48; @@ -224,6 +320,24 @@ pub fn decode_special(s: &str) -> String { // %%u, %%o and %%k toggle under/over/strike; we drop the styling. Some('u') | Some('U') | Some('o') | Some('O') | Some('k') | Some('K') => {} Some('%') => out.push('%'), + // %%nnn: the character of that number, three digits. + Some(d) if d.is_ascii_digit() => { + let mut code = d.to_digit(10).unwrap_or(0); + let mut digits = 1; + while digits < 3 { + match chars.peek().and_then(|c| c.to_digit(10)) { + Some(n) => { + code = code * 10 + n; + chars.next(); + digits += 1; + } + None => break, + } + } + if let Some(c) = u8::try_from(code).ok().and_then(windows_1252) { + out.push(c); + } + } Some(other) => { out.push('%'); out.push('%'); @@ -235,11 +349,24 @@ pub fn decode_special(s: &str) -> String { out } -/// Strip MTEXT inline formatting and split into lines. -/// -/// Formatting is dropped rather than honoured: mixed fonts, per-run colour and -/// stacked fractions are a documented gap. Stacked fractions degrade to `a/b` -/// so the content survives even though the layout does not. +/// A Windows-1252 byte as a character; None for a control character or +/// one of the five the code page leaves out. +fn windows_1252(b: u8) -> Option { + const HIGH: [u16; 32] = [ + 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, + 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, + 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178, + ]; + match b { + 0..=0x1F | 0x7F => None, + 0x80..=0x9F => { + char::from_u32(u32::from(HIGH[usize::from(b - 0x80)])).filter(|&c| c != '\0') + } + _ => Some(char::from(b)), + } +} + +/// MTEXT as plain lines: the formatting gone, a stack as `a/b`. pub fn mtext_lines(raw: &str) -> Vec { mtext_spans(raw) .into_iter() @@ -265,13 +392,35 @@ pub enum SpanHeight { /// /// The code is not documented by Autodesk; this follows the arguments the /// format is widely understood to use: `l` a left indent applying to every -/// line, `i` an extra indent on the first line only, and `t` a list of tab -/// stops. `q` justification and `x` line spacing are read and ignored. +/// line, `i` an extra indent on the first line only, `t` a list of tab stops +/// and `q` the justification. `x` line spacing is ignored. #[derive(Clone, Debug, PartialEq, Default)] pub struct Paragraph { pub indent: f64, pub first_indent: f64, pub tabs: Vec, + /// `q`: the lines aligned in the box this way rather than by the + /// attachment point (justified and distributed taken as left). + pub align: Option, +} + +/// A stacked fraction from `\S`. +#[derive(Clone, Debug, PartialEq)] +pub struct Stack { + pub upper: String, + pub lower: String, + pub kind: StackKind, +} + +/// The character between a stack's parts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StackKind { + /// `/`: one above the other across a bar. + Fraction, + /// `#`: side by side across a slash. + Diagonal, + /// `^`: one above the other, no bar, left-aligned (tolerances). + Tolerance, } /// One laid-out line: its runs and the paragraph settings in force. @@ -295,6 +444,14 @@ pub struct Span { pub underline: bool, pub overline: bool, pub strike: bool, + /// `\W`: a width factor in place of the style's. + pub width: Option, + /// `\Q`: an oblique angle (radians) in place of the style's. + pub oblique: Option
{ + let t = d.text_midpoint; + (d.flags & 128 != 0 || t.x != 0.0 || t.y != 0.0).then(|| p(t)) +} + +pub fn graphics(d: &Dimension, s: &Style, measure: &dyn Fn(&str) -> f64) -> Option { + match d.kind { + DimensionKind::Linear | DimensionKind::Aligned => linear(d, s, measure), + DimensionKind::Radius | DimensionKind::Diameter => radial(d, s), + DimensionKind::Angular | DimensionKind::Angular3Point => angular(d, s), + DimensionKind::Ordinate => ordinate(d, s), + } +} + +/// An extension line from `from` to `to` on the dimension line: `exo` short +/// of the feature, `exe` past the line. +fn extension(g: &mut Graphics, from: P, to: P, s: &Style) { + if let Some(e) = unit(sub(to, from)) { + if len(sub(to, from)) > s.exo { + g.lines + .push([add(from, mul(e, s.exo)), add(to, mul(e, s.exe))]); + } + } +} + +/// The ends of a dimension line or arc: arrowheads, or with DIMTSZ ticks +/// at 45 degrees and the line run on by DIMDLE. +fn ends(g: &mut Graphics, a: P, b: P, s: &Style) { + let Some(u) = unit(sub(b, a)) else { + return; + }; + if s.tsz > 0.0 { + let t = mul(add(u, perp(u)), 0.5 * s.tsz); + for c in [a, b] { + g.lines.push([sub(c, t), add(c, t)]); + } + } else { + g.arrows.push((a, b)); + g.arrows.push((b, a)); + } +} + +fn linear(d: &Dimension, s: &Style, measure: &dyn Fn(&str) -> f64) -> Option { + let (p1, p2, on) = (p(d.point13), p(d.point14), p(d.definition_point)); + let u = match d.kind { + DimensionKind::Aligned => unit(sub(p2, p1)).unwrap_or([1.0, 0.0]), + _ => polar(d.angle), + }; + let a = add(on, mul(u, dot(sub(p1, on), u))); + let b = add(on, mul(u, dot(sub(p2, on), u))); + let mut g = Graphics::default(); + extension(&mut g, p1, a, s); + extension(&mut g, p2, b, s); + let measured = dot(sub(p2, p1), u).abs() * s.lfac; + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(u[1].atan2(u[0])) + }; + let up = perp(polar(rotation)); + let mid = mul(add(a, b), 0.5); + let label = content(d, number(measured, s.dec, s.zin, s.dsep)).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| { + if s.above { + add(mid, mul(up, s.gap + s.txt / 2.0)) + } else { + mid + } + }), + text, + rotation, + }); + let du = unit(sub(b, a)).unwrap_or(u); + let run_on = if s.tsz > 0.0 { + mul(du, s.dle) + } else { + [0.0, 0.0] + }; + let (from, to) = (sub(a, run_on), add(b, run_on)); + // Text on the line breaks it, a gap either side. + let cut = label.as_ref().filter(|l| { + let off = sub(l.at, a); + let along = dot(off, du); + (dot(off, perp(du))).abs() < s.txt / 2.0 && along > 0.0 && along < len(sub(b, a)) + }); + match cut { + Some(l) => { + let half = measure(&l.text) * s.txt / 2.0 + s.gap; + let along = dot(sub(l.at, a), du); + let (c0, c1) = (add(a, mul(du, along - half)), add(a, mul(du, along + half))); + if dot(sub(c0, from), du) > 0.0 { + g.lines.push([from, c0]); + } + if dot(sub(to, c1), du) > 0.0 { + g.lines.push([c1, to]); + } + } + None => g.lines.push([from, to]), + } + ends(&mut g, a, b, s); + g.label = label; + Some(g) +} + +fn radial(d: &Dimension, s: &Style) -> Option { + let (far, near) = (p(d.definition_point), p(d.point15)); + let mut g = Graphics::default(); + let label_at = stored_text(d); + let (measured, prefix) = match d.kind { + DimensionKind::Radius => (len(sub(near, far)), "R"), + _ => (len(sub(near, far)), "%%c"), + }; + let u = unit(sub(near, far))?; + match d.kind { + DimensionKind::Radius => { + // From the centre to the curve, or from the curve out to text + // placed outside it. + let outside = label_at.filter(|t| len(sub(*t, far)) > measured); + match outside { + Some(t) => g.lines.push([near, t]), + None => g.lines.push([far, near]), + } + g.arrows.push((near, far)); + } + _ => { + g.lines.push([far, near]); + g.arrows.push((near, far)); + g.arrows.push((far, near)); + } + } + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(u[1].atan2(u[0])) + }; + let mid = mul(add(far, near), 0.5); + // The prefix is part of the measurement, `<>` included. + let measured = format!( + "{prefix}{}", + number(measured * s.lfac, s.dec, s.zin, s.dsep) + ); + g.label = content(d, measured).map(|text| Label { + at: label_at.unwrap_or_else(|| add(mid, mul(perp(polar(rotation)), s.gap + s.txt / 2.0))), + text, + rotation, + }); + Some(g) +} + +/// Where two lines cross, each through two points. +fn crossing(a0: P, a1: P, b0: P, b1: P) -> Option { + let (r, q) = (sub(a1, a0), sub(b1, b0)); + let den = r[0] * q[1] - r[1] * q[0]; + if den.abs() < 1e-12 * len(r) * len(q) || !den.is_finite() { + return None; + } + let t = ((b0[0] - a0[0]) * q[1] - (b0[1] - a0[1]) * q[0]) / den; + Some(add(a0, mul(r, t))) +} + +fn angular(d: &Dimension, s: &Style) -> Option { + use std::f64::consts::{PI, TAU}; + // The two legs, as the rays out of the centre and the feature points + // along each. + let (centre, legs, on) = match d.kind { + DimensionKind::Angular3Point => { + let c = p(d.point15); + ( + c, + [[c, p(d.point13)], [c, p(d.point14)]], + p(d.definition_point), + ) + } + _ => { + let l1 = [p(d.point13), p(d.point14)]; + let l2 = [p(d.point15), p(d.definition_point)]; + ( + crossing(l1[0], l1[1], l2[0], l2[1])?, + [l1, l2], + p(d.point16), + ) + } + }; + let radius = len(sub(on, centre)); + if !(radius > 1e-12) { + return None; + } + let theta = (on[1] - centre[1]).atan2(on[0] - centre[0]); + let (start, sweep) = match d.kind { + DimensionKind::Angular3Point => { + let a1 = angle_of(sub(legs[0][1], centre))?; + let a2 = angle_of(sub(legs[1][1], centre))?; + let ccw = (a2 - a1).rem_euclid(TAU); + if (theta - a1).rem_euclid(TAU) <= ccw { + (a1, ccw) + } else { + (a2, TAU - ccw) + } + } + _ => { + // The sector of the four the two lines make that holds the arc's + // point. + let a1 = angle_of(sub(legs[0][1], legs[0][0]))?; + let a2 = angle_of(sub(legs[1][1], legs[1][0]))?; + let mut rays = [a1, a1 + PI, a2, a2 + PI].map(|a| a.rem_euclid(TAU)); + rays.sort_by(f64::total_cmp); + let t = theta.rem_euclid(TAU); + let i = rays.iter().rposition(|r| *r <= t).unwrap_or(3); + let (lo, hi) = (rays[i], if i == 3 { rays[0] + TAU } else { rays[i + 1] }); + (lo, hi - lo) + } + }; + let mut g = Graphics::default(); + g.arcs.push((centre, radius, start, sweep)); + let e1 = add(centre, mul(polar(start), radius)); + let e2 = add(centre, mul(polar(start + sweep), radius)); + // An extension line from the leg's nearer feature point out to the arc, + // when the arc lies beyond the leg. + for (end, ray) in [(e1, polar(start)), (e2, polar(start + sweep))] { + let leg = legs.iter().find(|l| { + let dir = sub(l[1], l[0]); + (dir[0] * ray[1] - dir[1] * ray[0]).abs() < 1e-9 * len(dir) + }); + if let Some(leg) = leg { + let ts: Vec<(f64, P)> = leg + .iter() + .map(|q| (dot(sub(*q, centre), ray), *q)) + .collect(); + let near = ts + .iter() + .min_by(|a, b| (a.0 - radius).abs().total_cmp(&(b.0 - radius).abs()))?; + let (lo, hi) = (ts[0].0.min(ts[1].0), ts[0].0.max(ts[1].0)); + if radius > hi + 1e-9 || radius < lo - 1e-9 { + extension(&mut g, near.1, end, s); + } + } + } + if s.tsz > 0.0 { + ends(&mut g, e1, e2, s); + } else { + let t1 = perp(polar(start)); + let t2 = perp(polar(start + sweep)); + g.arrows.push((e1, add(e1, t1))); + g.arrows.push((e2, sub(e2, t2))); + } + let mid = start + sweep / 2.0; + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(mid + PI / 2.0) + }; + let lift = if s.above { s.gap + s.txt / 2.0 } else { 0.0 }; + let degrees = number(sweep.to_degrees(), s.adec, 0, s.dsep) + "%%d"; + g.label = content(d, degrees).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| add(centre, mul(polar(mid), radius + lift))), + text, + rotation, + }); + Some(g) +} + +fn angle_of(v: P) -> Option { + unit(v).map(|u| u[1].atan2(u[0])) +} + +fn ordinate(d: &Dimension, s: &Style) -> Option { + use std::f64::consts::FRAC_PI_2; + let (origin, feature, end) = (p(d.definition_point), p(d.point13), p(d.point14)); + let x_type = d.flags & 64 != 0; + let mut g = Graphics::default(); + extension(&mut g, feature, end, s); + let measured = if x_type { + feature[0] - origin[0] + } else { + feature[1] - origin[1] + }; + let rotation = if x_type { FRAC_PI_2 } else { 0.0 }; + let dir = unit(sub(end, feature)).unwrap_or(polar(rotation)); + g.label = content(d, number(measured.abs() * s.lfac, s.dec, s.zin, s.dsep)).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| add(end, mul(dir, s.gap + s.txt))), + text, + rotation, + }); + Some(g) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_follow_the_style() { + assert_eq!(number(12.5, 4, 0, '.'), "12.5000"); + assert_eq!(number(12.5, 4, 8, '.'), "12.5"); + assert_eq!(number(12.0, 2, 8, ','), "12"); + assert_eq!(number(0.25, 2, 4, ','), ",25"); + assert_eq!(number(-0.0001, 2, 0, '.'), "0.00"); + assert_eq!(number(100.5, 0, 0, '.'), "101"); + assert_eq!(number(2.25, 1, 0, '.'), "2.3"); + } + + #[test] + fn readable_text_never_runs_upside_down() { + use std::f64::consts::PI; + assert!((readable(PI) - 0.0).abs() < 1e-12); + assert!((readable(-PI / 2.0) - PI / 2.0).abs() < 1e-12); + assert!((readable(PI / 2.0) - PI / 2.0).abs() < 1e-12); + assert!((readable(0.3) - 0.3).abs() < 1e-12); + } +} diff --git a/crates/exav-render/src/formats/dwg/font.rs b/crates/exav-render/src/formats/dwg/font.rs index 9b8a8a3..d2a85e2 100644 --- a/crates/exav-render/src/formats/dwg/font.rs +++ b/crates/exav-render/src/formats/dwg/font.rs @@ -409,6 +409,73 @@ fn table_for(f: TrueTypeFace) -> (&'static [u16], &'static [(u32, u16)], f32) { } } +/// A font whose letters stand for other characters. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Symbols { + /// Symbol's Greek and mathematical signs; the SHX Greek fonts' letters. + Symbol, + /// greeks.shx, greekc.shx: Greek letters only. + Greek, + /// gdt.shx: the geometric tolerancing signs on lowercase letters. + Gdt, +} + +/// The symbol set a font file draws, by name. +pub fn symbols_of(file: &str) -> Option { + let lower = file.trim().to_ascii_lowercase(); + let base = lower.rsplit(['/', '\\']).next().unwrap_or(&lower); + let stem = base.rsplit_once('.').map_or(base, |(s, _)| s); + match stem { + "symbol" => Some(Symbols::Symbol), + "greeks" | "greekc" => Some(Symbols::Greek), + "gdt" | "amgdt" => Some(Symbols::Gdt), + _ => None, + } +} + +/// Greek for the Latin letters, in the order Symbol puts them: A Alpha, C +/// Chi, F Phi, J theta symbol, Q Theta, V final sigma, W Omega... +const GREEK_UPPER: [char; 26] = [ + 'Α', 'Î’', 'Χ', 'Δ', 'Ε', 'Φ', 'Γ', 'Η', 'Ι', 'Ï‘', 'Κ', 'Λ', 'Μ', 'Î�', 'Ο', 'Î ', 'Θ', 'Ρ', 'Σ', + 'Τ', 'Î¥', 'Ï‚', 'Ω', 'Ξ', 'Ψ', 'Ζ', +]; +const GREEK_LOWER: [char; 26] = [ + 'α', 'β', 'χ', 'δ', 'ε', 'φ', 'γ', 'η', 'ι', 'Ï•', 'κ', 'λ', 'μ', 'ν', 'ο', 'Ï€', 'θ', 'Ï�', 'σ', + 'Ï„', 'Ï…', 'Ï–', 'ω', 'ξ', 'ψ', 'ζ', +]; +/// gdt.shx's lowercase letters: a angularity, b perpendicularity, c +/// flatness, d surface profile, e circularity, f parallelism, g +/// cylindricity, h circular runout, i symmetry, j position, k line profile, +/// l LMC, m MMC, n diameter, o square, p projected zone, q centre line, r +/// concentricity, s RFS, t total runout, u straightness, v counterbore, w +/// countersink, x depth, y conical taper, z slope. +const GDT: [char; 26] = [ + '∠', '⊥', 'â�¥', '⌓', 'â—‹', '∥', 'âŒ', '↗', '⌯', '⌖', '⌒', 'â“�', 'â“‚', '⌀', 'â–¡', 'â“…', 'â„„', 'â—Ž', 'Ⓢ', + '⌰', 'â�¤', '⌴', '⌵', '↧', '⌲', '⌳', +]; + +/// What a string in a symbol font shows. +pub fn map_symbols(s: &str, set: Symbols) -> String { + let letter = |c: char, table: &[char; 26]| table[(c as u8 - b'a') as usize]; + s.chars() + .map(|c| match (set, c) { + (Symbols::Symbol | Symbols::Greek, 'A'..='Z') => GREEK_UPPER[(c as u8 - b'A') as usize], + (Symbols::Symbol | Symbols::Greek, 'a'..='z') => letter(c, &GREEK_LOWER), + (Symbols::Gdt, 'a'..='z') => letter(c, &GDT), + (Symbols::Symbol, '"') => '∀', + (Symbols::Symbol, '$') => '∃', + (Symbols::Symbol, '\'') => '∋', + (Symbols::Symbol, '*') => '∗', + (Symbols::Symbol, '-') => '−', + (Symbols::Symbol, '@') => '≅', + (Symbols::Symbol, '\\') => '∴', + (Symbols::Symbol, '^') => '⊥', + (Symbols::Symbol, '~') => '∼', + _ => c, + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -420,6 +487,28 @@ mod tests { } } + /// Greek and Cyrillic have their own widths, not the fallback's. + #[test] + fn greek_and_cyrillic_are_measured() { + let m = Metrics::for_face(Face::default()); + assert!(m.advance('Ж') > m.advance('г') * 1.5); + assert!(m.advance('Ω') > m.advance('ι') * 1.5); + assert_eq!(m.advance('Ω'), m.advance('\u{03A9}')); + } + + #[test] + fn symbol_fonts_draw_what_their_letters_stand_for() { + assert_eq!(symbols_of("SYMBOL.TTF"), Some(Symbols::Symbol)); + assert_eq!(symbols_of("fonts\\greeks.shx"), Some(Symbols::Greek)); + assert_eq!(symbols_of("gdt"), Some(Symbols::Gdt)); + assert_eq!(symbols_of("arial.ttf"), None); + assert_eq!(map_symbols("abg D=@", Symbols::Symbol), "αβγ Δ=≅"); + // The SHX Greek fonts change letters only. + assert_eq!(map_symbols("abg D=@", Symbols::Greek), "αβγ Δ=@"); + // GD&T: diameter 0.05, position, MMC; capitals stay. + assert_eq!(map_symbols("n0.05 jm A", Symbols::Gdt), "⌀0.05 ⌖Ⓜ A"); + } + /// The case the corpus is full of: a stock stroke font, stored with no /// extension at all. Testing for ".shx" would miss every one of these. #[test] diff --git a/crates/exav-render/src/formats/dwg/linetype.rs b/crates/exav-render/src/formats/dwg/linetype.rs index 6e61370..14e720f 100644 --- a/crates/exav-render/src/formats/dwg/linetype.rs +++ b/crates/exav-render/src/formats/dwg/linetype.rs @@ -10,27 +10,74 @@ /// segments for something that reads as solid anyway. const MAX_DASHES: usize = 20_000; -/// Text carried by one element of a complex linetype. +/// Text or a shape carried by one element of a complex linetype. /// /// A linetype like GAZ draws its dashes and stamps "GAS" into the gaps at every /// repetition. Without this the line is drawn as plain dashes and the label /// that says what the pipe carries is missing. #[derive(Clone, Debug)] pub struct Label { - /// Index into `runs` of the element that carries the text. + /// Index into `runs` of the element that carries the label, which is + /// placed at that element's end. pub run: usize, - pub text: String, - /// Text height in drawing units. + pub mark: Mark, + /// Text height, or the shape's unit, in drawing units. pub height: f64, - /// Offset from the element's position: along the line, then across it. + /// Offset from the element's end: along the line, then across it. pub offset: [f64; 2], pub rotation: f64, /// Rotation is world-absolute rather than relative to the line. pub absolute_rotation: bool, - /// The face the label's text style resolves to. These are usually stroke - /// fonts, since a linetype that stamps a symbol along a line is drawn from - /// an SHX shape file. - pub face: super::font::Face, +} + +#[derive(Clone, Debug)] +pub enum Mark { + /// `face` is what the label's text style resolves to. + Text { + text: String, + face: super::font::Face, + }, + /// A shape of ltypeshp.shx, as `ltypeshp_shape` draws it. + Shape(&'static [ShapePart]), +} + +/// A piece of a shape, in shape units from its insertion point, the line +/// running along +x. +#[derive(Debug, PartialEq)] +pub enum ShapePart { + Line(&'static [[f64; 2]]), + /// Centre, radius, start angle and signed sweep (radians). + Arc([f64; 2], f64, f64, f64), +} + +/// The shapes of ltypeshp.shx, which AutoCAD's own complex linetypes +/// (TRACKS, ZIGZAG, FENCELINE1 and 2, BATTING) stamp, redrawn here: the file +/// itself cannot be shipped, any more than an SHX font. Others are left out. +pub fn ltypeshp_shape(number: i16) -> Option<&'static [ShapePart]> { + use std::f64::consts::PI; + const TRACK1: &[ShapePart] = &[ShapePart::Line(&[[0.0, 1.0], [0.0, -1.0]])]; + const ZIG: &[ShapePart] = &[ShapePart::Line(&[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]])]; + const BOX: &[ShapePart] = &[ShapePart::Line(&[ + [0.0, -1.0], + [2.0, -1.0], + [2.0, 1.0], + [0.0, 1.0], + [0.0, -1.0], + ])]; + const CIRC1: &[ShapePart] = &[ShapePart::Arc([1.0, 0.0], 1.0, PI, -2.0 * PI)]; + const BAT: &[ShapePart] = &[ + ShapePart::Line(&[[0.0, 0.0], [-1.0, 2.0]]), + ShapePart::Arc([1.0, 2.0], 2.0, PI, -PI), + ShapePart::Line(&[[3.0, 2.0], [2.0, 0.0]]), + ]; + match number { + 130 => Some(TRACK1), + 131 => Some(ZIG), + 132 => Some(BOX), + 133 => Some(CIRC1), + 134 => Some(BAT), + _ => None, + } } /// One placed copy of a label, in drawing coordinates. @@ -215,7 +262,8 @@ pub fn dash_polyline( Some(out) } -/// Where each copy of the pattern's embedded text lands along a polyline. +/// Where each copy of the pattern's embedded text and shapes lands along a +/// polyline: the end of the element carrying it, shifted by its offset. /// /// The phase is the same walk the dashes use, so a label sits in the gap its /// definition puts it in rather than drifting against the dashes. @@ -241,7 +289,7 @@ pub fn dash_labels(pts: &[[f64; 2]], closed: bool, pattern: &Pattern) -> Vec len { continue; } @@ -323,12 +371,14 @@ mod embedded_text { &[10.0, -5.0], &[Label { run: 1, - text: "GAS".to_string(), + mark: Mark::Text { + text: "GAS".to_string(), + face: super::super::font::Face::Stroke, + }, height: 2.54, offset: [-2.54, -1.27], rotation: 0.0, absolute_rotation: false, - face: super::super::font::Face::Stroke, }], scale, ) @@ -361,9 +411,9 @@ mod embedded_text { fn the_offsets_place_it_along_and_across_the_line() { let places = dash_labels(&[[0.0, 0.0], [100.0, 0.0]], false, &gaz(1.0)); let first = &places[0]; - // The dash runs 0..10, so the gap starts at 10 and the label sits - // 2.54 back along it and 1.27 below it. - assert!((first.x - (10.0 - 2.54)).abs() < 1e-6, "x {}", first.x); + // The gap carrying it runs 10..15, and the label sits 2.54 back from + // its end and 1.27 below the line. + assert!((first.x - (15.0 - 2.54)).abs() < 1e-6, "x {}", first.x); assert!((first.y - -1.27).abs() < 1e-6, "y {}", first.y); assert!(first.angle.abs() < 1e-9, "angle {}", first.angle); } @@ -374,7 +424,7 @@ mod embedded_text { assert!((p.labels[0].height - 5.08).abs() < 1e-9); let places = dash_labels(&[[0.0, 0.0], [200.0, 0.0]], false, &p); assert!( - (places[0].x - (20.0 - 5.08)).abs() < 1e-6, + (places[0].x - (30.0 - 5.08)).abs() < 1e-6, "x {}", places[0].x ); @@ -418,12 +468,14 @@ mod tests { assert!(dash_polyline(&pts, false, &p).is_none()); let label = Label { run: 1, - text: "X".to_string(), + mark: Mark::Text { + text: "X".to_string(), + face: super::super::font::Face::Stroke, + }, height: 1.0, offset: [0.0, 0.0], rotation: 0.0, absolute_rotation: false, - face: super::super::font::Face::Stroke, }; let labelled = Pattern::with_labels(&[1.0, -1.0], &[label], 1.0).unwrap(); assert!(dash_labels(&pts, false, &labelled).is_empty()); diff --git a/crates/exav-render/src/formats/dwg/metrics.rs b/crates/exav-render/src/formats/dwg/metrics.rs index 700e5c7..89ad8fc 100644 --- a/crates/exav-render/src/formats/dwg/metrics.rs +++ b/crates/exav-render/src/formats/dwg/metrics.rs @@ -49,13 +49,187 @@ pub static SANS: [u16; 560] = [ 3186, 2278, 2732, 2048, 1430, 2804, 1504, 910, 3632, 3632, 2732, 2958, 2048, 2278, 2502, 2048, 2048, 2378, 1856, 2732, 2958, 2736, 2732, 2278, 2048, 910, 3020, 2278, 2958, 1364, 2732, 2048, ]; -pub static SANS_SPARSE: [(u32, u16); 22] = [ +pub static SANS_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2734), + (0x0387, 1138), + (0x0388, 3212), + (0x0389, 3432), + (0x038a, 1572), + (0x038c, 3172), + (0x038e, 3504), + (0x038f, 3082), + (0x0390, 910), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2256), + (0x0394, 2736), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2732), + (0x039b, 2736), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2662), + (0x039f, 3186), + (0x03a0, 2958), + (0x03a1, 2732), + (0x03a3, 2532), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3268), + (0x03a7, 2732), + (0x03a8, 3422), (0x03a9, 3062), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2368), + (0x03ad, 1826), + (0x03ae, 2278), + (0x03af, 910), + (0x03b0, 2240), + (0x03b1, 2368), + (0x03b2, 2356), + (0x03b3, 2048), + (0x03b4, 2280), + (0x03b5, 1826), + (0x03b6, 1806), + (0x03b7, 2278), + (0x03b8, 2278), + (0x03b9, 910), + (0x03ba, 2048), + (0x03bb, 2048), (0x03bc, 2360), + (0x03bd, 2048), + (0x03be, 1834), + (0x03bf, 2278), + (0x03c0, 2826), + (0x03c1, 2330), + (0x03c2, 1974), + (0x03c3, 2528), + (0x03c4, 1618), + (0x03c5, 2240), + (0x03c6, 2656), + (0x03c7, 2150), + (0x03c8, 2920), + (0x03c9, 3198), + (0x03ca, 910), + (0x03cb, 2240), + (0x03cc, 2278), + (0x03cd, 2240), + (0x03ce, 3198), + (0x03cf, 2732), + (0x03d0, 2356), + (0x03d1, 2240), + (0x03d2, 3164), + (0x03d3, 3924), + (0x03d4, 3164), + (0x03d5, 2294), + (0x03d6, 3198), + (0x0400, 2732), + (0x0401, 2734), + (0x0402, 3542), + (0x0403, 2218), + (0x0404, 2944), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2048), + (0x0409, 4330), + (0x040a, 4138), + (0x040b, 3498), + (0x040c, 2386), + (0x040d, 2944), + (0x040e, 2602), + (0x040f, 2944), + (0x0410, 2732), + (0x0411, 2688), + (0x0412, 2732), + (0x0413, 2218), + (0x0414, 2774), + (0x0415, 2732), + (0x0416, 3782), + (0x0417, 2474), + (0x0418, 2944), + (0x0419, 2944), + (0x041a, 2386), + (0x041b, 2688), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2944), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2602), + (0x0424, 3114), + (0x0425, 2732), + (0x0426, 3030), + (0x0427, 2730), + (0x0428, 3754), + (0x0429, 3840), + (0x042a, 3242), + (0x042b, 3626), + (0x042c, 2688), + (0x042d, 2944), + (0x042e, 4138), + (0x042f, 2958), + (0x0430, 2278), + (0x0431, 2346), + (0x0432, 2176), + (0x0433, 1494), + (0x0434, 2390), + (0x0435, 2278), + (0x0436, 2740), + (0x0437, 1878), + (0x0438, 2288), + (0x0439, 2288), + (0x043a, 1792), + (0x043b, 2390), + (0x043c, 2816), + (0x043d, 2262), + (0x043e, 2278), + (0x043f, 2218), + (0x0440, 2278), + (0x0441, 2048), + (0x0442, 1876), + (0x0443, 2048), + (0x0444, 3370), + (0x0445, 2048), + (0x0446, 2346), + (0x0447, 2134), + (0x0448, 3286), + (0x0449, 3370), + (0x044a, 2560), + (0x044b, 2944), + (0x044c, 2134), + (0x044d, 2090), + (0x044e, 3072), + (0x044f, 2218), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2278), + (0x0453, 1494), + (0x0454, 2090), + (0x0455, 2048), + (0x0456, 910), + (0x0457, 1138), + (0x0458, 910), + (0x0459, 3712), + (0x045a, 3328), + (0x045b, 2278), + (0x045c, 1792), + (0x045d, 2288), + (0x045e, 2048), + (0x045f, 2260), (0x2013, 2278), (0x2014, 4096), (0x2018, 910), @@ -69,6 +243,7 @@ pub static SANS_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -110,13 +285,187 @@ pub static SANS_BOLD: [u16; 560] = [ 3186, 2502, 2732, 2278, 2044, 3414, 2072, 1138, 3906, 3906, 2958, 2958, 2278, 2502, 2502, 2278, 2048, 2642, 1982, 2958, 2958, 2732, 2732, 2278, 2278, 1138, 3174, 2502, 2958, 1594, 2732, 2278, ]; -pub static SANS_BOLD_SPARSE: [(u32, u16); 22] = [ +pub static SANS_BOLD_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1904), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3494), + (0x0389, 3710), + (0x038a, 1940), + (0x038c, 3378), + (0x038e, 3798), + (0x038f, 3432), + (0x0390, 1138), + (0x0391, 2958), + (0x0392, 2958), + (0x0393, 2462), + (0x0394, 2944), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2958), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2636), + (0x039f, 3186), + (0x03a0, 2958), + (0x03a1, 2732), + (0x03a3, 2458), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3362), + (0x03a7, 2732), + (0x03a8, 3314), (0x03a9, 3284), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2518), + (0x03ad, 1848), + (0x03ae, 2502), + (0x03af, 1138), + (0x03b0, 2384), + (0x03b1, 2518), + (0x03b2, 2500), + (0x03b3, 2278), + (0x03b4, 2484), + (0x03b5, 1944), + (0x03b6, 1886), + (0x03b7, 2502), + (0x03b8, 2214), + (0x03b9, 1138), + (0x03ba, 2284), + (0x03bb, 2278), (0x03bc, 2506), + (0x03bd, 2278), + (0x03be, 1824), + (0x03bf, 2502), + (0x03c0, 3136), + (0x03c1, 2534), + (0x03c2, 2130), + (0x03c3, 2802), + (0x03c4, 1828), + (0x03c5, 2384), + (0x03c6, 2930), + (0x03c7, 2358), + (0x03c8, 3086), + (0x03c9, 3460), + (0x03ca, 1138), + (0x03cb, 2384), + (0x03cc, 2502), + (0x03cd, 2384), + (0x03ce, 3460), + (0x03cf, 2958), + (0x03d0, 2500), + (0x03d1, 2384), + (0x03d2, 3154), + (0x03d3, 4100), + (0x03d4, 3154), + (0x03d5, 3070), + (0x03d6, 3460), + (0x0400, 2732), + (0x0401, 2740), + (0x0402, 3626), + (0x0403, 2322), + (0x0404, 2914), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1130), + (0x0408, 2278), + (0x0409, 4480), + (0x040a, 4352), + (0x040b, 3584), + (0x040c, 2500), + (0x040d, 2944), + (0x040e, 2548), + (0x040f, 2944), + (0x0410, 2958), + (0x0411, 2944), + (0x0412, 2958), + (0x0413, 2322), + (0x0414, 2918), + (0x0415, 2732), + (0x0416, 3702), + (0x0417, 2566), + (0x0418, 2944), + (0x0419, 2944), + (0x041a, 2500), + (0x041b, 2874), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2944), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2548), + (0x0424, 3496), + (0x0425, 2732), + (0x0426, 2992), + (0x0427, 2878), + (0x0428, 4116), + (0x0429, 4174), + (0x042a, 3562), + (0x042b, 4010), + (0x042c, 2944), + (0x042d, 2914), + (0x042e, 4224), + (0x042f, 2944), + (0x0430, 2278), + (0x0431, 2530), + (0x0432, 2518), + (0x0433, 1706), + (0x0434, 2600), + (0x0435, 2278), + (0x0436, 2904), + (0x0437, 2036), + (0x0438, 2518), + (0x0439, 2518), + (0x043a, 2050), + (0x043b, 2602), + (0x043c, 3030), + (0x043d, 2474), + (0x043e, 2502), + (0x043f, 2474), + (0x0440, 2502), + (0x0441, 2278), + (0x0442, 2006), + (0x0443, 2278), + (0x0444, 3584), + (0x0445, 2278), + (0x0446, 2518), + (0x0447, 2378), + (0x0448, 3414), + (0x0449, 3456), + (0x044a, 2986), + (0x044b, 3498), + (0x044c, 2518), + (0x044d, 2262), + (0x044e, 3498), + (0x044f, 2390), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2502), + (0x0453, 1706), + (0x0454, 2262), + (0x0455, 2278), + (0x0456, 1138), + (0x0457, 1152), + (0x0458, 1138), + (0x0459, 3968), + (0x045a, 3712), + (0x045b, 2502), + (0x045c, 2050), + (0x045d, 2518), + (0x045e, 2278), + (0x045f, 2474), (0x2013, 2278), (0x2014, 4096), (0x2018, 1138), @@ -130,6 +479,7 @@ pub static SANS_BOLD_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -171,13 +521,187 @@ pub static SANS_ITALIC: [u16; 560] = [ 3186, 2278, 2732, 2048, 1204, 2576, 1424, 910, 3618, 3608, 2732, 2958, 2048, 2278, 2502, 2048, 2048, 2378, 1856, 2732, 2958, 2732, 2732, 2278, 2048, 910, 3092, 2278, 2958, 1364, 2732, 2048, ]; -pub static SANS_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SANS_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2732), + (0x0387, 1138), + (0x0388, 3230), + (0x0389, 3466), + (0x038a, 1592), + (0x038c, 3252), + (0x038e, 3544), + (0x038f, 3174), + (0x0390, 910), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2336), + (0x0394, 2748), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2732), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2656), + (0x039f, 3186), + (0x03a0, 2968), + (0x03a1, 2732), + (0x03a3, 2456), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3428), + (0x03a7, 2732), + (0x03a8, 3402), (0x03a9, 3116), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2336), + (0x03ad, 1800), + (0x03ae, 2274), + (0x03af, 910), + (0x03b0, 2252), + (0x03b1, 2336), + (0x03b2, 2340), + (0x03b3, 2048), + (0x03b4, 2276), + (0x03b5, 1800), + (0x03b6, 1898), + (0x03b7, 2274), + (0x03b8, 2222), + (0x03b9, 910), + (0x03ba, 2048), + (0x03bb, 2016), (0x03bc, 2244), + (0x03bd, 2048), + (0x03be, 1830), + (0x03bf, 2278), + (0x03c0, 2744), + (0x03c1, 2346), + (0x03c2, 1992), + (0x03c3, 2470), + (0x03c4, 1532), + (0x03c5, 2252), + (0x03c6, 2672), + (0x03c7, 2236), + (0x03c8, 2980), + (0x03c9, 3190), + (0x03ca, 910), + (0x03cb, 2252), + (0x03cc, 2278), + (0x03cd, 2252), + (0x03ce, 3190), + (0x03cf, 2732), + (0x03d0, 2068), + (0x03d1, 2252), + (0x03d2, 2932), + (0x03d3, 3612), + (0x03d4, 2932), + (0x03d5, 2278), + (0x03d6, 3190), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3452), + (0x0403, 2228), + (0x0404, 2898), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2048), + (0x0409, 4366), + (0x040a, 4024), + (0x040b, 3458), + (0x040c, 2414), + (0x040d, 2930), + (0x040e, 2618), + (0x040f, 2964), + (0x0410, 2732), + (0x0411, 2666), + (0x0412, 2732), + (0x0413, 2228), + (0x0414, 2884), + (0x0415, 2732), + (0x0416, 3756), + (0x0417, 2514), + (0x0418, 2930), + (0x0419, 2930), + (0x041a, 2414), + (0x041b, 2808), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2968), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2618), + (0x0424, 3256), + (0x0425, 2732), + (0x0426, 2978), + (0x0427, 2758), + (0x0428, 3770), + (0x0429, 3780), + (0x042a, 3298), + (0x042b, 3630), + (0x042c, 2666), + (0x042d, 2842), + (0x042e, 4188), + (0x042f, 2792), + (0x0430, 2278), + (0x0431, 2304), + (0x0432, 2138), + (0x0433, 2020), + (0x0434, 2264), + (0x0435, 2278), + (0x0436, 2818), + (0x0437, 1904), + (0x0438, 2278), + (0x0439, 2278), + (0x043a, 1934), + (0x043b, 2310), + (0x043c, 2808), + (0x043d, 2254), + (0x043e, 2278), + (0x043f, 2254), + (0x0440, 2278), + (0x0441, 2048), + (0x0442, 3412), + (0x0443, 2048), + (0x0444, 3422), + (0x0445, 2048), + (0x0446, 2342), + (0x0447, 2120), + (0x0448, 3400), + (0x0449, 3486), + (0x044a, 2544), + (0x044b, 3014), + (0x044c, 2154), + (0x044d, 2014), + (0x044e, 3080), + (0x044f, 2188), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2278), + (0x0453, 1548), + (0x0454, 2030), + (0x0455, 2048), + (0x0456, 910), + (0x0457, 910), + (0x0458, 910), + (0x0459, 3728), + (0x045a, 3390), + (0x045b, 2278), + (0x045c, 1934), + (0x045d, 2278), + (0x045e, 2048), + (0x045f, 2278), (0x2013, 2278), (0x2014, 4096), (0x2018, 910), @@ -191,6 +715,7 @@ pub static SANS_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -232,13 +757,187 @@ pub static SANS_BOLD_ITALIC: [u16; 560] = [ 3186, 2502, 2732, 2278, 1852, 3216, 2086, 1138, 3906, 3898, 2958, 2958, 2278, 2502, 2502, 2278, 2048, 2642, 1982, 2958, 2958, 2732, 2732, 2278, 2278, 1138, 3198, 2502, 2958, 1594, 2732, 2278, ]; -pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3498), + (0x0389, 3710), + (0x038a, 1938), + (0x038c, 3458), + (0x038e, 3810), + (0x038f, 3470), + (0x0390, 1138), + (0x0391, 2958), + (0x0392, 2958), + (0x0393, 2498), + (0x0394, 2750), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2958), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2692), + (0x039f, 3186), + (0x03a0, 2940), + (0x03a1, 2732), + (0x03a3, 2418), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3368), + (0x03a7, 2732), + (0x03a8, 3394), (0x03a9, 3198), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2540), + (0x03ad, 1960), + (0x03ae, 2502), + (0x03af, 1138), + (0x03b0, 2422), + (0x03b1, 2540), + (0x03b2, 2542), + (0x03b3, 2278), + (0x03b4, 2500), + (0x03b5, 1960), + (0x03b6, 2014), + (0x03b7, 2502), + (0x03b8, 2284), + (0x03b9, 1138), + (0x03ba, 2318), + (0x03bb, 2278), (0x03bc, 2470), + (0x03bd, 2278), + (0x03be, 1842), + (0x03bf, 2502), + (0x03c0, 2916), + (0x03c1, 2480), + (0x03c2, 2180), + (0x03c3, 2718), + (0x03c4, 1674), + (0x03c5, 2422), + (0x03c6, 2882), + (0x03c7, 2366), + (0x03c8, 3166), + (0x03c9, 3418), + (0x03ca, 1138), + (0x03cb, 2422), + (0x03cc, 2502), + (0x03cd, 2422), + (0x03ce, 3418), + (0x03cf, 2958), + (0x03d0, 2310), + (0x03d1, 2422), + (0x03d2, 3066), + (0x03d3, 4066), + (0x03d4, 3066), + (0x03d5, 3102), + (0x03d6, 3418), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3628), + (0x0403, 2514), + (0x0404, 2944), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2278), + (0x0409, 4482), + (0x040a, 4268), + (0x040b, 3496), + (0x040c, 2518), + (0x040d, 2946), + (0x040e, 2774), + (0x040f, 2946), + (0x0410, 2958), + (0x0411, 2900), + (0x0412, 2958), + (0x0413, 2514), + (0x0414, 2958), + (0x0415, 2732), + (0x0416, 3798), + (0x0417, 2632), + (0x0418, 2946), + (0x0419, 2946), + (0x041a, 2518), + (0x041b, 2814), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2946), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2774), + (0x0424, 3198), + (0x0425, 2732), + (0x0426, 2986), + (0x0427, 2900), + (0x0428, 4010), + (0x0429, 4050), + (0x042a, 3498), + (0x042b, 4096), + (0x042c, 2900), + (0x042d, 2944), + (0x042e, 4268), + (0x042f, 2986), + (0x0430, 2278), + (0x0431, 2536), + (0x0432, 2474), + (0x0433, 2186), + (0x0434, 2532), + (0x0435, 2278), + (0x0436, 3016), + (0x0437, 2090), + (0x0438, 2502), + (0x0439, 2502), + (0x043a, 2076), + (0x043b, 2548), + (0x043c, 3030), + (0x043d, 2474), + (0x043e, 2502), + (0x043f, 2502), + (0x0440, 2502), + (0x0441, 2278), + (0x0442, 3642), + (0x0443, 2278), + (0x0444, 3624), + (0x0445, 2278), + (0x0446, 2646), + (0x0447, 2388), + (0x0448, 3642), + (0x0449, 3830), + (0x044a, 2896), + (0x044b, 3498), + (0x044c, 2432), + (0x044d, 2260), + (0x044e, 3542), + (0x044f, 2412), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2502), + (0x0453, 1922), + (0x0454, 2306), + (0x0455, 2278), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 3970), + (0x045a, 3710), + (0x045b, 2502), + (0x045c, 2076), + (0x045d, 2502), + (0x045e, 2278), + (0x045f, 2502), (0x2013, 2278), (0x2014, 4096), (0x2018, 1138), @@ -252,6 +951,7 @@ pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -293,13 +993,187 @@ pub static SERIF: [u16; 560] = [ 2958, 2048, 2958, 2048, 1138, 2048, 1298, 1138, 3170, 3164, 2958, 2732, 2048, 2502, 2502, 1594, 1818, 2186, 1628, 2732, 2958, 2970, 2502, 1818, 1594, 1138, 2876, 2048, 2732, 1364, 2958, 2048, ]; -pub static SERIF_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1138), + (0x0388, 2842), + (0x0389, 3310), + (0x038a, 1684), + (0x038c, 2958), + (0x038e, 3342), + (0x038f, 3044), + (0x0390, 1102), + (0x0391, 2958), + (0x0392, 2732), + (0x0393, 2368), + (0x0394, 2634), + (0x0395, 2502), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 2958), + (0x0399, 1364), + (0x039a, 2958), + (0x039b, 2970), + (0x039c, 3642), + (0x039d, 2958), + (0x039e, 2634), + (0x039f, 2958), + (0x03a0, 2958), + (0x03a1, 2278), + (0x03a3, 2384), + (0x03a4, 2502), + (0x03a5, 2958), + (0x03a6, 2994), + (0x03a7, 2958), + (0x03a8, 3022), (0x03a9, 3044), + (0x03aa, 1364), + (0x03ab, 2958), + (0x03ac, 2146), + (0x03ad, 1720), + (0x03ae, 2142), + (0x03af, 1102), + (0x03b0, 2028), + (0x03b1, 2146), + (0x03b2, 2084), + (0x03b3, 1810), + (0x03b4, 1930), + (0x03b5, 1720), + (0x03b6, 1696), + (0x03b7, 2142), + (0x03b8, 1962), + (0x03b9, 1102), + (0x03ba, 2064), + (0x03bb, 1986), (0x03bc, 2196), + (0x03bd, 1852), + (0x03be, 1826), + (0x03bf, 2048), + (0x03c0, 2068), + (0x03c1, 2044), + (0x03c2, 1622), + (0x03c3, 2208), + (0x03c4, 1646), + (0x03c5, 2028), + (0x03c6, 2364), + (0x03c7, 1818), + (0x03c8, 2564), + (0x03c9, 2696), + (0x03ca, 1102), + (0x03cb, 2028), + (0x03cc, 2048), + (0x03cd, 2028), + (0x03ce, 2696), + (0x03cf, 2958), + (0x03d0, 2084), + (0x03d1, 2028), + (0x03d2, 2958), + (0x03d3, 3646), + (0x03d4, 2958), + (0x03d5, 2156), + (0x03d6, 2696), + (0x0400, 2502), + (0x0401, 2502), + (0x0402, 3080), + (0x0403, 2368), + (0x0404, 2704), + (0x0405, 2278), + (0x0406, 1364), + (0x0407, 1364), + (0x0408, 1594), + (0x0409, 3572), + (0x040a, 3572), + (0x040b, 3036), + (0x040c, 2732), + (0x040d, 2958), + (0x040e, 2900), + (0x040f, 2958), + (0x0410, 2958), + (0x0411, 2352), + (0x0412, 2732), + (0x0413, 2368), + (0x0414, 2794), + (0x0415, 2502), + (0x0416, 3670), + (0x0417, 2052), + (0x0418, 2958), + (0x0419, 2958), + (0x041a, 2732), + (0x041b, 2778), + (0x041c, 3642), + (0x041d, 2958), + (0x041e, 2958), + (0x041f, 2958), + (0x0420, 2278), + (0x0421, 2732), + (0x0422, 2502), + (0x0423, 2900), + (0x0424, 3236), + (0x0425, 2958), + (0x0426, 2958), + (0x0427, 2662), + (0x0428, 4132), + (0x0429, 4132), + (0x042a, 2892), + (0x042b, 3572), + (0x042c, 2352), + (0x042d, 2704), + (0x042e, 4210), + (0x042f, 2732), + (0x0430, 1818), + (0x0431, 2084), + (0x0432, 1934), + (0x0433, 1680), + (0x0434, 2084), + (0x0435, 1818), + (0x0436, 2830), + (0x0437, 1618), + (0x0438, 2192), + (0x0439, 2192), + (0x043a, 1990), + (0x043b, 2044), + (0x043c, 2592), + (0x043d, 2192), + (0x043e, 2048), + (0x043f, 2192), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 1790), + (0x0443, 2048), + (0x0444, 2654), + (0x0445, 2048), + (0x0446, 2192), + (0x0447, 2060), + (0x0448, 3154), + (0x0449, 3154), + (0x044a, 2118), + (0x044b, 2752), + (0x044c, 1868), + (0x044d, 1758), + (0x044e, 3060), + (0x044f, 1884), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 1978), + (0x0453, 1680), + (0x0454, 1758), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2978), + (0x045a, 2962), + (0x045b, 2048), + (0x045c, 1990), + (0x045d, 2192), + (0x045e, 2048), + (0x045f, 2192), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -313,6 +1187,7 @@ pub static SERIF_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2310), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -354,13 +1229,187 @@ pub static SERIF_BOLD: [u16; 560] = [ 3186, 2048, 2958, 2048, 1138, 2278, 1478, 1364, 3370, 3370, 2958, 2958, 2048, 2732, 2732, 1594, 1818, 2180, 1908, 2732, 2958, 2936, 2732, 1818, 2048, 1364, 3378, 2278, 2958, 1818, 2958, 2048, ]; -pub static SERIF_BOLD_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_BOLD_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3272), + (0x0389, 3724), + (0x038a, 2138), + (0x038c, 3186), + (0x038e, 3542), + (0x038f, 3280), + (0x0390, 1270), + (0x0391, 2958), + (0x0392, 2732), + (0x0393, 2606), + (0x0394, 2568), + (0x0395, 2732), + (0x0396, 2732), + (0x0397, 3186), + (0x0398, 3186), + (0x0399, 1594), + (0x039a, 3186), + (0x039b, 2936), + (0x039c, 3866), + (0x039d, 2958), + (0x039e, 2774), + (0x039f, 3186), + (0x03a0, 3186), + (0x03a1, 2502), + (0x03a3, 2678), + (0x03a4, 2732), + (0x03a5, 2958), + (0x03a6, 3396), + (0x03a7, 2958), + (0x03a8, 3194), (0x03a9, 3280), + (0x03aa, 1594), + (0x03ab, 2958), + (0x03ac, 2286), + (0x03ad, 1750), + (0x03ae, 2326), + (0x03af, 1270), + (0x03b0, 2126), + (0x03b1, 2286), + (0x03b2, 2158), + (0x03b3, 1916), + (0x03b4, 2126), + (0x03b5, 1750), + (0x03b6, 1692), + (0x03b7, 2326), + (0x03b8, 2138), + (0x03b9, 1270), + (0x03ba, 2274), + (0x03bb, 2012), (0x03bc, 2322), + (0x03bd, 1830), + (0x03be, 1826), + (0x03bf, 2048), + (0x03c0, 2244), + (0x03c1, 2192), + (0x03c2, 1728), + (0x03c3, 2228), + (0x03c4, 1888), + (0x03c5, 2126), + (0x03c6, 2556), + (0x03c7, 1892), + (0x03c8, 2818), + (0x03c9, 2994), + (0x03ca, 1270), + (0x03cb, 2126), + (0x03cc, 2048), + (0x03cd, 2126), + (0x03ce, 2994), + (0x03cf, 3186), + (0x03d0, 2158), + (0x03d1, 2454), + (0x03d2, 2958), + (0x03d3, 3782), + (0x03d4, 2958), + (0x03d5, 2556), + (0x03d6, 2994), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3276), + (0x0403, 2606), + (0x0404, 2778), + (0x0405, 2278), + (0x0406, 1594), + (0x0407, 1594), + (0x0408, 2048), + (0x0409, 4108), + (0x040a, 4104), + (0x040b, 3276), + (0x040c, 2970), + (0x040d, 3186), + (0x040e, 3006), + (0x040f, 3186), + (0x0410, 2958), + (0x0411, 2708), + (0x0412, 2732), + (0x0413, 2606), + (0x0414, 2818), + (0x0415, 2732), + (0x0416, 4050), + (0x0417, 2162), + (0x0418, 3186), + (0x0419, 3186), + (0x041a, 2970), + (0x041b, 3052), + (0x041c, 3866), + (0x041d, 3186), + (0x041e, 3186), + (0x041f, 3186), + (0x0420, 2502), + (0x0421, 2958), + (0x0422, 2732), + (0x0423, 3006), + (0x0424, 3518), + (0x0425, 2958), + (0x0426, 3186), + (0x0427, 3006), + (0x0428, 4498), + (0x0429, 4498), + (0x042a, 3134), + (0x042b, 4022), + (0x042c, 2708), + (0x042d, 2778), + (0x042e, 4612), + (0x042f, 2958), + (0x0430, 2048), + (0x0431, 2048), + (0x0432, 2212), + (0x0433, 1860), + (0x0434, 2072), + (0x0435, 1818), + (0x0436, 2970), + (0x0437, 1646), + (0x0438, 2360), + (0x0439, 2360), + (0x043a, 2360), + (0x043b, 2298), + (0x043c, 2790), + (0x043d, 2360), + (0x043e, 2048), + (0x043f, 2360), + (0x0440, 2278), + (0x0441, 1818), + (0x0442, 2012), + (0x0443, 2048), + (0x0444, 2834), + (0x0445, 2048), + (0x0446, 2360), + (0x0447, 2310), + (0x0448, 3458), + (0x0449, 3458), + (0x044a, 2388), + (0x044b, 3198), + (0x044c, 2166), + (0x044d, 1770), + (0x044e, 3130), + (0x044f, 2216), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 2200), + (0x0453, 1860), + (0x0454, 1770), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1364), + (0x0459, 3194), + (0x045a, 3264), + (0x045b, 2278), + (0x045c, 2360), + (0x045d, 2360), + (0x045e, 2048), + (0x045f, 2360), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -374,6 +1423,7 @@ pub static SERIF_BOLD_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2334), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -415,13 +1465,187 @@ pub static SERIF_ITALIC: [u16; 560] = [ 2958, 2048, 2278, 1818, 1138, 2048, 1138, 1138, 3082, 3088, 2502, 2732, 2048, 2278, 2278, 1594, 1594, 1994, 1786, 2502, 2958, 2474, 2502, 1818, 1818, 1138, 2900, 2048, 2502, 1594, 2278, 1818, ]; -pub static SERIF_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2502), + (0x0387, 1364), + (0x0388, 2782), + (0x0389, 3244), + (0x038a, 1646), + (0x038c, 2958), + (0x038e, 2704), + (0x038f, 2974), + (0x0390, 1138), + (0x0391, 2502), + (0x0392, 2502), + (0x0393, 2330), + (0x0394, 2404), + (0x0395, 2502), + (0x0396, 2278), + (0x0397, 2958), + (0x0398, 2958), + (0x0399, 1364), + (0x039a, 2732), + (0x039b, 2474), + (0x039c, 3412), + (0x039d, 2732), + (0x039e, 2618), + (0x039f, 2958), + (0x03a0, 2958), + (0x03a1, 2502), + (0x03a3, 2434), + (0x03a4, 2278), + (0x03a5, 2278), + (0x03a6, 3118), + (0x03a7, 2502), + (0x03a8, 2880), (0x03a9, 2962), + (0x03aa, 1364), + (0x03ab, 2278), + (0x03ac, 2150), + (0x03ad, 1614), + (0x03ae, 2032), + (0x03af, 1138), + (0x03b0, 1880), + (0x03b1, 2150), + (0x03b2, 2040), + (0x03b3, 1614), + (0x03b4, 1904), + (0x03b5, 1614), + (0x03b6, 1658), + (0x03b7, 2032), + (0x03b8, 2012), + (0x03b9, 1138), + (0x03ba, 1966), + (0x03bb, 1774), (0x03bc, 2056), + (0x03bd, 1818), + (0x03be, 1754), + (0x03bf, 2048), + (0x03c0, 2052), + (0x03c1, 1966), + (0x03c2, 1662), + (0x03c3, 2020), + (0x03c4, 1466), + (0x03c5, 1880), + (0x03c6, 2266), + (0x03c7, 1802), + (0x03c8, 2544), + (0x03c9, 2892), + (0x03ca, 1138), + (0x03cb, 1880), + (0x03cc, 2048), + (0x03cd, 1880), + (0x03ce, 2858), + (0x03cf, 2732), + (0x03d0, 2040), + (0x03d1, 2010), + (0x03d2, 2606), + (0x03d3, 3108), + (0x03d4, 2606), + (0x03d5, 2120), + (0x03d6, 2892), + (0x0400, 2502), + (0x0401, 2502), + (0x0402, 3264), + (0x0403, 2330), + (0x0404, 2740), + (0x0405, 2048), + (0x0406, 1364), + (0x0407, 1364), + (0x0408, 1818), + (0x0409, 3798), + (0x040a, 3772), + (0x040b, 3272), + (0x040c, 2720), + (0x040d, 2958), + (0x040e, 2756), + (0x040f, 2958), + (0x0410, 2502), + (0x0411, 2416), + (0x0412, 2502), + (0x0413, 2330), + (0x0414, 2606), + (0x0415, 2502), + (0x0416, 3752), + (0x0417, 2028), + (0x0418, 2958), + (0x0419, 2958), + (0x041a, 2720), + (0x041b, 2774), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 2958), + (0x041f, 2958), + (0x0420, 2502), + (0x0421, 2732), + (0x0422, 2278), + (0x0423, 2756), + (0x0424, 3294), + (0x0425, 2502), + (0x0426, 2958), + (0x0427, 2822), + (0x0428, 4272), + (0x0429, 4272), + (0x042a, 2768), + (0x042b, 3572), + (0x042c, 2416), + (0x042d, 2682), + (0x042e, 4228), + (0x042f, 2692), + (0x0430, 2048), + (0x0431, 2060), + (0x0432, 1778), + (0x0433, 1598), + (0x0434, 2072), + (0x0435, 1818), + (0x0436, 3810), + (0x0437, 1598), + (0x0438, 2048), + (0x0439, 2048), + (0x043a, 1916), + (0x043b, 1786), + (0x043c, 2614), + (0x043d, 2032), + (0x043e, 2048), + (0x043f, 2048), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 2958), + (0x0443, 1818), + (0x0444, 2908), + (0x0445, 1818), + (0x0446, 2048), + (0x0447, 1954), + (0x0448, 3092), + (0x0449, 3092), + (0x044a, 2126), + (0x044b, 2774), + (0x044c, 1864), + (0x044d, 1806), + (0x044e, 2888), + (0x044f, 1904), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 1962), + (0x0453, 1544), + (0x0454, 1786), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2782), + (0x045a, 2916), + (0x045b, 2048), + (0x045c, 1916), + (0x045d, 2048), + (0x045e, 1818), + (0x045f, 2048), (0x2013, 2048), (0x2014, 3642), (0x2018, 1364), @@ -435,6 +1659,7 @@ pub static SERIF_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2764), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -476,13 +1701,187 @@ pub static SERIF_BOLD_ITALIC: [u16; 560] = [ 2958, 2048, 2502, 1818, 1138, 2278, 1138, 1138, 2998, 3010, 2732, 2732, 2048, 2502, 2502, 1594, 1594, 2084, 1908, 2732, 2958, 2732, 2732, 1818, 2048, 1138, 3214, 2048, 2732, 1594, 2502, 1818, ]; -pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2732), + (0x0387, 1364), + (0x0388, 3182), + (0x0389, 3666), + (0x038a, 2072), + (0x038c, 2994), + (0x038e, 3072), + (0x038f, 3216), + (0x0390, 1138), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2474), + (0x0394, 2568), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 3186), + (0x0398, 3126), + (0x0399, 1594), + (0x039a, 2732), + (0x039b, 2732), + (0x039c, 3642), + (0x039d, 2958), + (0x039e, 2700), + (0x039f, 2958), + (0x03a0, 3186), + (0x03a1, 2502), + (0x03a3, 2536), + (0x03a4, 2502), + (0x03a5, 2502), + (0x03a6, 3232), + (0x03a7, 2732), + (0x03a8, 3276), (0x03a9, 3056), + (0x03aa, 1594), + (0x03ab, 2502), + (0x03ac, 2266), + (0x03ad, 1680), + (0x03ae, 2248), + (0x03af, 1138), + (0x03b0, 2006), + (0x03b1, 2266), + (0x03b2, 2098), + (0x03b3, 1704), + (0x03b4, 2098), + (0x03b5, 1680), + (0x03b6, 1806), + (0x03b7, 2248), + (0x03b8, 2126), + (0x03b9, 1138), + (0x03ba, 2170), + (0x03bb, 1818), (0x03bc, 2278), + (0x03bd, 1818), + (0x03be, 1840), + (0x03bf, 2048), + (0x03c0, 2236), + (0x03c1, 2110), + (0x03c2, 1826), + (0x03c3, 2208), + (0x03c4, 1818), + (0x03c5, 2006), + (0x03c6, 2396), + (0x03c7, 1904), + (0x03c8, 2736), + (0x03c9, 2962), + (0x03ca, 1138), + (0x03cb, 2006), + (0x03cc, 2048), + (0x03cd, 2006), + (0x03ce, 2962), + (0x03cf, 2732), + (0x03d0, 2098), + (0x03d1, 2160), + (0x03d2, 2968), + (0x03d3, 3444), + (0x03d4, 2968), + (0x03d5, 2370), + (0x03d6, 3098), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3350), + (0x0403, 2474), + (0x0404, 2692), + (0x0405, 2278), + (0x0406, 1594), + (0x0407, 1594), + (0x0408, 2048), + (0x0409, 3720), + (0x040a, 3998), + (0x040b, 3322), + (0x040c, 2778), + (0x040d, 3186), + (0x040e, 2850), + (0x040f, 3186), + (0x0410, 2732), + (0x0411, 2602), + (0x0412, 2732), + (0x0413, 2474), + (0x0414, 2798), + (0x0415, 2732), + (0x0416, 3760), + (0x0417, 2084), + (0x0418, 3186), + (0x0419, 3186), + (0x041a, 2778), + (0x041b, 2810), + (0x041c, 3642), + (0x041d, 3186), + (0x041e, 2958), + (0x041f, 3186), + (0x0420, 2502), + (0x0421, 2732), + (0x0422, 2502), + (0x0423, 2850), + (0x0424, 3482), + (0x0425, 2732), + (0x0426, 3186), + (0x0427, 3064), + (0x0428, 4612), + (0x0429, 4612), + (0x042a, 3114), + (0x042b, 3970), + (0x042c, 2596), + (0x042d, 2666), + (0x042e, 4326), + (0x042f, 2822), + (0x0430, 2048), + (0x0431, 2044), + (0x0432, 1830), + (0x0433, 1512), + (0x0434, 2032), + (0x0435, 1818), + (0x0436, 3900), + (0x0437, 1598), + (0x0438, 2278), + (0x0439, 2278), + (0x043a, 2076), + (0x043b, 2102), + (0x043c, 2806), + (0x043d, 2274), + (0x043e, 2048), + (0x043f, 2278), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 3186), + (0x0443, 1818), + (0x0444, 2932), + (0x0445, 2048), + (0x0446, 2278), + (0x0447, 2150), + (0x0448, 3412), + (0x0449, 3412), + (0x044a, 2158), + (0x044b, 2842), + (0x044c, 1872), + (0x044d, 1700), + (0x044e, 2990), + (0x044f, 2110), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 2142), + (0x0453, 1610), + (0x0454, 1700), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2700), + (0x045a, 2920), + (0x045b, 2278), + (0x045c, 2076), + (0x045d, 2278), + (0x045e, 1818), + (0x045f, 2278), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -496,6 +1895,7 @@ pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2482), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -537,13 +1937,186 @@ pub static MONO: [u16; 560] = [ 2458, 2458, 2458, 2458, 2458, 2458, 2458, 0, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, ]; -pub static MONO_SPARSE: [(u32, u16); 22] = [ +pub static MONO_SPARSE: [(u32, u16); 196] = [ (0x00b0, 2458), (0x00b1, 2458), (0x00b2, 2458), (0x00b3, 2458), + (0x0384, 2458), + (0x0385, 2458), + (0x0386, 2458), + (0x0387, 2458), + (0x0388, 2458), + (0x0389, 2458), + (0x038a, 2458), + (0x038c, 2458), + (0x038e, 2458), + (0x038f, 2458), + (0x0390, 2458), + (0x0391, 2458), + (0x0392, 2458), + (0x0393, 2458), + (0x0394, 2458), + (0x0395, 2458), + (0x0396, 2458), + (0x0397, 2458), + (0x0398, 2458), + (0x0399, 2458), + (0x039a, 2458), + (0x039b, 2458), + (0x039c, 2458), + (0x039d, 2458), + (0x039e, 2458), + (0x039f, 2458), + (0x03a0, 2458), + (0x03a1, 2458), + (0x03a3, 2458), + (0x03a4, 2458), + (0x03a5, 2458), + (0x03a6, 2458), + (0x03a7, 2458), + (0x03a8, 2458), (0x03a9, 2458), + (0x03aa, 2458), + (0x03ab, 2458), + (0x03ac, 2458), + (0x03ad, 2458), + (0x03ae, 2458), + (0x03af, 2458), + (0x03b0, 2458), + (0x03b1, 2458), + (0x03b2, 2458), + (0x03b3, 2458), + (0x03b4, 2458), + (0x03b5, 2458), + (0x03b6, 2458), + (0x03b7, 2458), + (0x03b8, 2458), + (0x03b9, 2458), + (0x03ba, 2458), + (0x03bb, 2458), (0x03bc, 2458), + (0x03bd, 2458), + (0x03be, 2458), + (0x03bf, 2458), + (0x03c0, 2458), + (0x03c1, 2458), + (0x03c2, 2458), + (0x03c3, 2458), + (0x03c4, 2458), + (0x03c5, 2458), + (0x03c6, 2458), + (0x03c7, 2458), + (0x03c8, 2458), + (0x03c9, 2458), + (0x03ca, 2458), + (0x03cb, 2458), + (0x03cc, 2458), + (0x03cd, 2458), + (0x03ce, 2458), + (0x03d0, 2458), + (0x03d1, 2458), + (0x03d2, 2458), + (0x03d3, 2458), + (0x03d4, 2458), + (0x03d5, 2458), + (0x03d6, 2458), + (0x0400, 2458), + (0x0401, 2458), + (0x0402, 2458), + (0x0403, 2458), + (0x0404, 2458), + (0x0405, 2458), + (0x0406, 2458), + (0x0407, 2458), + (0x0408, 2458), + (0x0409, 2458), + (0x040a, 2458), + (0x040b, 2458), + (0x040c, 2458), + (0x040d, 2458), + (0x040e, 2458), + (0x040f, 2458), + (0x0410, 2458), + (0x0411, 2458), + (0x0412, 2458), + (0x0413, 2458), + (0x0414, 2458), + (0x0415, 2458), + (0x0416, 2458), + (0x0417, 2458), + (0x0418, 2458), + (0x0419, 2458), + (0x041a, 2458), + (0x041b, 2458), + (0x041c, 2458), + (0x041d, 2458), + (0x041e, 2458), + (0x041f, 2458), + (0x0420, 2458), + (0x0421, 2458), + (0x0422, 2458), + (0x0423, 2458), + (0x0424, 2458), + (0x0425, 2458), + (0x0426, 2458), + (0x0427, 2458), + (0x0428, 2458), + (0x0429, 2458), + (0x042a, 2458), + (0x042b, 2458), + (0x042c, 2458), + (0x042d, 2458), + (0x042e, 2458), + (0x042f, 2458), + (0x0430, 2458), + (0x0431, 2458), + (0x0432, 2458), + (0x0433, 2458), + (0x0434, 2458), + (0x0435, 2458), + (0x0436, 2458), + (0x0437, 2458), + (0x0438, 2458), + (0x0439, 2458), + (0x043a, 2458), + (0x043b, 2458), + (0x043c, 2458), + (0x043d, 2458), + (0x043e, 2458), + (0x043f, 2458), + (0x0440, 2458), + (0x0441, 2458), + (0x0442, 2458), + (0x0443, 2458), + (0x0444, 2458), + (0x0445, 2458), + (0x0446, 2458), + (0x0447, 2458), + (0x0448, 2458), + (0x0449, 2458), + (0x044a, 2458), + (0x044b, 2458), + (0x044c, 2458), + (0x044d, 2458), + (0x044e, 2458), + (0x044f, 2458), + (0x0450, 2458), + (0x0451, 2458), + (0x0452, 2458), + (0x0453, 2458), + (0x0454, 2458), + (0x0455, 2458), + (0x0456, 2458), + (0x0457, 2458), + (0x0458, 2458), + (0x0459, 2458), + (0x045a, 2458), + (0x045b, 2458), + (0x045c, 2458), + (0x045d, 2458), + (0x045e, 2458), + (0x045f, 2458), (0x2013, 2458), (0x2014, 2458), (0x2018, 2458), @@ -557,6 +2130,7 @@ pub static MONO_SPARSE: [(u32, u16); 22] = [ (0x2192, 2458), (0x2193, 2458), (0x2206, 2458), + (0x2212, 2458), (0x2260, 2458), (0x2264, 2458), (0x2265, 2458), diff --git a/crates/exav-render/src/formats/dwg/mod.rs b/crates/exav-render/src/formats/dwg/mod.rs index 5916d10..1a76103 100644 --- a/crates/exav-render/src/formats/dwg/mod.rs +++ b/crates/exav-render/src/formats/dwg/mod.rs @@ -18,6 +18,7 @@ mod clip; mod curves; +mod dimension; mod fill; mod font; mod gradient; @@ -31,6 +32,7 @@ pub mod stroke_font; mod tessellate; mod text; mod tiles; +mod wide; pub use curves::{flatten_arc, flatten_circle, flatten_ellipse}; pub use fill::fill_even_odd; @@ -39,8 +41,10 @@ pub use font::{ }; pub use hatch::nesting_depths; pub use palette::Palette; -pub use scene::{Affine, Scene, FILL_BYTES, STROKE_BYTES}; -pub use tessellate::{expand_bulges, LayerInfo, LayoutInfo, Tessellator, Warnings, SCENE_BUDGET}; +pub use scene::{Affine, Scene, FILL_BYTES, FILL_EDGE, STROKE_BYTES}; +pub use tessellate::{ + expand_bulges, LayerInfo, LayoutInfo, Sheet, Tessellator, Warnings, SCENE_BUDGET, +}; pub use text::{mtext_lines, HAlign, TextRun, VAlign, TEXT_RECORD_BYTES}; /// Why a file did not parse. @@ -198,24 +202,31 @@ impl Document { String::new() } }; + // Everything read from the scene before `buffers`, which empties it. let (texts, text_strings) = t.scene.text_buffers(); + let (origin, extents) = (t.scene.origin(), t.scene.extents_local()); + let clips = t.scene.clip_buffer(); + let b = t.scene.buffers(); Drawing { layout: drawn, - strokes: t.scene.stroke_buffer(), - fills: t.scene.fill_buffer(), + strokes: b.strokes, + fills: b.fills, texts, text_strings, - origin: t.scene.origin(), - extents: t.scene.extents_local(), + origin, + extents, max_order: t.scene.max_order(), - opaque_strokes: t.scene.opaque_strokes(), - opaque_fills: t.scene.opaque_fills(), - stroke_tiles: t.scene.stroke_tiles(), - fill_tiles: t.scene.fill_tiles(), - opaque_stroke_tiles: t.scene.opaque_stroke_tiles(), - opaque_fill_tiles: t.scene.opaque_fill_tiles(), + opaque_strokes: b.opaque_strokes, + opaque_fills: b.opaque_fills, + clips, + stroke_tiles: b.stroke_tiles, + fill_tiles: b.fill_tiles, + opaque_stroke_tiles: b.opaque_stroke_tiles, + opaque_fill_tiles: b.opaque_fill_tiles, + translucent_runs: b.translucent_runs, layers: t.layers, warnings: t.warnings, + sheet: t.sheet, } } } @@ -236,8 +247,9 @@ pub struct Drawing { /// for does not exist. pub layout: String, /// Stroke instances, [`STROKE_BYTES`] each: `f32 x0, y0, x1, y1 | u32 rgba - /// | u32 attr | u32 order`, little-endian. `attr` packs the layer id, the - /// lineweight and the flags (see `scene.rs`). + /// | u32 attr | u32 order | f32 bulge`, little-endian. `attr` packs the + /// layer id, the lineweight and the flags (see `scene.rs`); `bulge` is 0 + /// for a line, else the stroke is a circular arc (`Stroke::bulge`). pub strokes: Vec, /// Fill vertices, [`FILL_BYTES`] each: `f32 x, y | u32 rgba | u32 attr | /// u32 order`. @@ -245,12 +257,16 @@ pub struct Drawing { /// Text placement records, [`TEXT_RECORD_BYTES`] each, indexing into /// `text_strings`: `f32 x, y, height, rotation, widthFactor, oblique | u32 /// rgba, attr, strOffset | u16 strLen | u8 hAlign, vAlign | u32 order | u8 - /// face | 3 bytes padding`. Only runs in an outline face appear here; a + /// face | u8 padding | u16 clip`. Only runs in an outline face appear here; a /// stroke-font run is drawn as strokes, so that the drawing's lineweight /// decides how thick its letters are. pub texts: Vec, /// UTF-8, every run's characters back to back. pub text_strings: Vec, + /// The regions text runs are clipped to (a viewport's window, an XCLIP + /// boundary), which a run's `clip` numbers from 1: for each, `u32` + /// triangle count, then `f32 x, y` per corner relative to `origin`. + pub clips: Vec, /// World coordinates the buffers are relative to. pub origin: [f64; 2], /// `[min_x, min_y, max_x, max_y]`, relative to `origin`. @@ -258,22 +274,29 @@ pub struct Drawing { /// Highest draw-order index, for normalising depth. pub max_order: u32, /// Leading strokes and fill vertices that are opaque; the rest are - /// translucent and are drawn after, without writing depth. + /// translucent, in draw order, and are drawn after. pub opaque_strokes: usize, pub opaque_fills: usize, - /// Spatial buckets: `u32 start, count | f32 min_x, min_y, max_x, max_y`, - /// 24 bytes each, `start` and `count` in strokes (in vertices for fills). - /// The box is the true extent of the contents, which can reach outside - /// the bucket's grid square. + /// Tiles: `u32 start, count | f32 min_x, min_y, max_x, max_y`, 24 bytes + /// each, `start` and `count` in strokes (in vertices for fills). The + /// opaque half's are spatial buckets, the translucent half's runs of it + /// in draw order. The box is the true extent of the contents, which can + /// reach outside the bucket's grid square. pub stroke_tiles: Vec, pub fill_tiles: Vec, /// Leading entries of each table covering the opaque half. pub opaque_stroke_tiles: usize, pub opaque_fill_tiles: usize, + /// The order of the translucent pass: for each run, `u32` 1 for fill + /// tiles or 0 for stroke tiles, then the first tile and the one past the + /// last, indices into that kind's table. + pub translucent_runs: Vec, /// The layer table; a vertex's layer id indexes it. pub layers: Vec, /// What could not be drawn. pub warnings: Warnings, + /// What the tab is drawn on. + pub sheet: Sheet, } impl Drawing { @@ -310,11 +333,38 @@ impl Drawing { format!("[{}]", items.join(",")) } + /// [`Sheet`] as JSON: `{"mmPerUnit", "lineweights"}`. + pub fn sheet_json(&self) -> String { + let s = &self.sheet; + // The paper relative to `origin`, as the buffers are. + let paper = match s.paper { + Some(p) => format!( + "[{},{},{},{}]", + json_number(p[0] - self.origin[0]), + json_number(p[1] - self.origin[1]), + json_number(p[2] - self.origin[0]), + json_number(p[3] - self.origin[1]) + ), + None => "null".to_string(), + }; + format!( + r#"{{"mmPerUnit":{},"lineweights":{},"paper":{paper}}}"#, + json_number(s.mm_per_unit), + s.lineweights + ) + } + /// [`Warnings`] as JSON. All zeroes means everything was drawn. pub fn warnings_json(&self) -> String { let w = &self.warnings; + let not_drawn: Vec = w + .not_drawn + .iter() + .map(|(k, n)| format!("{}:{n}", json_string(k))) + .collect(); + let files: Vec = w.external_files.iter().map(|f| json_string(f)).collect(); format!( - r#"{{"unknownEntities":{},"missingBlocks":{},"textRuns":{},"strokeGlyphs":{},"hatchPatternsMissing":{},"hatchPatternsTruncated":{},"externalReferences":{},"depthExceeded":{},"sceneTruncated":{},"proxyWithoutGraphics":{}}}"#, + r#"{{"unknownEntities":{},"missingBlocks":{},"textRuns":{},"strokeGlyphs":{},"hatchPatternsMissing":{},"hatchPatternsTruncated":{},"externalReferences":{},"depthExceeded":{},"sceneTruncated":{},"proxyWithoutGraphics":{},"notDrawn":{{{}}},"externalFiles":[{}]}}"#, w.unknown_entities, w.missing_blocks, w.text_runs, @@ -324,11 +374,22 @@ impl Drawing { w.external_references, w.depth_exceeded, w.scene_truncated, - w.proxy_without_graphics + w.proxy_without_graphics, + not_drawn.join(","), + files.join(",") ) } } +/// A number JSON can hold: a non-finite one is 0. +fn json_number(v: f64) -> String { + if v.is_finite() { + format!("{v}") + } else { + "0".to_string() + } +} + /// JSON string escaping. Layer and layout names are file data and routinely /// hold quotes, backslashes and control characters. fn json_string(s: &str) -> String { diff --git a/crates/exav-render/src/formats/dwg/scene.rs b/crates/exav-render/src/formats/dwg/scene.rs index ce74c9d..13a41f9 100644 --- a/crates/exav-render/src/formats/dwg/scene.rs +++ b/crates/exav-render/src/formats/dwg/scene.rs @@ -4,6 +4,8 @@ //! precision, then re-emitted as f32 relative to a per-drawing origin. See //! DESIGN.md section 4.4. +use super::tiles::Tile; + /// 2D affine transform, applied as the block/insert stack is walked. /// /// x' = a*x + c*y + e @@ -127,7 +129,8 @@ impl Affine { } } -/// One line segment, pre-transform-applied, still in drawing coordinates. +/// One line segment, or circular arc, pre-transform-applied, still in drawing +/// coordinates. #[derive(Clone, Copy)] pub struct Stroke { pub x0: f64, @@ -139,6 +142,32 @@ pub struct Stroke { pub attr: u32, /// Position in the drawing's draw order. See `Scene::order`. pub order: u32, + /// 0 for a line; for an arc from the first point to the second, the tan + /// of a quarter of its sweep, positive counter-clockwise (DXF's bulge), + /// at most [`MAX_ARC_BULGE`]: the renderer draws it exactly at any zoom. + pub bulge: f32, +} + +/// The largest bulge a stroke carries, a quarter turn's: an arc stroke then +/// lies within the box of its ends widened by a fifth of its chord, and +/// within its sector the shader's test is two cross products. +pub const MAX_ARC_BULGE: f64 = 0.414_213_562_373_095_1; + +impl Stroke { + /// How far an arc stroke's curve stands off its chord, at most. + pub fn sagitta(&self) -> f64 { + (self.bulge.abs() as f64) * (self.x1 - self.x0).hypot(self.y1 - self.y0) / 2.0 + } + + /// The stroke as straight segments: itself, or its arc in `n` pieces. + pub fn flatten(&self, n: usize) -> Vec<[[f64; 2]; 2]> { + let (a, b) = ([self.x0, self.y0], [self.x1, self.y1]); + if self.bulge == 0.0 { + return vec![[a, b]]; + } + let pts = super::curves::bulge_points(a, b, self.bulge as f64, n); + pts.windows(2).map(|w| [w[0], w[1]]).collect() + } } /// One triangle corner. @@ -151,7 +180,7 @@ pub struct FillVertex { pub order: u32, } -pub const STROKE_BYTES: usize = 28; +pub const STROKE_BYTES: usize = 32; pub const FILL_BYTES: usize = 20; /// Fill flag: paint the viewer's background colour instead of the vertex's. @@ -160,6 +189,10 @@ pub const FILL_BYTES: usize = 20; /// background, which the tessellator does not know. The renderer substitutes it. pub const FILL_FLAG_BACKGROUND: u8 = 1; +/// The lineweight code of a fill's edge, a stroke of no width whose fringe is +/// the fill's anti-aliasing (`shaders.ts`). Lineweights stop at 211. +pub const FILL_EDGE: u8 = 255; + /// Flag: this colour is AutoCAD's colour 7, so it flips with the background. /// /// Colour 7 means "white or black, whichever contrasts". A true colour that @@ -178,15 +211,22 @@ pub const FLAG_CONTRAST: u8 = 0x80; /// should average out to. 0 means "do not fade". #[inline] pub fn encode_fade_spacing(spacing: f64) -> u8 { - if !spacing.is_finite() || spacing <= 1.0 { + if !spacing.is_finite() || spacing <= 0.0 { return 0; } - // Logarithmic, eight steps to the octave, which covers millimetres through - // to tens of metres in the seven bits left beside the contrast flag. - let code = (spacing.log2() * 8.0).round(); + // Logarithmic, five steps to the octave from 2^-9: in the seven bits left + // beside the contrast flag, 0.002 to 86,000 units, a pattern a few + // millimetres apart in a drawing in metres to one metres apart in a + // drawing in millimetres. `spacingFade` in shaders.ts decodes it. + let code = ((spacing.log2() + FADE_OCTAVES_BELOW_ONE) * FADE_STEPS_PER_OCTAVE).round(); code.clamp(1.0, 127.0) as u8 } +/// Octaves below a unit `encode_fade_spacing` reaches, and its steps per +/// octave. +const FADE_OCTAVES_BELOW_ONE: f64 = 9.0; +const FADE_STEPS_PER_OCTAVE: f64 = 5.0; + #[inline] pub fn pack_attr(layer: u16, lineweight: u8, flags: u8) -> u32 { (layer as u32) | ((lineweight as u32) << 16) | ((flags as u32) << 24) @@ -217,11 +257,15 @@ pub struct Scene { pub strokes: Vec, pub fills: Vec, pub texts: Vec, + /// Clip regions as triangles, a text run's `clip` indexing them from 1. + pub clips: Vec>, /// Draw order counter. AutoCAD paints entities in this sequence, and later /// entities cover earlier ones: a small solid fill placed over a hatch is /// how a roof vent masks the tiles beneath it. Without it a viewer that /// batches all fills before all strokes draws the hatch back on top. pub order: u32, + /// Fill vertices `edge_fills` has outlined. + edged: usize, min_x: f64, min_y: f64, max_x: f64, @@ -234,7 +278,9 @@ impl Scene { strokes: Vec::new(), fills: Vec::new(), texts: Vec::new(), + clips: Vec::new(), order: 0, + edged: 0, min_x: f64::INFINITY, min_y: f64::INFINITY, max_x: f64::NEG_INFINITY, @@ -245,7 +291,10 @@ impl Scene { /// Record a text run. Only the anchor grows the extents: the rendered width /// depends on font metrics the renderer has and this side does not. pub fn push_text(&mut self, run: super::text::TextRun) { - if !(run.x.is_finite() && run.y.is_finite() && run.height.is_finite()) { + if ![run.x, run.y, run.u[0], run.u[1], run.v[0], run.v[1]] + .iter() + .all(|c| c.is_finite()) + { return; } if run.text.is_empty() { @@ -286,9 +335,65 @@ impl Scene { rgba, attr, order, + bulge: 0.0, }); } + /// Push a circular arc as arc strokes of at most a quarter turn each: + /// centre, radius, start angle and signed sweep (positive + /// counter-clockwise), in drawing coordinates. + #[allow(clippy::too_many_arguments)] + pub fn push_arc( + &mut self, + cx: f64, + cy: f64, + r: f64, + start: f64, + sweep: f64, + rgba: u32, + attr: u32, + ) { + if ![cx, cy, r, start, sweep].iter().all(|v| v.is_finite()) || r <= 0.0 || sweep == 0.0 { + return; + } + let n = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize; + let step = sweep / n as f64; + let bulge = (step / 4.0).tan() as f32; + debug_assert!(f64::from(bulge.abs()) <= MAX_ARC_BULGE + 1e-6); + let at = |a: f64| [cx + r * a.cos(), cy + r * a.sin()]; + // The extents: the arc's ends and the quadrant points it passes. + let (lo, hi) = if sweep > 0.0 { + (start, start + sweep) + } else { + (start + sweep, start) + }; + let first = (lo / std::f64::consts::FRAC_PI_2).ceil() as i64; + let last = (hi / std::f64::consts::FRAC_PI_2).floor() as i64; + for k in first..=last.min(first + 4) { + let p = at(k as f64 * std::f64::consts::FRAC_PI_2); + self.grow(p[0], p[1]); + } + let order = self.order; + for i in 0..n { + let (a, b) = ( + at(start + step * i as f64), + at(start + step * (i + 1) as f64), + ); + self.grow(a[0], a[1]); + self.grow(b[0], b[1]); + self.strokes.push(Stroke { + x0: a[0], + y0: a[1], + x1: b[0], + y1: b[1], + rgba, + attr, + order, + bulge, + }); + } + } + /// Push a flattened polyline as a run of segments. pub fn push_polyline(&mut self, pts: &[[f64; 2]], closed: bool, rgba: u32, attr: u32) { if pts.len() < 2 { @@ -355,6 +460,60 @@ impl Scene { } /// Where each buffer currently ends, for a later `clip_since`. + /// Outline the opaque fills of one colour pushed since the last call with + /// `FILL_EDGE` strokes, whose fringe anti-aliases the fill's edge: drawn + /// at the fill's depth, each is hidden where the fill is and shows only + /// outside it. An edge two triangles share is left out; a translucent, + /// shaded or background fill is not outlined. + pub fn edge_fills(&mut self) { + let from = self.edged.min(self.fills.len()); + self.edged = self.fills.len(); + let tris = self.fills[from..].as_chunks::<3>().0; + if tris.is_empty() { + return; + } + let key = |a: &FillVertex, b: &FillVertex| { + let (pa, pb) = ( + [a.x.to_bits(), a.y.to_bits()], + [b.x.to_bits(), b.y.to_bits()], + ); + let (lo, hi) = if pa <= pb { (pa, pb) } else { (pb, pa) }; + (a.rgba, a.attr, a.order, lo, hi) + }; + let mut edges = Vec::new(); + let mut uses: std::collections::HashMap<_, u32> = std::collections::HashMap::new(); + for t in tris { + let one_colour = t[1].rgba == t[0].rgba && t[2].rgba == t[0].rgba; + let opaque = t[0].rgba >> 24 == 255; + let background = (t[0].attr >> 24) as u8 & FILL_FLAG_BACKGROUND != 0; + if !one_colour || !opaque || background { + continue; + } + for (a, b) in [(&t[0], &t[1]), (&t[1], &t[2]), (&t[2], &t[0])] { + *uses.entry(key(a, b)).or_insert(0) += 1; + edges.push((*a, *b)); + } + } + for (a, b) in edges { + if uses[&key(&a, &b)] != 1 { + continue; + } + let attr = (a.attr & 0xFFFF) + | u32::from(FILL_EDGE) << 16 + | (a.attr & (u32::from(FLAG_CONTRAST) << 24)); + self.strokes.push(Stroke { + x0: a.x, + y0: a.y, + x1: b.x, + y1: b.y, + rgba: a.rgba, + attr, + order: a.order, + bulge: 0.0, + }); + } + } + pub fn mark(&self) -> Mark { Mark { strokes: self.strokes.len(), @@ -381,15 +540,30 @@ impl Scene { } continue; } - shape.clip_segment(a, b, &mut spans); - for (t0, t1) in spans.iter().copied() { - kept.push(Stroke { - x0: a[0] + (b[0] - a[0]) * t0, - y0: a[1] + (b[1] - a[1]) * t0, - x1: a[0] + (b[0] - a[0]) * t1, - y1: a[1] + (b[1] - a[1]) * t1, - ..s - }); + // An arc wholly inside stays one; one crossing the edge is cut as + // the straight pieces of its curve. + if s.bulge != 0.0 { + let [x0, y0, x1, y1] = stroke_box(&s); + if [[x0, y0], [x1, y0], [x1, y1], [x0, y1]] + .iter() + .all(|&c| shape.contains(c)) + { + kept.push(s); + continue; + } + } + for [a, b] in s.flatten(16) { + shape.clip_segment(a, b, &mut spans); + for (t0, t1) in spans.iter().copied() { + kept.push(Stroke { + x0: a[0] + (b[0] - a[0]) * t0, + y0: a[1] + (b[1] - a[1]) * t0, + x1: a[0] + (b[0] - a[0]) * t1, + y1: a[1] + (b[1] - a[1]) * t1, + bulge: 0.0, + ..s + }); + } } } self.strokes.extend(kept); @@ -411,12 +585,87 @@ impl Scene { } } self.fills.extend(kept_fills); - - // A run is kept or dropped whole: its rendered width lives on the other - // side of the worker boundary, so there is no box here to cut against. + // Outlined before they were cut, and the outlines cut with the + // strokes. + self.edged = self.fills.len(); + + // A run's glyphs are laid out on the other side of the worker + // boundary, so it cannot be cut here: one that may cross the shape's + // edge is drawn through a clip region, which the renderer masks it + // with. One wholly outside is dropped, one wholly inside is left be. let tail: Vec = self.texts.drain(mark.texts..).collect(); - self.texts - .extend(tail.into_iter().filter(|r| shape.contains([r.x, r.y]))); + let (min, max) = shape.bounds(); + let mut regions: std::collections::HashMap> = Default::default(); + for mut r in tail { + let reach = text_reach(&r); + if r.x + reach < min[0] + || r.x - reach > max[0] + || r.y + reach < min[1] + || r.y - reach > max[1] + { + continue; + } + let corners = [ + [r.x - reach, r.y - reach], + [r.x + reach, r.y - reach], + [r.x + reach, r.y + reach], + [r.x - reach, r.y + reach], + ]; + if !corners.iter().all(|c| shape.contains(*c)) { + let region = *regions + .entry(r.clip) + .or_insert_with(|| self.clip_region(r.clip, shape)); + match region { + Some(id) => r.clip = id, + // Nothing of the earlier region is inside this one. + None => continue, + } + } + self.texts.push(r); + } + } + + /// A new clip region: the shape, or the part of region `within` it, as + /// its id; None when that is empty, or when ids have run out (the run is + /// then kept or dropped by its anchor, as before regions). + fn clip_region(&mut self, within: u16, shape: &super::clip::ClipShape) -> Option { + let triangles = if within == 0 { + shape.triangles() + } else { + let mut out = Vec::new(); + let mut piece = Vec::new(); + for t in self + .clips + .get(within as usize - 1) + .cloned() + .unwrap_or_default() + { + shape.clip_triangle(t, &mut piece); + out.extend_from_slice(&piece); + } + out + }; + if triangles.is_empty() || self.clips.len() >= u16::MAX as usize { + return None; + } + self.clips.push(triangles); + Some(self.clips.len() as u16) + } + + /// The clip regions as the renderer takes them: for each, a `u32` + /// triangle count, then `f32 x, y` for each corner, relative to + /// `origin()`. + pub fn clip_buffer(&self) -> Vec { + let o = self.origin(); + let mut out = Vec::new(); + for region in &self.clips { + out.extend_from_slice(&(region.len() as u32).to_le_bytes()); + for p in region.iter().flatten() { + out.extend_from_slice(&((p[0] - o[0]) as f32).to_le_bytes()); + out.extend_from_slice(&((p[1] - o[1]) as f32).to_le_bytes()); + } + } + out } /// Declare the extents instead of deriving them from the geometry. @@ -452,6 +701,48 @@ impl Scene { } } + /// Narrow the extents to where the drawing is when a few strays lie far + /// off it (a point left at the origin of a drawing in national grid + /// coordinates): the f32 origin, the fitted view and the culling grid + /// then serve the drawing, not the empty space between it and them, + /// which cost it centimetres of precision and showed it as a speck. + /// The core is what lies between the 0.5th and 99.5th percentiles on + /// each axis, with a margin; the strays are still drawn. + pub fn focus_on_core(&mut self) { + let points = self.strokes.len() * 2 + self.fills.len() + self.texts.len(); + if points < 200 || !self.min_x.is_finite() { + return; + } + let stride = (points / 50_000).max(1); + let all = self + .strokes + .iter() + .flat_map(|s| [[s.x0, s.y0], [s.x1, s.y1]]) + .chain(self.fills.iter().map(|v| [v.x, v.y])) + .chain(self.texts.iter().map(|r| [r.x, r.y])); + let (mut xs, mut ys): (Vec, Vec) = + all.step_by(stride).map(|p| (p[0], p[1])).unzip(); + let span = |v: &mut Vec| -> (f64, f64) { + let n = v.len(); + let (lo, hi) = (n / 200, (n - 1) - n / 200); + let a = *v.select_nth_unstable_by(lo, f64::total_cmp).1; + let b = *v.select_nth_unstable_by(hi, f64::total_cmp).1; + (a, b) + }; + let ((x0, x1), (y0, y1)) = (span(&mut xs), span(&mut ys)); + let margin = (x1 - x0).max(y1 - y0) * 0.25; + let core = [x0 - margin, y0 - margin, x1 + margin, y1 + margin]; + let (full_w, full_h) = (self.max_x - self.min_x, self.max_y - self.min_y); + let (core_w, core_h) = (core[2] - core[0], core[3] - core[1]); + if !(core_w > 0.0 && core_h > 0.0) || (full_w <= 4.0 * core_w && full_h <= 4.0 * core_h) { + return; + } + self.min_x = self.min_x.max(core[0]); + self.min_y = self.min_y.max(core[1]); + self.max_x = self.max_x.min(core[2]); + self.max_y = self.max_y.min(core[3]); + } + /// Highest draw-order value used, for normalising depth. pub fn max_order(&self) -> u32 { self.order @@ -492,173 +783,285 @@ impl Scene { [self.min_x, self.min_y, self.max_x, self.max_y] } - /// Interleaved stroke instance buffer, `STROKE_BYTES` per segment. + /// The geometry as the renderer takes it (see [`Buffers`]). /// - /// Opaque segments come first, then translucent ones, and each half is - /// sorted into spatial buckets so the renderer can skip what a view does - /// not touch. The halves stay whole: the opaque pass writes depth and the - /// translucent pass only tests it, so interleaving them by bucket would let - /// one bucket's opaque geometry paint over another's translucent. - pub fn stroke_buffer(&self) -> Vec { + /// Opaque geometry comes first, sorted into spatial buckets so the + /// renderer can skip what a view does not touch, then translucent + /// geometry in draw order: blending is drawn in order, and the opaque + /// pass must be done before any of it, so the halves stay whole. + /// + /// It takes the strokes and fills out of the scene as it encodes them, + /// so each list is freed once its bytes are made: read the origin, the + /// extents, the text and the clip regions first. + pub fn buffers(&mut self) -> Buffers { let o = self.origin(); - let mut out = Vec::with_capacity(self.strokes.len() * STROKE_BYTES); - for half in self.stroke_halves() { - let (order, _) = self.bucket_strokes(&half); - for i in order { - let s = half[i as usize]; - out.extend_from_slice(&((s.x0 - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.y0 - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.x1 - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.y1 - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&s.rgba.to_le_bytes()); - out.extend_from_slice(&s.attr.to_le_bytes()); - out.extend_from_slice(&s.order.to_le_bytes()); + // Indices rather than copies: a stroke is 48 bytes, its index 4. + let [opaque_strokes, strokes] = self.stroke_halves(); + let [opaque_fills, fills] = self.fill_halves(); + let (stroke_order, mut stroke_tiles) = self.bucket_strokes(&opaque_strokes); + let (fill_order, mut fill_tiles) = self.bucket_fills(&opaque_fills); + let translucent = self.translucent(&strokes, &fills); + + let mut stroke_bytes = Vec::with_capacity(self.strokes.len() * STROKE_BYTES); + let ordered = stroke_order + .iter() + .map(|&i| opaque_strokes[i as usize]) + .chain(strokes.iter().copied()); + for s in ordered.map(|i| &self.strokes[i as usize]) { + stroke_bytes.extend_from_slice(&((s.x0 - o[0]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.y0 - o[1]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.x1 - o[0]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.y1 - o[1]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&s.rgba.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.attr.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.order.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.bulge.to_le_bytes()); + } + drop((stroke_order, strokes)); + self.strokes = Vec::new(); + let mut fill_bytes = Vec::with_capacity(self.fills.len() * FILL_BYTES); + let ordered = fill_order + .iter() + .map(|&i| opaque_fills[i as usize]) + .chain(fills.iter().copied()); + for t in ordered { + for v in &self.fills[t as usize * 3..t as usize * 3 + 3] { + fill_bytes.extend_from_slice(&((v.x - o[0]) as f32).to_le_bytes()); + fill_bytes.extend_from_slice(&((v.y - o[1]) as f32).to_le_bytes()); + fill_bytes.extend_from_slice(&v.rgba.to_le_bytes()); + fill_bytes.extend_from_slice(&v.attr.to_le_bytes()); + fill_bytes.extend_from_slice(&v.order.to_le_bytes()); } } - out - } + drop((fill_order, fills)); + self.fills = Vec::new(); + + // Tiles count strokes, and fill vertices (for drawArrays); the + // translucent ones start past the opaque half. + for tile in &mut fill_tiles { + tile.start *= 3; + tile.count *= 3; + } + let (opaque_stroke_tiles, opaque_fill_tiles) = (stroke_tiles.len(), fill_tiles.len()); + for mut tile in translucent.strokes { + tile.start += opaque_strokes.len() as u32; + stroke_tiles.push(tile); + } + for mut tile in translucent.fills { + tile.start = (opaque_fills.len() as u32 + tile.start) * 3; + tile.count *= 3; + fill_tiles.push(tile); + } + let mut runs = Vec::with_capacity(translucent.runs.len() * 12); + for (fill, from, to) in translucent.runs { + let base = if fill { + opaque_fill_tiles + } else { + opaque_stroke_tiles + } as u32; + runs.extend_from_slice(&u32::from(fill).to_le_bytes()); + runs.extend_from_slice(&(base + from).to_le_bytes()); + runs.extend_from_slice(&(base + to).to_le_bytes()); + } - /// Interleaved fill vertex buffer, `FILL_BYTES` per vertex. - pub fn fill_buffer(&self) -> Vec { - let o = self.origin(); - let mut out = Vec::with_capacity(self.fills.len() * FILL_BYTES); - for half in self.fill_halves() { - let (order, _) = self.bucket_fills(&half); - for i in order { - for v in half[i as usize] { - out.extend_from_slice(&((v.x - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((v.y - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&v.rgba.to_le_bytes()); - out.extend_from_slice(&v.attr.to_le_bytes()); - out.extend_from_slice(&v.order.to_le_bytes()); - } - } + Buffers { + strokes: stroke_bytes, + fills: fill_bytes, + opaque_strokes: opaque_strokes.len(), + opaque_fills: opaque_fills.len() * 3, + stroke_tiles: super::tiles::encode(&stroke_tiles, o), + fill_tiles: super::tiles::encode(&fill_tiles, o), + opaque_stroke_tiles, + opaque_fill_tiles, + translucent_runs: runs, } - out } - /// Strokes split into the opaque half and the translucent half, in that - /// order. Both buffer emission and the tile table walk this, so they cannot - /// disagree about what went where. - fn stroke_halves(&self) -> [Vec; 2] { - let (a, b): (Vec, Vec) = - self.strokes.iter().partition(|s| is_opaque(s.rgba)); + /// The strokes of the opaque half and of the translucent half, by + /// index, the translucent one in draw order. Both buffer emission and + /// the tile table walk this, so they cannot disagree about what went + /// where. + fn stroke_halves(&self) -> [Vec; 2] { + let all = 0..self.strokes.len() as u32; + let (a, mut b): (Vec, Vec) = + all.partition(|&i| is_opaque(self.strokes[i as usize].rgba)); + b.sort_by_key(|&i| self.strokes[i as usize].order); [a, b] } - /// The same for fills, kept as whole triangles. A gradient gives its - /// corners different colours but the same alpha, so one corner decides. - fn fill_halves(&self) -> [Vec<[FillVertex; 3]>; 2] { - let tris: Vec<[FillVertex; 3]> = self.fills.as_chunks::<3>().0.to_vec(); - let (a, b): (Vec<_>, Vec<_>) = tris.into_iter().partition(|t| is_opaque(t[0].rgba)); + /// The same for fills, by triangle. A gradient gives its corners + /// different colours but the same alpha, so one corner decides. + fn fill_halves(&self) -> [Vec; 2] { + let all = 0..(self.fills.len() / 3) as u32; + let (a, mut b): (Vec, Vec) = + all.partition(|&t| is_opaque(self.fills[t as usize * 3].rgba)); + b.sort_by_key(|&t| self.fills[t as usize * 3].order); [a, b] } - fn bucket_strokes(&self, half: &[Stroke]) -> (Vec, Vec) { + fn triangle(&self, t: u32) -> &[FillVertex; 3] { + let at = t as usize * 3; + self.fills[at..at + 3].try_into().expect("three vertices") + } + + /// The translucent halves' tiles, and the order to draw them in. A tile + /// is a run of the half in draw order, so it is no spatial bucket, but + /// neighbours in draw order are often neighbours on the sheet too. Fills + /// and strokes interleave by draw order, a run of tiles of one kind at a + /// time; past [`MAX_TRANSLUCENT_RUNS`] runs, the fills then the strokes. + fn translucent(&self, strokes: &[u32], fills: &[u32]) -> Translucent { + let stroke = |i: u32| &self.strokes[i as usize]; + let mut t = Translucent::default(); + let (mut i, mut j) = (0, 0); + while i < strokes.len() || j < fills.len() { + let fill = match (strokes.get(i), fills.get(j)) { + (Some(&s), Some(&f)) => self.triangle(f)[0].order <= stroke(s).order, + (None, _) => true, + (Some(_), None) => false, + }; + if fill { + t.push(true, j, fill_box(self.triangle(fills[j]))); + j += 1; + } else { + t.push(false, i, stroke_box(stroke(strokes[i]))); + i += 1; + } + } + if t.runs.len() > MAX_TRANSLUCENT_RUNS { + t = Translucent::default(); + for (j, &f) in fills.iter().enumerate() { + t.push(true, j, fill_box(self.triangle(f))); + } + for (i, &s) in strokes.iter().enumerate() { + t.push(false, i, stroke_box(stroke(s))); + } + } + t + } + + fn bucket_strokes(&self, half: &[u32]) -> (Vec, Vec) { + let stroke = |i: &u32| &self.strokes[*i as usize]; super::tiles::bucket( half, self.world_extent(), - |s| [(s.x0 + s.x1) / 2.0, (s.y0 + s.y1) / 2.0], - |s| { - [ - s.x0.min(s.x1), - s.y0.min(s.y1), - s.x0.max(s.x1), - s.y0.max(s.y1), - ] + |i| { + let s = stroke(i); + [(s.x0 + s.x1) / 2.0, (s.y0 + s.y1) / 2.0] }, + |i| stroke_box(stroke(i)), ) } - fn bucket_fills(&self, half: &[[FillVertex; 3]]) -> (Vec, Vec) { + fn bucket_fills(&self, half: &[u32]) -> (Vec, Vec) { super::tiles::bucket( half, self.world_extent(), - |t| { + |&i| { + let t = self.triangle(i); [ (t[0].x + t[1].x + t[2].x) / 3.0, (t[0].y + t[1].y + t[2].y) / 3.0, ] }, - |t| { - [ - t[0].x.min(t[1].x).min(t[2].x), - t[0].y.min(t[1].y).min(t[2].y), - t[0].x.max(t[1].x).max(t[2].x), - t[0].y.max(t[1].y).max(t[2].y), - ] - }, + |&i| fill_box(self.triangle(i)), ) } +} - /// Tile table for `stroke_buffer`, counted in strokes. The translucent - /// half's tiles are offset past the opaque half, so a start index addresses - /// the whole buffer. - pub fn stroke_tiles(&self) -> Vec { - let halves = self.stroke_halves(); - let mut tiles = Vec::new(); - let mut base = 0u32; - for half in &halves { - let (_, mut t) = self.bucket_strokes(half); - for tile in &mut t { - tile.start += base; - } - base += half.len() as u32; - tiles.extend(t); - } - super::tiles::encode(&tiles, self.origin()) - } - - /// Tile table for `fill_buffer`, counted in vertices, so it can be handed - /// straight to `drawArrays`. - pub fn fill_tiles(&self) -> Vec { - let halves = self.fill_halves(); - let mut tiles = Vec::new(); - let mut base = 0u32; - for half in &halves { - let (_, mut t) = self.bucket_fills(half); - for tile in &mut t { - tile.start = base + tile.start * 3; - tile.count *= 3; - } - base += half.len() as u32 * 3; - tiles.extend(t); - } - super::tiles::encode(&tiles, self.origin()) - } +/// What [`Scene::buffers`] hands the renderer; see [`super::Drawing`]. +pub struct Buffers { + pub strokes: Vec, + pub fills: Vec, + pub opaque_strokes: usize, + pub opaque_fills: usize, + pub stroke_tiles: Vec, + pub fill_tiles: Vec, + pub opaque_stroke_tiles: usize, + pub opaque_fill_tiles: usize, + pub translucent_runs: Vec, +} - /// Where the translucent tiles begin, as an index into the tile table. - pub fn opaque_stroke_tiles(&self) -> usize { - self.bucket_strokes(&self.stroke_halves()[0]).1.len() - } +/// Elements in one tile of a translucent half. +const TRANSLUCENT_TILE: u32 = 4096; +/// Most runs the translucent pass alternates fills and strokes in, each a +/// few draw calls. +const MAX_TRANSLUCENT_RUNS: usize = 1024; - pub fn opaque_fill_tiles(&self) -> usize { - self.bucket_fills(&self.fill_halves()[0]).1.len() - } +/// The translucent halves' tiles, `start` and `count` in elements of the +/// half, and the runs of tiles to draw: (fills, first tile, end tile). +#[derive(Default)] +struct Translucent { + strokes: Vec, + fills: Vec, + runs: Vec<(bool, u32, u32)>, +} - /// How many leading strokes of `stroke_buffer` are opaque. - pub fn opaque_strokes(&self) -> usize { - self.strokes.iter().filter(|s| is_opaque(s.rgba)).count() +impl Translucent { + /// Element `index` of a half, next in draw order. + fn push(&mut self, fill: bool, index: usize, b: [f64; 4]) { + let tiles = if fill { + &mut self.fills + } else { + &mut self.strokes + }; + let same_run = self.runs.last().is_some_and(|r| r.0 == fill); + if let Some(t) = tiles + .last_mut() + .filter(|t| same_run && t.count < TRANSLUCENT_TILE) + { + t.count += 1; + t.min_x = t.min_x.min(b[0]); + t.min_y = t.min_y.min(b[1]); + t.max_x = t.max_x.max(b[2]); + t.max_y = t.max_y.max(b[3]); + return; + } + tiles.push(Tile { + start: index as u32, + count: 1, + min_x: b[0], + min_y: b[1], + max_x: b[2], + max_y: b[3], + }); + let n = tiles.len() as u32; + match self.runs.last_mut() { + Some(r) if same_run => r.2 = n, + _ => self.runs.push((fill, n - 1, n)), + } } +} - /// How many leading vertices of `fill_buffer` are opaque. - pub fn opaque_fills(&self) -> usize { - self.fills - .as_chunks::<3>() - .0 - .iter() - .filter(|t| is_opaque(t[0].rgba)) - .count() - * 3 - } +fn stroke_box(s: &Stroke) -> [f64; 4] { + let h = s.sagitta(); + [ + s.x0.min(s.x1) - h, + s.y0.min(s.y1) - h, + s.x0.max(s.x1) + h, + s.y0.max(s.y1) + h, + ] } -/// Fully opaque geometry can write depth; translucent geometry cannot. -/// -/// The draw order is resolved through the depth buffer, so a translucent -/// entity that writes depth rejects everything drawn behind it afterwards -/// instead of letting it blend through. Splitting the buffers lets the -/// renderer draw the translucent tail with depth writes off. +fn fill_box(t: &[FillVertex; 3]) -> [f64; 4] { + [ + t[0].x.min(t[1].x).min(t[2].x), + t[0].y.min(t[1].y).min(t[2].y), + t[0].x.max(t[1].x).max(t[2].x), + t[0].y.max(t[1].y).max(t[2].y), + ] +} + +/// How far from its anchor a run can reach, whatever its alignment and +/// rotation: every character an em wide, which no bundled face exceeds, and +/// a line's height. +fn text_reach(r: &super::text::TextRun) -> f64 { + let chars = r.text.chars().count() as f64; + r.unit() * (chars + 1.0) +} + +/// Fully opaque geometry can be drawn in any order, the depth buffer +/// deciding what covers what; translucent geometry blends over what is +/// already drawn, so it comes after, in draw order. #[inline] fn is_opaque(rgba: u32) -> bool { rgba >> 24 == 255 @@ -736,23 +1139,119 @@ mod tests { s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); s.push_triangle([2.0, 0.0], [3.0, 0.0], [2.0, 1.0], solid, 0); - assert_eq!(s.opaque_strokes(), 1); - assert_eq!(s.opaque_fills(), 3); + let b = s.buffers(); + assert_eq!(b.opaque_strokes, 1); + assert_eq!(b.opaque_fills, 3); // The opaque one comes first in the buffer whatever order it arrived in. - let buf = s.stroke_buffer(); - let first_rgba = u32::from_le_bytes(buf[16..20].try_into().unwrap()); + let first_rgba = u32::from_le_bytes(b.strokes[16..20].try_into().unwrap()); assert_eq!(first_rgba, solid); - let fills = s.fill_buffer(); - let first_fill_rgba = u32::from_le_bytes(fills[8..12].try_into().unwrap()); + let first_fill_rgba = u32::from_le_bytes(b.fills[8..12].try_into().unwrap()); assert_eq!(first_fill_rgba, solid); } + fn u32s(b: &[u8]) -> Vec { + b.as_chunks::<4>() + .0 + .iter() + .map(|c| u32::from_le_bytes(*c)) + .collect() + } + + /// An opaque fill is outlined by edges of no width, which anti-alias it: + /// a square of two triangles by its four sides, not their diagonal. A + /// translucent fill and a background one are not. + #[test] + fn opaque_fills_are_outlined_for_their_edges() { + let mut s = Scene::new(); + let red = pack_rgba(255, 0, 0, 255); + let attr = pack_attr(3, 25, FLAG_CONTRAST); + s.push_triangle([0.0, 0.0], [1.0, 0.0], [1.0, 1.0], red, attr); + s.push_triangle([0.0, 0.0], [1.0, 1.0], [0.0, 1.0], red, attr); + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + for k in &s.strokes { + assert_eq!(k.attr, pack_attr(3, FILL_EDGE, FLAG_CONTRAST)); + assert_eq!(k.rgba, red); + assert!(k.x0 == k.x1 || k.y0 == k.y1, "a side, not the diagonal"); + } + // Each fill once: a second call outlines nothing more. + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + s.push_triangle( + [5.0, 0.0], + [6.0, 0.0], + [6.0, 1.0], + pack_rgba(255, 0, 0, 128), + attr, + ); + s.push_triangle( + [7.0, 0.0], + [8.0, 0.0], + [8.0, 1.0], + red, + pack_attr(3, 25, FILL_FLAG_BACKGROUND), + ); + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + } + + /// Translucent geometry is drawn in draw order, fills and strokes + /// interleaved, wherever it was pushed from. + #[test] + fn translucent_geometry_keeps_draw_order() { + let mut s = Scene::new(); + let clear = pack_rgba(255, 0, 0, 128); + for (order, fill) in [(3, false), (1, true), (2, false), (4, true), (0, false)] { + s.order = order; + if fill { + s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); + } else { + s.push_stroke(0.0, 0.0, 1.0, 0.0, clear, 0); + } + } + // An opaque stroke elsewhere gives the stroke table a bucket first. + s.order = 9; + s.push_stroke(50.0, 50.0, 60.0, 50.0, pack_rgba(0, 0, 0, 255), 0); + let b = s.buffers(); + let orders: Vec = b + .strokes + .chunks(STROKE_BYTES) + .map(|c| u32s(&c[24..28])[0]) + .collect(); + assert_eq!(orders, [9, 0, 2, 3]); + // Strokes 0, fill 1, strokes 2 and 3, fill 4: stroke tiles from 1 + // (past the opaque bucket), fill tiles from 0. + assert_eq!( + u32s(&b.translucent_runs), + [0, 1, 2, 1, 0, 1, 0, 2, 3, 1, 1, 2] + ); + let tiles = u32s(&b.stroke_tiles); + let starts: Vec = tiles.chunks(6).map(|t| t[0]).collect(); + let counts: Vec = tiles.chunks(6).map(|t| t[1]).collect(); + assert_eq!((starts, counts), (vec![0, 1, 2], vec![1, 1, 2])); + } + + /// Past the most runs, the fills then the strokes. + #[test] + fn translucent_runs_have_a_ceiling() { + let mut s = Scene::new(); + let clear = pack_rgba(255, 0, 0, 128); + for i in 0..MAX_TRANSLUCENT_RUNS as u32 { + s.order = 2 * i; + s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); + s.order = 2 * i + 1; + s.push_stroke(0.0, 0.0, 1.0, 0.0, clear, 0); + } + let runs = u32s(&s.buffers().translucent_runs); + assert_eq!(runs, [1, 0, 1, 0, 0, 1]); + } + #[test] fn the_contrast_flag_and_the_fade_code_share_one_byte() { // Both live in the flags byte, so a hatch on colour 7 has to carry its // spacing and its flip without either eating the other. - for spacing in [1.5f64, 16.0, 500.0, 50_000.0] { + for spacing in [0.01, 0.5, 1.5f64, 16.0, 500.0, 50_000.0] { let fade = encode_fade_spacing(spacing); assert!( fade > 0 && fade < FLAG_CONTRAST, @@ -765,18 +1264,18 @@ mod tests { assert_eq!(a & 0xFFFF, 7); assert_eq!((a >> 16) & 0xFF, 25); } - assert_eq!(encode_fade_spacing(0.5), 0); + assert_eq!(encode_fade_spacing(0.0), 0); } #[test] fn the_fade_code_still_resolves_a_useful_spacing_range() { - // Seven bits, eight steps to the octave: a millimetre up to tens of - // metres, which is every hatch spacing a drawing uses. - let decode = |code: u8| (code as f64 / 8.0).exp2(); - for spacing in [2.0f64, 40.0, 5000.0] { + // Seven bits, five steps to the octave from 2^-9: a pattern + // millimetres apart in metres, or metres apart in millimetres. + let decode = |code: u8| (code as f64 / 5.0 - 9.0).exp2(); + for spacing in [0.003f64, 0.05, 0.5, 2.0, 40.0, 5000.0, 80_000.0] { let back = decode(encode_fade_spacing(spacing)); assert!( - (back / spacing - 1.0).abs() < 0.05, + (back / spacing - 1.0).abs() < 0.075, "{spacing} came back as {back}" ); } @@ -796,7 +1295,7 @@ mod tests { let o = s.origin(); assert!((o[0] - 6_500_000.5).abs() < 1e-6); - let buf = s.stroke_buffer(); + let buf = s.buffers().strokes; let x0 = f32::from_le_bytes(buf[0..4].try_into().unwrap()); let x1 = f32::from_le_bytes(buf[8..12].try_into().unwrap()); // Relative coordinates keep the 1-unit separation intact. @@ -814,13 +1313,66 @@ mod tests { ); } + /// A circle is four quarter arcs, its extents its own box exactly; an + /// arc inside a clip stays one, one across it is cut along its curve. + #[test] + fn arcs_are_strokes_of_a_quarter_turn() { + let mut s = Scene::new(); + s.push_arc(10.0, 0.0, 2.0, 0.0, std::f64::consts::TAU, 0, 0); + assert_eq!(s.strokes.len(), 4); + let quarter = (std::f64::consts::PI / 8.0).tan() as f32; + assert!(s.strokes.iter().all(|k| (k.bulge - quarter).abs() < 1e-6)); + let o = s.origin(); + assert_eq!( + s.extents_local().map(|v| (v * 1e4).round() / 1e4), + [-2.0, -2.0, 2.0, 2.0] + ); + assert_eq!(o, [10.0, 0.0]); + // The encoding carries the bulge last. + let b = s.buffers(); + let at = STROKE_BYTES - 4; + assert_eq!( + f32::from_le_bytes(b.strokes[at..at + 4].try_into().unwrap()), + quarter + ); + + let mut s = Scene::new(); + s.push_arc(0.0, 0.0, 1.0, 0.0, std::f64::consts::FRAC_PI_2, 0, 0); + let mark = Mark { + strokes: 0, + fills: 0, + texts: 0, + }; + s.clip_since( + mark, + &super::super::clip::ClipShape::rect([0.0, 0.0], 10.0, 10.0), + ); + assert_eq!(s.strokes.len(), 1, "inside: still one arc"); + s.clip_since( + mark, + &super::super::clip::ClipShape::rect([1.0, 0.0], 1.0, 10.0), + ); + assert!( + s.strokes.len() > 1 + && s.strokes + .iter() + .all(|k| k.bulge == 0.0 && k.x0 >= 0.5 - 1e-9) + ); + // On the curve, to the flattening's sagitta. + assert!(s + .strokes + .iter() + .all(|k| (k.x0.hypot(k.y0) - 1.0).abs() < 2e-3)); + } + #[test] fn buffers_have_the_documented_stride() { let mut s = Scene::new(); s.push_stroke(0.0, 0.0, 1.0, 1.0, 0, 0); s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], 0, 0); - assert_eq!(s.stroke_buffer().len(), STROKE_BYTES); - assert_eq!(s.fill_buffer().len(), 3 * FILL_BYTES); + let b = s.buffers(); + assert_eq!(b.strokes.len(), STROKE_BYTES); + assert_eq!(b.fills.len(), 3 * FILL_BYTES); } #[test] diff --git a/crates/exav-render/src/formats/dwg/tessellate.rs b/crates/exav-render/src/formats/dwg/tessellate.rs index c83f158..bedfe8d 100644 --- a/crates/exav-render/src/formats/dwg/tessellate.rs +++ b/crates/exav-render/src/formats/dwg/tessellate.rs @@ -1,6 +1,6 @@ //! Walk a drawing model and turn drawable entities into GPU buffers. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::cad::{ Color, Drawing as CadDrawing, Edge, Entity, EntityKind, HAlign, Handle, LineWeight, @@ -9,6 +9,7 @@ use crate::cad::{ use super::clip::ClipShape; use super::curves; +use super::dimension; use super::fill; use super::font; use super::gradient::{self, Gradient}; @@ -20,6 +21,7 @@ use super::scene::{ encode_fade_spacing, pack_attr, pack_rgba, Affine, Scene, FILL_FLAG_BACKGROUND, FLAG_CONTRAST, }; use super::text; +use super::wide::{self, WideVertex}; fn h_align_of(a: HAlign) -> text::HAlign { match a { @@ -94,6 +96,230 @@ fn island_style(h: &crate::cad::Hatch) -> fill::IslandStyle { /// step AutoCAD's own editor appears to use and is the width of a typical list /// indent; it is a guess, and it is only ever reached by text that tabs without /// declaring where to. +/// An arrowhead one unit long, its tip at the origin pointing along +x: +/// triangles to fill and lines to stroke. +struct ArrowShape { + filled: Vec<[f64; 2]>, + lines: Vec>, +} + +/// AutoCAD's built-in arrowheads by block name (`_OPEN`...); an empty or +/// unknown name is the closed filled one. +fn arrow_shape(name: &str) -> ArrowShape { + let circle = |r: f64, cx: f64| -> Vec<[f64; 2]> { + let mut c = curves::flatten_circle(cx, 0.0, r); + if let Some(&first) = c.first() { + c.push(first); + } + c + }; + let disc = |r: f64| -> Vec<[f64; 2]> { + let ring = curves::flatten_circle(0.0, 0.0, r); + (0..ring.len()) + .flat_map(|i| [[0.0, 0.0], ring[i], ring[(i + 1) % ring.len()]]) + .collect() + }; + let triangle = |w: f64, l: f64| vec![[0.0, 0.0], [-l, w], [-l, -w]]; + let (filled, lines) = match name { + "_NONE" => (vec![], vec![]), + "_OPEN" => ( + vec![], + vec![vec![[-1.0, 1.0 / 6.0], [0.0, 0.0], [-1.0, -1.0 / 6.0]]], + ), + "_OPEN30" => ( + vec![], + vec![vec![[-1.0, 0.268], [0.0, 0.0], [-1.0, -0.268]]], + ), + "_OPEN90" => (vec![], vec![vec![[-0.5, 0.5], [0.0, 0.0], [-0.5, -0.5]]]), + "_CLOSEDBLANK" => ( + vec![], + vec![{ + let mut t = triangle(1.0 / 6.0, 1.0); + t.push([0.0, 0.0]); + t + }], + ), + "_CLOSED" => ( + vec![], + vec![ + { + let mut t = triangle(1.0 / 6.0, 1.0); + t.push([0.0, 0.0]); + t + }, + vec![[-1.0, 0.0], [0.0, 0.0]], + ], + ), + "_SMALL" => (triangle(1.0 / 12.0, 0.5), vec![]), + "_DOT" => (disc(0.25), vec![]), + "_DOTSMALL" => (disc(1.0 / 16.0), vec![]), + "_DOTBLANK" => (vec![], vec![circle(0.25, 0.0)]), + "_ORIGIN" => (vec![], vec![circle(0.5, 0.0)]), + "_ORIGIN2" => (vec![], vec![circle(0.5, 0.0), circle(0.25, 0.0)]), + "_OBLIQUE" | "_ARCHTICK" => (vec![], vec![vec![[-0.5, -0.5], [0.5, 0.5]]]), + "_BOXFILLED" => ( + vec![ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + ], + vec![], + ), + "_BOXBLANK" => ( + vec![], + vec![vec![ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + [-0.5, -0.5], + ]], + ), + "_DATUMFILLED" => (vec![[0.0, 0.577], [0.0, -0.577], [-1.0, 0.0]], vec![]), + "_DATUMBLANK" => ( + vec![], + vec![vec![[0.0, 0.577], [0.0, -0.577], [-1.0, 0.0], [0.0, 0.577]]], + ), + _ => (triangle(1.0 / 6.0, 1.0), vec![]), + }; + ArrowShape { filled, lines } +} + +/// A tolerance box's text with its GD&T font runs (`{\Fgdt;j}`, the font +/// code in any case) as the symbols they stand for, the braces gone. +fn gdt_codes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.to_ascii_lowercase().find("\\fgdt;") { + let before = &rest[..at]; + out.push_str(before.strip_suffix('{').unwrap_or(before)); + let after = &rest[at + "\\fgdt;".len()..]; + let end = after.find('}').unwrap_or(after.len()); + out.push_str(&font::map_symbols(&after[..end], font::Symbols::Gdt)); + rest = after.get(end + 1..).unwrap_or(""); + } + out.push_str(rest); + out +} + +/// A polygon mesh's lines: along M and along N, closed as its flags say +/// (1 in M, 32 in N). +fn polygon_mesh_edges(p: &crate::cad::Polyline) -> Vec> { + let (m, n) = (p.m_count.max(0) as usize, p.n_count.max(0) as usize); + let pts: Vec<[f64; 2]> = p + .vertices + .iter() + .map(|v| [v.location.x, v.location.y]) + .collect(); + if m < 1 || n < 1 || pts.len() < m * n { + return Vec::new(); + } + let at = |i: usize, j: usize| pts[i * n + j]; + let mut out = Vec::new(); + for i in 0..m { + let mut row: Vec<[f64; 2]> = (0..n).map(|j| at(i, j)).collect(); + if p.flags & 32 != 0 && n > 2 { + row.push(at(i, 0)); + } + out.push(row); + } + for j in 0..n { + let mut column: Vec<[f64; 2]> = (0..m).map(|i| at(i, j)).collect(); + if p.flags & 1 != 0 && m > 2 { + column.push(at(0, j)); + } + out.push(column); + } + out +} + +/// A polyface mesh's visible edges: each face's sides, but those whose +/// starting index is negative, each side once. +fn polyface_edges(p: &crate::cad::Polyline) -> Vec> { + let corners: Vec<[f64; 2]> = p + .vertices + .iter() + .filter(|v| v.flags & 64 != 0) + .map(|v| [v.location.x, v.location.y]) + .collect(); + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for face in p + .vertices + .iter() + .filter(|v| v.flags & 64 == 0 && v.flags & 128 != 0) + { + let ids: Vec = face + .indices + .iter() + .copied() + .take_while(|&i| i != 0) + .collect(); + for k in 0..ids.len() { + let (a, b) = (ids[k], ids[(k + 1) % ids.len()]); + if a < 0 || ids.len() < 2 { + continue; + } + let (a, b) = (a.unsigned_abs() as usize, b.unsigned_abs() as usize); + let (Some(&pa), Some(&pb)) = (corners.get(a - 1), corners.get(b - 1)) else { + continue; + }; + if seen.insert((a.min(b), a.max(b))) { + out.push(vec![pa, pb]); + } + } + } + out +} + +/// Opacity of the fill drawn for a pattern too dense to draw: about what +/// sub-pixel lines, faded by their spacing, add up to on screen. +const TRUNCATED_PATTERN_TONE: f64 = 0.5; + +/// The scale, turn and mirroring of a transform that keeps circles round +/// (rotation, uniform scale, perhaps a mirror); None for any other. +fn similarity(xf: &Affine) -> Option<(f64, f64, bool)> { + let scale = xf.a.hypot(xf.b); + let tolerance = 1e-9 * scale.max(1e-300); + let turn = xf.b.atan2(xf.a); + if (xf.a - xf.d).abs() <= tolerance && (xf.b + xf.c).abs() <= tolerance { + return (scale > 0.0).then_some((scale, turn, false)); + } + if (xf.a + xf.d).abs() <= tolerance && (xf.b - xf.c).abs() <= tolerance { + return (scale > 0.0).then_some((scale, turn, true)); + } + None +} + +/// A stack's parts against the text height: AutoCAD's default (`TSTACKSIZE`). +const STACK_SIZE: f64 = 0.7; + +/// A run's height: the entity's, times a `\H...x` factor, or a `\H` height. +fn span_height(s: &text::Span, base: f64) -> f64 { + match s.height { + text::SpanHeight::Factor(f) if f > 0.0 => base * f, + text::SpanHeight::Absolute(h) if h > 0.0 => h, + _ => base, + } +} + +/// How wide a stack is drawn: its wider part, or both parts and the slash +/// of a diagonal one. +fn stack_width(s: &text::Stack, metrics: &font::Metrics, height: f64, w: f64) -> f64 { + let part = height * STACK_SIZE; + let (upper, lower) = ( + metrics.measure(&s.upper) * part * w, + metrics.measure(&s.lower) * part * w, + ); + match s.kind { + text::StackKind::Diagonal => upper + 0.5 * part + lower, + _ => upper.max(lower), + } +} + fn next_tab_stop(cursor: f64, paragraph: &text::Paragraph, height: f64) -> f64 { if let Some(stop) = paragraph.tabs.iter().find(|s| **s > cursor + 1e-9) { return *stop; @@ -130,41 +356,69 @@ struct LineTypeDef { labels: Vec, } -/// The text a complex linetype embeds, ready to scale. +/// The text and shapes a complex linetype embeds, ready to scale. /// -/// Shape elements are skipped: they name a glyph in an SHX shape file, which -/// cannot be shipped any more than an SHX font can (DESIGN.md section 5.3). +/// A shape names a glyph in an SHX shape file, which cannot be shipped any +/// more than an SHX font can (DESIGN.md section 5.3): only ltypeshp.shx's, +/// which AutoCAD's own linetypes use, are drawn, redrawn here. fn embedded_labels(doc: &CadDrawing, lt: &crate::cad::Linetype) -> Vec { let mut out = Vec::new(); for (run, e) in lt.elements.iter().enumerate() { - if !e.has_text() || e.text.is_empty() || !e.scale.is_finite() || e.scale <= 0.0 { + if !e.scale.is_finite() || e.scale <= 0.0 { continue; } - // The element's scale multiplies the style's fixed height, or is the - // height itself when the style leaves it free, which is the usual case. let style = doc .text_styles .iter() .find(|s| !e.style.is_null() && s.handle == e.style); - let style_height = style.map(|s| s.height).unwrap_or(0.0); - let height = if style_height > 0.0 { - style_height * e.scale + let (mark, height) = if e.has_text() && !e.text.is_empty() { + // The element's scale multiplies the style's fixed height, or is + // the height itself when the style leaves it free, which is the + // usual case. + let style_height = style.map(|s| s.height).unwrap_or(0.0); + let height = if style_height > 0.0 { + style_height * e.scale + } else { + e.scale + }; + let face = style.map(font::resolve_style).unwrap_or(font::Face::Stroke); + ( + linetype::Mark::Text { + text: text::decode_special(&e.text), + face, + }, + height, + ) + } else if e.has_shape() { + let Some(shape) = style + .filter(|s| is_ltypeshp(&s.font_file)) + .and_then(|_| linetype::ltypeshp_shape(e.shape_number)) + else { + continue; + }; + (linetype::Mark::Shape(shape), e.scale) } else { - e.scale + continue; }; out.push(linetype::Label { run, - text: text::decode_special(&e.text), + mark, height, offset: [e.offset.x, e.offset.y], rotation: e.rotation, absolute_rotation: e.absolute_rotation(), - face: style.map(font::resolve_style).unwrap_or(font::Face::Stroke), }); } out } +/// A style's font file is ltypeshp.shx, under any path or case. +fn is_ltypeshp(file: &str) -> bool { + let name = file.rsplit(['/', '\\']).next().unwrap_or(file); + let stem = name.split('.').next().unwrap_or(name); + stem.eq_ignore_ascii_case("ltypeshp") +} + /// A RAY or XLINE, held back until the drawing's extents are known. struct InfiniteLine { base: [f64; 2], @@ -219,6 +473,32 @@ pub struct LayoutInfo { pub tab_order: i16, /// The sheet, as `[min_x, min_y, max_x, max_y]` in paper units. pub limits: Option<[f64; 4]>, + /// Millimetres per paper unit: 25.4 for a sheet set up in inches. + pub mm_per_unit: f64, +} + +/// What a tab is drawn on, beyond its geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct Sheet { + /// Millimetres per drawing unit, which a lineweight on a layout is a + /// width in: 25.4 for a sheet in inches. 0 for model space, where a + /// lineweight is a width on the screen. + pub mm_per_unit: f64, + /// `$LWDISPLAY`: the drawing is shown with its lineweights. + pub lineweights: bool, + /// A layout's paper, `[min_x, min_y, max_x, max_y]` in drawing units: + /// its limits. None for model space. + pub paper: Option<[f64; 4]>, +} + +impl Default for Sheet { + fn default() -> Self { + Sheet { + mm_per_unit: 0.0, + lineweights: true, + paper: None, + } + } } pub struct LayerInfo { @@ -259,6 +539,33 @@ pub struct Warnings { /// without proxy graphics, or with graphics that draw nothing: only the /// application that made them can show them. pub proxy_without_graphics: u32, + /// What `unknown_entities` counts, by DXF type, at most + /// [`MAX_NAMED`] types. + pub not_drawn: BTreeMap, + /// The files external references name (xrefs, images, underlays), at + /// most [`MAX_NAMED`]. + pub external_files: BTreeSet, +} + +/// Most names a warning lists. +pub const MAX_NAMED: usize = 16; + +impl Warnings { + fn not_drawn(&mut self, kind: &str) { + self.unknown_entities = self.unknown_entities.saturating_add(1); + if let Some(n) = self.not_drawn.get_mut(kind) { + *n = n.saturating_add(1); + } else if self.not_drawn.len() < MAX_NAMED { + self.not_drawn.insert(kind.to_string(), 1); + } + } + + fn external(&mut self, file: &str) { + self.external_references = self.external_references.saturating_add(1); + if !file.is_empty() && self.external_files.len() < MAX_NAMED { + self.external_files.insert(file.to_string()); + } + } } /// Default for [`Tessellator::budget`]: strokes and fill vertices one layout @@ -318,13 +625,14 @@ pub struct Tessellator<'a> { /// because a drawing names a handful of styles and uses them thousands of /// times. style_faces: HashMap, - /// The same, by style handle: entities that reference a style by name are - /// the common case, but leaders and dimensions point at one by handle. - style_faces_by_handle: HashMap, + /// Styles whose font draws symbols for letters (symbol.ttf, gdt.shx). + style_symbols: HashMap, /// Which ACI table indexed colours resolve through. palette: Palette, /// Block names currently being expanded, to break reference cycles. stack: Vec, + /// What the tab drawn last is drawn on. + pub sheet: Sheet, } fn normalize(name: &str) -> String { @@ -449,13 +757,17 @@ impl<'a> Tessellator<'a> { .iter() .map(|s| (normalize(&s.name), font::resolve_style(s))) .collect(), - style_faces_by_handle: doc + style_symbols: doc .text_styles .iter() - .map(|s| (s.handle.0, font::resolve_style(s))) + .filter_map(|s| Some((normalize(&s.name), font::symbols_of(&s.font_file)?))) .collect(), palette, stack: Vec::new(), + sheet: Sheet { + lineweights: doc.header.lwdisplay, + ..Sheet::default() + }, } } @@ -504,60 +816,74 @@ impl<'a> Tessellator<'a> { maxy += pad; for line in std::mem::take(&mut self.pending_infinite) { - let len = (line.dir[0] * line.dir[0] + line.dir[1] * line.dir[1]).sqrt(); - if !len.is_finite() || len < 1e-12 { - continue; - } - let u = [line.dir[0] / len, line.dir[1] / len]; + self.push_infinite(&line, [minx, miny], [maxx, maxy]); + } + } - // Slab clip: the parameter range where the line is inside the box. - let mut t0 = if line.both_ways { - f64::NEG_INFINITY - } else { - 0.0 - }; - let mut t1 = f64::INFINITY; - let mut outside = false; - for (p, d, lo, hi) in [ - (line.base[0], u[0], minx, maxx), - (line.base[1], u[1], miny, maxy), - ] { - if d.abs() < 1e-12 { - if p < lo || p > hi { - outside = true; - } - continue; + /// The part of a ray or xline inside a box, as a stroke. + fn push_infinite(&mut self, line: &InfiniteLine, min: [f64; 2], max: [f64; 2]) { + let len = (line.dir[0] * line.dir[0] + line.dir[1] * line.dir[1]).sqrt(); + if !len.is_finite() || len < 1e-12 { + return; + } + let u = [line.dir[0] / len, line.dir[1] / len]; + + // Slab clip: the parameter range where the line is inside the box. + let mut t0 = if line.both_ways { + f64::NEG_INFINITY + } else { + 0.0 + }; + let mut t1 = f64::INFINITY; + for (p, d, lo, hi) in [ + (line.base[0], u[0], min[0], max[0]), + (line.base[1], u[1], min[1], max[1]), + ] { + if d.abs() < 1e-12 { + if p < lo || p > hi { + return; } - let (a, b) = ((lo - p) / d, (hi - p) / d); - let (a, b) = if a <= b { (a, b) } else { (b, a) }; - t0 = t0.max(a); - t1 = t1.min(b); - } - if outside || !(t1 > t0) || !t0.is_finite() || !t1.is_finite() { continue; } - - self.scene.push_stroke( - line.base[0] + u[0] * t0, - line.base[1] + u[1] * t0, - line.base[0] + u[0] * t1, - line.base[1] + u[1] * t1, - line.rgba, - line.attr, - ); + let (a, b) = ((lo - p) / d, (hi - p) / d); + let (a, b) = if a <= b { (a, b) } else { (b, a) }; + t0 = t0.max(a); + t1 = t1.min(b); + } + if !(t1 > t0) || !t0.is_finite() || !t1.is_finite() { + return; } + + self.scene.push_stroke( + line.base[0] + u[0] * t0, + line.base[1] + u[1] * t0, + line.base[0] + u[0] * t1, + line.base[1] + u[1] * t1, + line.rgba, + line.attr, + ); } /// Leader lines plus the label they point at. fn multi_leader(&mut self, m: &crate::cad::MultiLeader, ctx: &Ctx, rgba: u32, attr: u32) { let c = &m.context; - // Every leader root contributes one or more polylines. + // Every leader root contributes one or more polylines, each with + // its arrowhead at its first vertex. + let scale = if c.scale > 0.0 { c.scale } else { 1.0 }; + let size = m.arrowhead_size * scale; for root in &c.leaders { for line in &root.lines { - let pts: Vec<[f64; 2]> = line.vertices.iter().map(|p| [p.x, p.y]).collect(); + // From the arrow's tip through the line's vertices to where + // the root lands. + let mut pts: Vec<[f64; 2]> = line.vertices.iter().map(|p| [p.x, p.y]).collect(); + let landing = [root.connection_point.x, root.connection_point.y]; + if pts.last() != Some(&landing) && (root.has_connection_point || pts.len() < 2) { + pts.push(landing); + } if pts.len() >= 2 { self.push_transformed(&pts, false, &ctx.xf, rgba, attr, None); + self.arrowhead(m.arrowhead, pts[0], pts[1], size, ctx, rgba, attr); } } // The landing runs from the last leader point to the content. @@ -582,47 +908,32 @@ impl<'a> Tessellator<'a> { } else { 2.5 }; - let spacing = height - * text::MTEXT_LINE_SPACING - * if c.line_spacing_factor > 0.0 { - c.line_spacing_factor - } else { - 1.0 - }; - let lines = text::mtext_lines(&c.text); - let face = self.style_face_by_handle(c.text_style); - // Attachment codes match MTEXT's. - let (h_align, dy0) = - text::mtext_layout(c.text_attachment as u8, lines.len(), height, spacing); - let (sin_r, cos_r) = rotation.sin_cos(); let text_rgba = match c.text_color { Color::ByLayer | Color::ByBlock => rgba, ref col => colour_rgb(col, self.palette) - .map(|(r, g, b)| pack_rgba(r, g, b, 255)) + .map(|(r, g, b)| pack_rgba(r, g, b, (rgba >> 24) as u8)) .unwrap_or(rgba), }; - - for (i, line) in lines.iter().enumerate() { - if line.is_empty() { - continue; - } - let dy = dy0 - i as f64 * spacing; - self.push_text_run( - line.clone(), - c.text_location.x - dy * sin_r, - c.text_location.y + dy * cos_r, - height, - rotation, - 1.0, - 0.0, - h_align, - text::VAlign::Baseline, - ctx, - text_rgba, - attr, - face, - ); - } + // The content is MTEXT, drawn as one (attachment codes match). + let style = self + .doc + .text_styles + .iter() + .find(|s| s.handle == c.text_style && !c.text_style.is_null()) + .map(|s| s.name.clone()) + .unwrap_or_default(); + let content = crate::cad::MText { + insertion: c.text_location, + height, + reference_width: c.text_width, + attachment: c.text_attachment, + style, + rotation, + line_spacing_factor: c.line_spacing_factor, + text: c.text.clone(), + ..crate::cad::MText::default() + }; + self.mtext(&content, ctx, text_rgba, attr); } } @@ -717,13 +1028,14 @@ impl<'a> Tessellator<'a> { .unwrap_or(font::Face::Stroke) } - /// The same, for an entity that points at its style by handle. - fn style_face_by_handle(&self, handle: Handle) -> font::Face { - self.style_faces_by_handle - .get(&handle.0) - .copied() - .filter(|_| !handle.is_null()) - .unwrap_or(font::Face::Stroke) + /// A TEXT's or ATTRIB's string as its style shows it: the `%%` codes + /// decoded, then a symbol font's letters as the symbols they draw. + fn style_text(&self, style: &str, raw: &str) -> String { + let s = text::decode_special(raw); + match self.style_symbols.get(&normalize(style)) { + Some(&set) => font::map_symbols(&s, set), + None => s, + } } /// MTEXT content as the lines it is drawn on: the explicit `\P` breaks, @@ -755,138 +1067,338 @@ impl<'a> Tessellator<'a> { .collect() } - /// Draw one laid-out MTEXT line, honouring per-run colour, height and rules. + /// A style's width factor and oblique angle, which MTEXT takes unless + /// `\W` and `\Q` say otherwise. + fn style_shape(&self, style: &str) -> (f64, f64) { + self.doc + .text_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(style)) + .map(|s| { + let w = if s.width_factor > 0.0 { + s.width_factor + } else { + 1.0 + }; + (w, s.oblique) + }) + .unwrap_or((1.0, 0.0)) + } + + /// An MTEXT, or a multiline attribute: its lines wrapped to its box and + /// stacked from the attachment point, each aligned as its paragraph + /// says, over its background mask when it has one. + fn mtext(&mut self, m: &crate::cad::MText, ctx: &Ctx, rgba: u32, attr: u32) { + let height = if m.height > 0.0 { m.height } else { 1.0 }; + let face = self.style_face(&m.style); + let shape = self.style_shape(&m.style); + let mut lines = self.wrap_mtext(&m.text, m.reference_width / shape.0, height, face); + if let Some(&set) = self.style_symbols.get(&normalize(&m.style)) { + for s in lines + .iter_mut() + .flat_map(|l| l.spans.iter_mut()) + .filter(|s| s.face.is_none()) + { + s.text = font::map_symbols(&s.text, set); + } + } + let spacing = height + * text::MTEXT_LINE_SPACING + * if m.line_spacing_factor > 0.0 { + m.line_spacing_factor + } else { + 1.0 + }; + + // DWG stores the text direction as a vector when it has one. + let rotation = match m.x_direction { + Some(d) if d.x != 0.0 || d.y != 0.0 => d.y.atan2(d.x), + _ => m.rotation, + }; + let (h_align, dy0) = text::mtext_layout(m.attachment as u8, lines.len(), height, spacing); + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + m.insertion.x + along * cos_r - up * sin_r, + m.insertion.y + along * sin_r + up * cos_r, + ] + }; + + // The box lines align in and the mask covers: the entity's width, + // else its widest line. + let realigned = lines + .iter() + .any(|l| l.paragraph.align.is_some_and(|a| a != h_align)); + let masked = m.background_fill & 0x13 != 0; + let box_width = if m.reference_width > 0.0 { + m.reference_width + } else if realigned || masked { + lines + .iter() + .map(|l| self.mtext_line_layout(l, height, face, shape.0).1) + .fold(0.0, f64::max) + } else { + 0.0 + }; + let left = match h_align { + text::HAlign::Center | text::HAlign::Middle => -box_width / 2.0, + text::HAlign::Right => -box_width, + _ => 0.0, + }; + + if masked { + // A border of (scale - 1) heights around the lines, from the + // first line's capitals to the last line's baseline. + let scale = if m.background_scale > 0.0 { + m.background_scale + } else { + 1.5 + }; + let border = height * (scale - 1.0).max(0.0); + let top = dy0 + height + border; + let bottom = dy0 - (lines.len().max(1) - 1) as f64 * spacing - border; + let corners: Vec<[f64; 2]> = [ + (left - border, bottom), + (left + box_width + border, bottom), + (left + box_width + border, top), + (left - border, top), + ] + .iter() + .map(|&(a, b)| { + let p = at(a, b); + ctx.xf.apply(p[0], p[1]) + }) + .collect(); + self.mtext_background(m, &corners, rgba, attr); + // The text over its own mask: one step later in draw order. + self.scene.order = self.scene.order.saturating_add(1); + if m.background_fill & 0x10 != 0 { + self.scene.push_polyline(&corners, true, rgba, attr); + } + } + + for (i, line) in lines.iter().enumerate() { + if line.spans.is_empty() { + continue; + } + // Lines stack along the text's own down direction. + let dy = dy0 - i as f64 * spacing; + let (align, along) = match line.paragraph.align { + Some(a) if a != h_align && box_width > 0.0 => ( + a, + left + match a { + text::HAlign::Center => box_width / 2.0, + text::HAlign::Right => box_width, + _ => 0.0, + }, + ), + _ => (h_align, 0.0), + }; + let p = at(along, dy); + self.mtext_line( + line, height, p, rotation, align, shape, ctx, rgba, attr, face, + ); + } + } + + /// An MTEXT's background: its colour (group 90 bit 1) or the drawing + /// area's (bit 2), as a WIPEOUT paints it. + fn mtext_background( + &mut self, + m: &crate::cad::MText, + corners: &[[f64; 2]], + rgba: u32, + attr: u32, + ) { + let base = attr & 0x00FF_FFFF; + let colour = (m.background_fill & 2 == 0) + .then(|| colour_rgb(&m.background_color, self.palette)) + .flatten(); + let (fill, fill_attr) = match colour { + Some((r, g, b)) => (pack_rgba(r, g, b, 255), base), + None => (rgba, base | u32::from(FILL_FLAG_BACKGROUND) << 24), + }; + for t in fill::fill_even_odd(&[corners.to_vec()]).chunks(3) { + if t.len() == 3 { + self.scene.push_triangle(t[0], t[1], t[2], fill, fill_attr); + } + } + } + + /// Where each run of an MTEXT line goes, from the line's left edge: the + /// runs as (span, text, start, width), and the line's width. A tab stop + /// is measured from that edge, so alignment comes after. + fn mtext_line_layout( + &self, + line: &text::Line, + base_height: f64, + face: font::Face, + base_width: f64, + ) -> (Vec<(usize, String, f64, f64)>, f64) { + let mut placed = Vec::new(); + // The paragraph's indents; a first line can be indented differently + // from the rest, which is what makes a numbered list hang. + let mut cursor = line.paragraph.indent + + if line.first { + line.paragraph.first_indent + } else { + 0.0 + }; + for (i, span) in line.spans.iter().enumerate() { + let height = span_height(span, base_height); + let w = span.width.unwrap_or(base_width); + // Each run is measured in its own face, since a `\f` in the + // middle of a line changes how wide the rest of it sits. + let metrics = font::Metrics::for_face(span.face.unwrap_or(face)); + if let Some(stack) = &span.stack { + let width = stack_width(stack, &metrics, height, w); + placed.push((i, String::new(), cursor, width)); + cursor += width; + continue; + } + for (n, piece) in span.text.split('\t').enumerate() { + if n > 0 { + cursor = next_tab_stop(cursor, &line.paragraph, base_height); + } + if piece.is_empty() { + continue; + } + let width = metrics.measure(piece) * height * w * span.tracking.unwrap_or(1.0); + placed.push((i, piece.to_string(), cursor, width)); + cursor += width; + } + } + (placed, cursor) + } + + /// Draw one laid-out MTEXT line, honouring per-run colour, height, width + /// factor, slant, tracking, stacks and rules. /// - /// A line of one run is handed to the renderer to align, which measures the - /// string with the real font. A line of several has to be placed here, - /// because each run starts where the one before it ended. + /// A line of plain text is handed to the renderer to align, which + /// measures the string with the real font. Any other has to be placed + /// here, because each run starts where the one before it ended. #[allow(clippy::too_many_arguments)] - fn push_mtext_line( + fn mtext_line( &mut self, line: &text::Line, base_height: f64, - x: f64, - y: f64, + [x, y]: [f64; 2], rotation: f64, h_align: text::HAlign, + (base_width, base_oblique): (f64, f64), ctx: &Ctx, rgba: u32, attr: u32, face: font::Face, ) { let spans = &line.spans; - let height_of = |s: &text::Span| match s.height { - text::SpanHeight::Factor(f) if f > 0.0 => base_height * f, - text::SpanHeight::Absolute(h) if h > 0.0 => h, - _ => base_height, + let plain = |s: &text::Span| { + s.color.is_none() + && !s.underline + && !s.overline + && !s.strike + && s.width.is_none() + && s.oblique.is_none() + && s.tracking.is_none() + && s.stack.is_none() + && !s.text.contains('\t') }; - // A `\f` run draws in the face it names; the rest take the entity's. - let face_of = |s: &text::Span| s.face.unwrap_or(face); - - // Where the line starts, from its paragraph's indents. The first line - // of a paragraph can be indented differently from the rest, which is - // what makes a numbered list hang. - let indent = line.paragraph.indent - + if line.first { - line.paragraph.first_indent - } else { - 0.0 - }; - let tabbed = spans.iter().any(|s| s.text.contains('\t')); - - let styled = tabbed - || indent != 0.0 - || spans.iter().any(|s| { - s.color.is_some() - || s.face.is_some_and(|f| f != face) - || s.underline - || s.overline - || s.strike - || height_of(s) != base_height - }); + let indented = + line.paragraph.indent != 0.0 || line.first && line.paragraph.first_indent != 0.0; + let first = &spans[0]; + let one_style = spans.iter().all(|s| { + s.face.unwrap_or(face) == first.face.unwrap_or(face) + && span_height(s, base_height) == span_height(first, base_height) + }); // Nothing to place: hand the whole line over as one run. - if !styled - || spans.len() == 1 - && !spans[0].underline - && !spans[0].overline - && !spans[0].strike - && spans[0].color.is_none() - { + if !indented && one_style && spans.iter().all(plain) { let text: String = spans.iter().map(|s| s.text.as_str()).collect(); - let height = spans.first().map(height_of).unwrap_or(base_height); - let face = spans.first().map(face_of).unwrap_or(face); self.push_text_run( text, x, y, - height, - rotation, - 1.0, - 0.0, + text::frame( + span_height(first, base_height), + rotation, + base_width, + base_oblique, + ), h_align, text::VAlign::Baseline, ctx, rgba, attr, - face, + first.face.unwrap_or(face), ); return; } - // Lay the line out from its own left edge first. A tab stop is measured - // from there, not from the anchor, so alignment can only be applied - // once the whole line has been placed. - let mut placed: Vec<(usize, String, f64, f64)> = Vec::new(); - let mut cursor = indent; - for (i, span) in spans.iter().enumerate() { - let height = height_of(span); - for (n, piece) in span.text.split('\t').enumerate() { - if n > 0 { - cursor = next_tab_stop(cursor, &line.paragraph, base_height); - } - if piece.is_empty() { - continue; - } - // Each run is measured in its own face, since a `\f` in the - // middle of a line changes how wide the rest of it sits. - let width = font::Metrics::for_face(face_of(span)).measure(piece) * height; - placed.push((i, piece.to_string(), cursor, width)); - cursor += width; - } - } - + let (placed, total) = self.mtext_line_layout(line, base_height, face, base_width); let offset = match h_align { - text::HAlign::Center | text::HAlign::Middle => -cursor / 2.0, - text::HAlign::Right => -cursor, + text::HAlign::Center | text::HAlign::Middle => -total / 2.0, + text::HAlign::Right => -total, _ => 0.0, }; let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + x + along * cos_r - up * sin_r, + y + along * sin_r + up * cos_r, + ] + }; for (i, piece, start, width) in placed { let span = &spans[i]; let along = start + offset; - let width = &width; - let height = height_of(span); + let height = span_height(span, base_height); let (rgba, attr) = match span.color { Some(c) => self.span_colour(c, rgba, attr), None => (rgba, attr), }; - self.push_text_run( - piece, - x + along * cos_r, - y + along * sin_r, - height, - rotation, - 1.0, - 0.0, - text::HAlign::Left, - text::VAlign::Baseline, - ctx, - rgba, - attr, - face_of(span), - ); + let face = span.face.unwrap_or(face); + let w = span.width.unwrap_or(base_width); + let frame = text::frame(height, rotation, w, span.oblique.unwrap_or(base_oblique)); + if let Some(stack) = &span.stack { + let shape = (w, span.oblique.unwrap_or(base_oblique)); + self.mtext_stack( + stack, + at(along, 0.0), + rotation, + height, + shape, + width, + ctx, + rgba, + attr, + face, + ); + } else if let Some(t) = span.tracking.filter(|t| *t != 1.0) { + // Each character at its advance times the tracking. + let metrics = font::Metrics::for_face(face); + let mut pen = along; + for c in piece.chars() { + let p = at(pen, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run( + c.to_string(), + p[0], + p[1], + frame, + l, + b, + ctx, + rgba, + attr, + face, + ); + pen += metrics.advance(c) * height * w * t; + } + } else { + let p = at(along, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(piece, p[0], p[1], frame, l, b, ctx, rgba, attr, face); + } // The three rules, as strokes along the run at the usual offsets. for (on, dy) in [ @@ -897,19 +1409,76 @@ impl<'a> Tessellator<'a> { if !on { continue; } - let a = ctx.xf.apply( - x + along * cos_r - dy * sin_r, - y + along * sin_r + dy * cos_r, - ); - let b = ctx.xf.apply( - x + (along + width) * cos_r - dy * sin_r, - y + (along + width) * sin_r + dy * cos_r, - ); + let (a, b) = (at(along, dy), at(along + width, dy)); + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); } } } + /// A stack on the baseline at `p`, `width` wide (`stack_width`): its + /// parts at [`STACK_SIZE`] of the height, one above the other about the + /// capitals' middle (across a bar for a fraction, left-aligned for a + /// tolerance), or side by side across a slash. + #[allow(clippy::too_many_arguments)] + fn mtext_stack( + &mut self, + s: &text::Stack, + p: [f64; 2], + rotation: f64, + height: f64, + (w, oblique): (f64, f64), + width: f64, + ctx: &Ctx, + rgba: u32, + attr: u32, + face: font::Face, + ) { + let part = height * STACK_SIZE; + let metrics = font::Metrics::for_face(face); + let wide = |t: &str| metrics.measure(t) * part * w; + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + p[0] + along * cos_r - up * sin_r, + p[1] + along * sin_r + up * cos_r, + ] + }; + let frame = text::frame(part, rotation, w, oblique); + // Where each part's baseline starts, and the bar or slash. + let (upper, lower, line) = match s.kind { + text::StackKind::Diagonal => { + let slant = 0.5 * part; + let wu = wide(&s.upper); + ( + (0.0, height - part), + (wu + slant, 0.0), + Some(((wu, 0.0), (wu + slant, height))), + ) + } + kind => { + let (bar, gap) = (0.5 * height, 0.2 * part); + let centre = kind == text::StackKind::Fraction; + let x = |t: &str| if centre { (width - wide(t)) / 2.0 } else { 0.0 }; + ( + (x(&s.upper), bar + gap), + (x(&s.lower), bar - gap - part), + centre.then_some(((0.0, bar), (width, bar))), + ) + } + }; + for (t, (along, up)) in [(&s.upper, upper), (&s.lower, lower)] { + let q = at(along, up); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(t.clone(), q[0], q[1], frame, l, b, ctx, rgba, attr, face); + } + if let Some(((a0, a1), (b0, b1))) = line { + let (a, b) = (at(a0, a1), at(b0, b1)); + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + } + /// Resolve an MTEXT colour escape against the entity's own colour. fn span_colour(&self, c: text::SpanColor, rgba: u32, attr: u32) -> (u32, u32) { let alpha = (rgba >> 24) as u8; @@ -970,8 +1539,17 @@ impl<'a> Tessellator<'a> { && limits[2] > limits[0] && limits[3] > limits[1]) .then_some(limits), + // Group 72: 0 inches, 1 millimetres, 2 pixels (no physical + // size, taken as millimetres). + mm_per_unit: if l.plot.paper_units == 0 { 25.4 } else { 1.0 }, }); } + // Without LAYOUT objects, the sheet is in the drawing's own system. + let mm_per_unit = if self.doc.header.measurement == 0 { + 25.4 + } else { + 1.0 + }; // Some drawings carry no LAYOUT objects at all (R13 and R14 DWG // saved by AutoCAD before layouts): model space still draws, and so @@ -984,6 +1562,7 @@ impl<'a> Tessellator<'a> { is_model: true, tab_order: 0, limits: None, + mm_per_unit, }); } let paper = self @@ -1002,6 +1581,7 @@ impl<'a> Tessellator<'a> { && limits[2] > limits[0] && limits[3] > limits[1]) .then_some(limits), + mm_per_unit, }); } out.sort_by_key(|l| (!l.is_model, l.tab_order)); @@ -1018,10 +1598,16 @@ impl<'a> Tessellator<'a> { return false; }; let block = layout.block.clone(); + if !layout.is_model { + self.sheet.mm_per_unit = layout.mm_per_unit; + } self.run_block(&block, !layout.is_model); // The sheet, not the contents: see `Scene::set_extents`. if let Some(l) = layout.limits.filter(|_| !layout.is_model) { self.scene.set_extents([l[0], l[1]], [l[2], l[3]]); + if l[2] > l[0] && l[3] > l[1] { + self.sheet.paper = Some(l); + } } true } @@ -1071,6 +1657,7 @@ impl<'a> Tessellator<'a> { if paper { self.scene.recompute_extents(); } + self.scene.focus_on_core(); } fn root_ctx(&self) -> Ctx { @@ -1292,19 +1879,21 @@ impl<'a> Tessellator<'a> { // Colour 7 flips with the background; every other colour, including a // true colour that happens to be white, is drawn as stored. - let (rgba, contrast) = match common.color { - Color::ByLayer => (layer.rgba, layer.contrast), - Color::ByBlock => (ctx.rgba, ctx.contrast), + let (rgb, contrast) = match common.color { + Color::ByLayer => (layer.rgba & 0x00ff_ffff, layer.contrast), + Color::ByBlock => (ctx.rgba & 0x00ff_ffff, ctx.contrast), ref c => { let (r, g, b) = colour_rgb(c, self.palette)?; - let alpha = match common.transparency { - Transparency::Alpha(a) => a, - Transparency::ByBlock => (ctx.rgba >> 24) as u8, - Transparency::ByLayer => (layer.rgba >> 24) as u8, - }; - (pack_rgba(r, g, b, alpha), is_contrast_color(c)) + (pack_rgba(r, g, b, 0), is_contrast_color(c)) } }; + // Transparency resolves on its own, whatever the colour does. + let alpha = match common.transparency { + Transparency::Alpha(a) => a, + Transparency::ByBlock => (ctx.rgba >> 24) as u8, + Transparency::ByLayer => (layer.rgba >> 24) as u8, + }; + let rgba = rgb | u32::from(alpha) << 24; let flags = if contrast { FLAG_CONTRAST } else { 0 }; let lw = match common.lineweight { @@ -1317,6 +1906,11 @@ impl<'a> Tessellator<'a> { } fn entity(&mut self, e: &Entity, outer: &Ctx) { + self.entity_geometry(e, outer); + self.scene.edge_fills(); + } + + fn entity_geometry(&mut self, e: &Entity, outer: &Ctx) { // Advance the draw order for every entity, drawn or not, so the // sequence matches the document's own. self.scene.order = self.scene.order.saturating_add(1); @@ -1361,19 +1955,22 @@ impl<'a> Tessellator<'a> { } EntityKind::Circle(c) => { - let pts = curves::flatten_circle(c.center.x, c.center.y, c.radius); - self.push_transformed(&pts, false, xf, rgba, attr, pattern); + let tau = std::f64::consts::TAU; + self.push_curve( + c.center.x, c.center.y, c.radius, 0.0, tau, xf, rgba, attr, pattern, + ); } EntityKind::Arc(a) => { - let pts = curves::flatten_arc( - a.center.x, - a.center.y, - a.radius, - a.start_angle, - a.end_angle, - ); - self.push_transformed(&pts, false, xf, rgba, attr, pattern); + // DWG arcs always run counter-clockwise from start to end. + let sweep = (a.end_angle - a.start_angle).rem_euclid(std::f64::consts::TAU); + let sweep = if sweep > 0.0 { + sweep + } else { + std::f64::consts::TAU + }; + let (c, r) = (a.center, a.radius); + self.push_curve(c.x, c.y, r, a.start_angle, sweep, xf, rgba, attr, pattern); } EntityKind::Ellipse(el) => { @@ -1390,25 +1987,49 @@ impl<'a> Tessellator<'a> { } EntityKind::LwPolyline(p) => { - let pts: Vec<([f64; 2], f64)> = p + // Group 43, the constant width, stands for every vertex's. + let wide: Vec = p .vertices .iter() - .map(|v| ([v.point.x, v.point.y], v.bulge)) + .map(|v| { + let (w0, w1) = if p.constant_width > 0.0 { + (p.constant_width, p.constant_width) + } else { + (v.start_width, v.end_width) + }; + WideVertex { + p: [v.point.x, v.point.y], + bulge: v.bulge, + start_width: w0, + end_width: w1, + } + }) .collect(); - let closed = p.is_closed(); - let flat = expand_bulges(&pts, closed); - self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + let pattern = pattern.map(|q| (q, p.flags & 128 != 0)); + self.polyline(&wide, p.is_closed(), xf, rgba, attr, pattern); } EntityKind::Polyline(p) if p.kind() == PolylineKind::Polyline2D => { - let pts: Vec<([f64; 2], f64)> = p + // A vertex without widths of its own takes the polyline's. + let wide: Vec = p .vertices .iter() - .map(|v| ([v.location.x, v.location.y], v.bulge)) + .map(|v| { + let (w0, w1) = if v.start_width == 0.0 && v.end_width == 0.0 { + (p.default_start_width, p.default_end_width) + } else { + (v.start_width, v.end_width) + }; + WideVertex { + p: [v.location.x, v.location.y], + bulge: v.bulge, + start_width: w0, + end_width: w1, + } + }) .collect(); - let closed = p.is_closed(); - let flat = expand_bulges(&pts, closed); - self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + let pattern = pattern.map(|q| (q, p.flags & 128 != 0)); + self.polyline(&wide, p.is_closed(), xf, rgba, attr, pattern); } EntityKind::Polyline(p) if p.kind() == PolylineKind::Polyline3D => { @@ -1425,11 +2046,7 @@ impl<'a> Tessellator<'a> { self.push_transformed(&pts, false, xf, rgba, attr, pattern); } - EntityKind::Point(p) => { - // Zero-length stroke; the renderer draws it as a round dot. - let a = xf.apply(p.location.x, p.location.y); - self.scene.push_stroke(a[0], a[1], a[0], a[1], rgba, attr); - } + EntityKind::Point(p) => self.point(p, xf, rgba, attr), EntityKind::Solid(s) | EntityKind::Trace(s) => { // SOLID and TRACE store their corners in Z order, so corners 3 @@ -1453,8 +2070,50 @@ impl<'a> Tessellator<'a> { xf.apply(c[2].x, c[2].y), xf.apply(c[3].x, c[3].y), ]; - // 3DFACE is an outline in a 2D view, not a filled region. - self.push_path(&q, true, rgba, attr, pattern); + // 3DFACE is an outline in a 2D view, not a filled region, + // without the edges its flags hide. + if f.invisible_edges & 0x0F == 0 { + self.push_path(&q, true, rgba, attr, pattern); + } else { + for i in 0..4 { + if f.invisible_edges & (1 << i) == 0 { + self.push_path(&[q[i], q[(i + 1) % 4]], false, rgba, attr, pattern); + } + } + } + } + + // Meshes in plan: their edges. + EntityKind::Mesh(m) => { + let mut seen = HashSet::new(); + for f in &m.faces { + for (k, &a) in f.iter().enumerate() { + let b = f[(k + 1) % f.len()]; + if !seen.insert((a.min(b), a.max(b))) { + continue; + } + let (pa, pb) = (m.vertices[a as usize], m.vertices[b as usize]); + self.push_transformed( + &[[pa.x, pa.y], [pb.x, pb.y]], + false, + xf, + rgba, + attr, + pattern, + ); + } + } + } + EntityKind::Tolerance(t) => self.tolerance(t, ctx, rgba, attr), + EntityKind::Polyline(p) if p.kind() == PolylineKind::PolygonMesh => { + for edge in polygon_mesh_edges(p) { + self.push_transformed(&edge, false, xf, rgba, attr, pattern); + } + } + EntityKind::Polyline(p) if p.kind() == PolylineKind::PolyfaceMesh => { + for edge in polyface_edges(p) { + self.push_transformed(&edge, false, xf, rgba, attr, pattern); + } } EntityKind::MLine(m) => { @@ -1521,6 +2180,12 @@ impl<'a> Tessellator<'a> { EntityKind::Insert(ins) => self.insert(ins, ctx, lid, rgba, contrast, lw, lt), + EntityKind::Dimension(d) + if d.block_name.is_empty() || self.block(&d.block_name).is_none() => + { + self.dimension(d, ctx, rgba, attr); + } + EntityKind::Dimension(d) => { // AutoCAD bakes dimension graphics into an anonymous block. // Render that instead of reimplementing the dimension engine. @@ -1555,49 +2220,23 @@ impl<'a> Tessellator<'a> { EntityKind::Text(t) => self.text(t, ctx, rgba, attr), - EntityKind::Attribute(a) => self.attribute_text(&a.text, ctx, rgba, attr), - - EntityKind::MText(m) => { - let height = if m.height > 0.0 { m.height } else { 1.0 }; - let face = self.style_face(&m.style); - let lines = self.wrap_mtext(&m.text, m.reference_width, height, face); - let spacing = height - * text::MTEXT_LINE_SPACING - * if m.line_spacing_factor > 0.0 { - m.line_spacing_factor - } else { - 1.0 - }; - - // DWG stores the text direction as a vector when it has one. - let rotation = match m.x_direction { - Some(d) if d.x != 0.0 || d.y != 0.0 => d.y.atan2(d.x), - _ => m.rotation, - }; - let (h_align, dy0) = - text::mtext_layout(m.attachment as u8, lines.len(), height, spacing); + // A multiline attribute (2018 on) is an MTEXT. + EntityKind::Attribute(a) => match &a.mtext { + Some(m) => self.mtext(m, ctx, rgba, attr), + None => self.attribute_text(&a.text, ctx, rgba, attr), + }, - let (sin_r, cos_r) = rotation.sin_cos(); - for (i, line) in lines.iter().enumerate() { - if line.spans.is_empty() { - continue; - } - // Lines stack along the text's own down direction. - let dy = dy0 - i as f64 * spacing; - let x = m.insertion.x - dy * sin_r; - let y = m.insertion.y + dy * cos_r; - self.push_mtext_line( - line, height, x, y, rotation, h_align, ctx, rgba, attr, face, - ); - } - } + EntityKind::MText(m) => self.mtext(m, ctx, rgba, attr), // An ordinary ATTDEF is the template for an ATTRIB carried by the // insert, so drawing it too would double the text. A *constant* // attribute has no ATTRIB: the definition is the only copy, and - // AutoCAD draws it. - EntityKind::AttributeDefinition(a) if a.is_constant() => { - self.attribute_text(&a.text, ctx, rgba, attr) + // AutoCAD draws it, unless it is invisible too. + EntityKind::AttributeDefinition(a) if a.is_constant() && !a.is_invisible() => { + match &a.mtext { + Some(m) => self.mtext(m, ctx, rgba, attr), + None => self.attribute_text(&a.text, ctx, rgba, attr), + } } EntityKind::AttributeDefinition(_) => {} @@ -1695,26 +2334,17 @@ impl<'a> Tessellator<'a> { } if l.arrowhead { - // A filled triangle at the first vertex, pointing back - // along the first segment, as long as the dimension - // style's arrow size (DIMASZ times DIMSCALE) and a third - // as wide: AutoCAD's default closed filled arrowhead. + // At the first vertex, pointing back along the first + // segment, the dimension style's arrow size (DIMASZ + // times DIMSCALE) long, in its leader arrow (DIMLDRBLK). let size = self.arrow_size(&l.style); - let d = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]]; - let len = d[0].hypot(d[1]); - if size > 0.0 && len > 1e-9 { - let u = [d[0] / len, d[1] / len]; - let n = [-u[1], u[0]]; - let base = [pts[0][0] + u[0] * size, pts[0][1] + u[1] * size]; - let half = size / 6.0; - self.scene.push_triangle( - xf.apply(pts[0][0], pts[0][1]), - xf.apply(base[0] + n[0] * half, base[1] + n[1] * half), - xf.apply(base[0] - n[0] * half, base[1] - n[1] * half), - rgba, - attr, - ); - } + let block = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&l.style)) + .map_or(Handle::NULL, |s| s.dimldrblk); + self.arrowhead(block, pts[0], pts[1], size, ctx, rgba, attr); } } @@ -1722,8 +2352,13 @@ impl<'a> Tessellator<'a> { // that can be drawn is the frame, which is what AutoCAD shows for // an image it cannot load, and the count so the host can say so. EntityKind::Image(img) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + let file = self + .doc + .image_defs + .iter() + .find(|d| d.handle == img.image_def); + self.warnings + .external(file.map_or("", |d| d.file_name.as_str())); let u = [img.u_vector.x, img.u_vector.y]; let v = [img.v_vector.x, img.v_vector.y]; let o = [img.insertion.x, img.insertion.y]; @@ -1744,8 +2379,13 @@ impl<'a> Tessellator<'a> { } EntityKind::Underlay(u) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + let file = self + .doc + .underlay_defs + .iter() + .find(|d| d.handle == u.definition); + self.warnings + .external(file.map_or("", |d| d.file_name.as_str())); if u.clip_vertices.len() >= 2 { let pts: Vec<[f64; 2]> = u.clip_vertices.iter().map(|p| [p.x, p.y]).collect(); // Two points are the corners of a rectangle, more are a path. @@ -1771,8 +2411,7 @@ impl<'a> Tessellator<'a> { } EntityKind::Ole2Frame(o) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + self.warnings.external(""); let (a, b) = (o.upper_left, o.lower_right); let frame = [[a.x, a.y], [b.x, a.y], [b.x, b.y], [a.x, b.y]]; self.push_transformed(&frame, true, xf, rgba, attr, pattern); @@ -1796,11 +2435,11 @@ impl<'a> Tessellator<'a> { self.warnings.proxy_without_graphics.saturating_add(1); } - // Meshes, SHAPE (a glyph of an SHX file, which cannot be shipped) - // and every other type the model does not hold. - EntityKind::Polyline(_) | EntityKind::Shape(_) | EntityKind::Unknown(_) => { - self.warnings.unknown_entities = self.warnings.unknown_entities.saturating_add(1); - } + // SHAPE (a glyph of an SHX file, which cannot be shipped) and + // every other type the model does not hold. + EntityKind::Polyline(_) => self.warnings.not_drawn("POLYLINE"), + EntityKind::Shape(_) => self.warnings.not_drawn("SHAPE"), + EntityKind::Unknown(u) => self.warnings.not_drawn(&u.type_name), } } @@ -1841,7 +2480,7 @@ impl<'a> Tessellator<'a> { } else { t.alignment_point.unwrap_or(t.insertion) }; - let value = text::decode_special(&t.value); + let value = self.style_text(&t.style, &t.value); // Aligned and Fit stretch the string between two points rather than // anchoring it at one, so they need its width. Aligned scales the @@ -1849,24 +2488,38 @@ impl<'a> Tessellator<'a> { // instead. let stretched = matches!(t.h_align, HAlign::Aligned | HAlign::Fit); let face = self.style_face(&t.style); + let width_factor = if t.width_factor > 0.0 { + t.width_factor + } else { + 1.0 + }; if stretched { if let Some(second) = t.alignment_point { let first = t.insertion; let span = (second.x - first.x).hypot(second.y - first.y); - let natural = font::Metrics::for_face(face).measure(&value) * t.height; + let natural = + font::Metrics::for_face(face).measure(&value) * t.height * width_factor; if span > 1e-9 && natural > 1e-9 { let factor = span / natural; let fit = t.h_align == HAlign::Fit; + let frame = text::generated_frame( + if fit { t.height } else { t.height * factor }, + (second.y - first.y).atan2(second.x - first.x), + if fit { + width_factor * factor + } else { + width_factor + }, + t.oblique, + t.generation, + ); self.push_text_run( value, first.x, first.y, - if fit { t.height } else { t.height * factor }, - (second.y - first.y).atan2(second.x - first.x), - if fit { factor } else { 1.0 }, - t.oblique, + frame, text::HAlign::Left, - v_align_of(t.v_align), + text::VAlign::Baseline, ctx, rgba, attr, @@ -1881,10 +2534,13 @@ impl<'a> Tessellator<'a> { value, anchor.x, anchor.y, - t.height, - t.rotation, - t.width_factor, - t.oblique, + text::generated_frame( + t.height, + t.rotation, + t.width_factor, + t.oblique, + t.generation, + ), h_align_of(t.h_align), v_align_of(t.v_align), ctx, @@ -1905,13 +2561,16 @@ impl<'a> Tessellator<'a> { }; let face = self.style_face(&t.style); self.push_text_run( - text::decode_special(&t.value), + self.style_text(&t.style, &t.value), anchor.x, anchor.y, - t.height, - t.rotation, - t.width_factor, - t.oblique, + text::generated_frame( + t.height, + t.rotation, + t.width_factor, + t.oblique, + t.generation, + ), h_align_of(t.h_align), v_align_of(t.v_align), ctx, @@ -1932,10 +2591,7 @@ impl<'a> Tessellator<'a> { value: String, x: f64, y: f64, - height: f64, - rotation: f64, - width_factor: f64, - oblique: f64, + (u, v): ([f64; 2], [f64; 2]), h_align: text::HAlign, v_align: text::VAlign, ctx: &Ctx, @@ -1946,22 +2602,30 @@ impl<'a> Tessellator<'a> { if value.trim().is_empty() { return; } + // The stroke font has no CJK and few symbols: a run with characters + // it lacks goes to an outline face, which the browser completes from + // the system's fonts where the bundle has none. + let stroke = super::stroke_font::newstroke(); + let face = if face == font::Face::Stroke + && value + .chars() + .any(|c| !c.is_whitespace() && stroke.glyph(c).is_none()) + { + font::Face::default() + } else { + face + }; let p = ctx.xf.apply(x, y); - // Blocks rotate and scale their contents, so the text follows. - let block_rotation = ctx.xf.b.atan2(ctx.xf.a); - let scale = ctx.xf.scale_magnitude(); + // Blocks rotate, scale and mirror their contents, so the text + // follows, its frame with them. + let xf = &ctx.xf; + let linear = |w: [f64; 2]| [xf.a * w[0] + xf.c * w[1], xf.b * w[0] + xf.d * w[1]]; self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); let run = text::TextRun { x: p[0], y: p[1], - height: if height > 0.0 { height * scale } else { scale }, - rotation: rotation + block_rotation, - width_factor: if width_factor > 0.0 { - width_factor - } else { - 1.0 - }, - oblique, + u: linear(u), + v: linear(v), rgba, attr, h_align, @@ -1969,6 +2633,7 @@ impl<'a> Tessellator<'a> { text: value, face, order: self.scene.order, + clip: 0, }; match face { font::Face::Stroke => self.push_stroke_text(&run), @@ -1985,19 +2650,14 @@ impl<'a> Tessellator<'a> { /// order, layer visibility and viewport clipping all come from `attr`. fn push_stroke_text(&mut self, run: &text::TextRun) { let stroke = super::stroke_font::newstroke(); - let height = run.height; - if !(height > 0.0) { + if !(run.height() > 0.0) { return; } - let advance_scale = height * run.width_factor; - let width: f64 = run - .text - .chars() - .map(|c| stroke.advance(c) as f64 * advance_scale) - .sum(); + // Everything is in cap heights until the frame places it. + let width: f64 = run.text.chars().map(|c| stroke.advance(c) as f64).sum(); - // Aligned and Fit stretch between two points the record does not carry, - // so they centre, as the host's path does. + // Aligned and Fit arrive stretched and left-aligned (`text`); here + // they centre, as the host's path does. let dx = match run.h_align { text::HAlign::Center | text::HAlign::Middle @@ -2006,17 +2666,12 @@ impl<'a> Tessellator<'a> { text::HAlign::Right => -width, text::HAlign::Left => 0.0, }; - let dy = match run.v_align { - text::VAlign::Middle => -height / 2.0, - text::VAlign::Top => -height, - text::VAlign::Baseline | text::VAlign::Bottom => 0.0, - }; - - let (sin_r, cos_r) = run.rotation.sin_cos(); - let tan_o = if run.oblique.abs() < std::f64::consts::FRAC_PI_2 { - run.oblique.tan() - } else { - 0.0 + let dy = text::baseline_offset(run.h_align, run.v_align, font::Face::Stroke); + let at = |a: f64, b: f64| { + [ + run.x + a * run.u[0] + b * run.v[0], + run.y + a * run.u[1] + b * run.v[1], + ] }; let mut pen = dx; @@ -2031,22 +2686,142 @@ impl<'a> Tessellator<'a> { for polyline in glyph.polylines() { points.clear(); for p in polyline.points() { - // Glyph units are cap heights, so the text height is - // the only scale there is. - let gx = pen + p[0] as f64 * advance_scale; - let gy = dy + p[1] as f64 * height; - // Oblique leans the vertical, shifting x by height. - let sx = gx + gy * tan_o; - points.push([ - run.x + sx * cos_r - gy * sin_r, - run.y + sx * sin_r + gy * cos_r, - ]); + points.push(at(pen + p[0] as f64, dy + p[1] as f64)); } self.scene.push_polyline(&points, false, run.rgba, run.attr); } } - pen += stroke.advance(c) as f64 * advance_scale; + pen += stroke.advance(c) as f64; + } + } + + /// An LWPOLYLINE or 2D POLYLINE: a line through its bulges, or, where its + /// segments have width, the band they fill (outlined under `$FILLMODE` + /// 0). The band is in object coordinates and transformed corner by + /// corner, so a scaled block widens it too. + /// + /// A dashed one carries whether its linetype runs on across its vertices + /// (group 70 bit 128, PLINEGEN); without it each segment starts its + /// pattern afresh, as AutoCAD draws it. + fn polyline( + &mut self, + verts: &[WideVertex], + closed: bool, + xf: &Affine, + rgba: u32, + attr: u32, + dashes: Option<(&Pattern, bool)>, + ) { + let pattern = dashes.map(|(p, _)| p); + if !verts + .iter() + .any(|v| v.start_width > 0.0 || v.end_width > 0.0) + { + let pts: Vec<([f64; 2], f64)> = verts.iter().map(|v| (v.p, v.bulge)).collect(); + if pattern.is_none() + && pts.iter().any(|p| p.1 != 0.0) + && self.push_bulged(&pts, closed, xf, rgba, attr) + { + return; + } + if let Some((p, false)) = dashes { + let segments = if closed { + pts.len() + } else { + pts.len().saturating_sub(1) + }; + for i in 0..segments { + let piece = expand_bulges(&[pts[i], pts[(i + 1) % pts.len()]], false); + self.push_transformed(&piece, false, xf, rgba, attr, Some(p)); + } + return; + } + let flat = expand_bulges(&pts, closed); + self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + return; + } + let band = wide::band(verts, closed); + for run in &band.thin { + self.push_transformed(run, false, xf, rgba, attr, pattern); + } + if self.doc.header.fillmode { + for [a, b, c] in &band.triangles { + self.scene.push_triangle( + xf.apply(a[0], a[1]), + xf.apply(b[0], b[1]), + xf.apply(c[0], c[1]), + rgba, + attr, + ); + } + } else { + for outline in &band.outlines { + self.push_transformed(outline, true, xf, rgba, attr, None); + } + } + } + + /// A circular arc in object coordinates (centre, radius, start angle, + /// signed sweep): arc strokes, exact at any zoom, when nothing dashes it + /// and the transform keeps circles round; else its flattened points. + #[allow(clippy::too_many_arguments)] + fn push_curve( + &mut self, + cx: f64, + cy: f64, + r: f64, + start: f64, + sweep: f64, + xf: &Affine, + rgba: u32, + attr: u32, + pattern: Option<&Pattern>, + ) { + if pattern.is_none() { + if let Some((scale, turn, mirrored)) = similarity(xf) { + let c = xf.apply(cx, cy); + let (start, sweep) = if mirrored { + (turn - start, -sweep) + } else { + (start + turn, sweep) + }; + self.scene + .push_arc(c[0], c[1], r * scale, start, sweep, rgba, attr); + return; + } + } + let pts = curves::arc_points(cx, cy, r, start, sweep); + self.push_transformed(&pts, false, xf, rgba, attr, pattern); + } + + /// A polyline's straight and bulged segments, the bulged ones as arc + /// strokes, under a transform that keeps circles round; false, drawing + /// nothing, under any other. + fn push_bulged( + &mut self, + pts: &[([f64; 2], f64)], + closed: bool, + xf: &Affine, + rgba: u32, + attr: u32, + ) -> bool { + let Some((_, _, mirrored)) = similarity(xf) else { + return false; + }; + let n = pts.len(); + let segments = if closed { n } else { n.saturating_sub(1) }; + for i in 0..segments { + let ((a, bulge), (b, _)) = (pts[i], pts[(i + 1) % n]); + let (wa, wb) = (xf.apply(a[0], a[1]), xf.apply(b[0], b[1])); + let bulge = if mirrored { -bulge } else { bulge }; + if bulge == 0.0 || !bulge.is_finite() { + self.scene + .push_stroke(wa[0], wa[1], wb[0], wb[1], rgba, attr); + } else if let Some((cx, cy, r, start, sweep)) = curves::bulge_arc(wa, wb, bulge) { + self.scene.push_arc(cx, cy, r, start, sweep, rgba, attr); + } } + true } fn push_transformed( @@ -2089,7 +2864,8 @@ impl<'a> Tessellator<'a> { self.scene.push_polyline(pts, closed, rgba, attr); } - /// Stamp a complex linetype's text along a path it has just dashed. + /// Stamp a complex linetype's text and shapes along a path it has just + /// dashed. fn push_line_labels( &mut self, pts: &[[f64; 2]], @@ -2103,29 +2879,306 @@ impl<'a> Tessellator<'a> { } for place in linetype::dash_labels(pts, closed, pattern) { let label = &pattern.labels[place.label]; - let run = text::TextRun { - x: place.x, - y: place.y, - height: label.height, - rotation: place.angle, - width_factor: 1.0, - oblique: 0.0, + match &label.mark { + linetype::Mark::Text { text, face } => { + let (u, v) = text::frame(label.height, place.angle, 1.0, 0.0); + let run = text::TextRun { + x: place.x, + y: place.y, + u, + v, + rgba, + attr, + h_align: text::HAlign::Left, + v_align: text::VAlign::Baseline, + text: text.clone(), + face: *face, + order: self.scene.order, + clip: 0, + }; + self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); + match face { + font::Face::Stroke => self.push_stroke_text(&run), + font::Face::TrueType(_) => self.scene.push_text(run), + } + } + linetype::Mark::Shape(parts) => { + let (sin, cos) = place.angle.sin_cos(); + let k = label.height; + let at = |p: [f64; 2]| { + [ + place.x + k * (p[0] * cos - p[1] * sin), + place.y + k * (p[0] * sin + p[1] * cos), + ] + }; + for part in *parts { + match part { + linetype::ShapePart::Line(ps) => { + for w in ps.windows(2) { + let (a, b) = (at(w[0]), at(w[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + } + linetype::ShapePart::Arc(c, r, start, sweep) => { + let c = at(*c); + self.scene.push_arc( + c[0], + c[1], + r * k, + start + place.angle, + *sweep, + rgba, + attr, + ); + } + } + } + } + } + } + } + + /// An arrowhead `size` long at `tip`, pointing away from `from`: the + /// arrow block `block` names, drawn from the drawing's block of that + /// name when it has one (AutoCAD saves `_Open` and the like there), else + /// the built-in shape of that name, else the closed filled default. + #[allow(clippy::too_many_arguments)] + fn arrowhead( + &mut self, + block: Handle, + tip: [f64; 2], + from: [f64; 2], + size: f64, + ctx: &Ctx, + rgba: u32, + attr: u32, + ) { + let d = [tip[0] - from[0], tip[1] - from[1]]; + if !(size > 0.0) || !(d[0].hypot(d[1]) > 1e-9) { + return; + } + let angle = d[1].atan2(d[0]); + let place = ctx.xf.mul(&Affine::new(tip[0], tip[1], size, size, angle)); + let name = (!block.is_null()) + .then(|| self.doc.blocks.iter().find(|b| b.record == block)) + .flatten(); + if let Some(b) = name.filter(|b| !b.entities.is_empty()) { + let (lid, lw) = (attr as u16, (attr >> 16) as u8); + let contrast = (attr >> 24) as u8 & FLAG_CONTRAST != 0; + let name = b.name.clone(); + self.expand_block( + &name, + &place, + lid, rgba, - attr, - h_align: text::HAlign::Left, - v_align: text::VAlign::Baseline, - text: label.text.clone(), - face: label.face, - order: self.scene.order, + contrast, + lw, + 0, + ctx.lt_scale, + ctx.depth, + ); + return; + } + let kind = name.map_or(String::new(), |b| b.name.to_ascii_uppercase()); + let shape = arrow_shape(&kind); + let world = |p: &[f64; 2]| place.apply(p[0], p[1]); + for t in shape.filled.chunks(3) { + if let [a, b, c] = t { + self.scene + .push_triangle(world(a), world(b), world(c), rgba, attr); + } + } + for line in shape.lines { + let pts: Vec<[f64; 2]> = line.iter().map(world).collect(); + self.scene.push_polyline(&pts, false, rgba, attr); + } + } + + /// A dimension saved without its block, drawn from its definition points + /// and its style (`dimension.rs`). A style the drawing does not have + /// falls back to the header's variables. + fn dimension(&mut self, d: &crate::cad::Dimension, ctx: &Ctx, rgba: u32, attr: u32) { + let h = &self.doc.header; + let style = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&d.style)) + .cloned() + .unwrap_or_else(|| crate::cad::DimStyle { + dimscale: h.dimscale, + dimasz: h.dimasz, + dimtxt: h.dimtxt, + dimgap: h.dimgap, + ..crate::cad::DimStyle::default() + }); + let text_style = self + .doc + .text_styles + .iter() + .find(|ts| ts.handle == style.dimtxsty && !style.dimtxsty.is_null()) + .map(|ts| ts.name.clone()) + .unwrap_or_default(); + let face = self.style_face(&text_style); + let metrics = font::Metrics::for_face(face); + let s = dimension::Style::new(&style); + let measure = |t: &str| metrics.measure(&text::decode_special(t)); + let Some(g) = dimension::graphics(d, &s, &measure) else { + return; + }; + for [a, b] in &g.lines { + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + for (c, r, start, sweep) in &g.arcs { + self.push_curve(c[0], c[1], *r, *start, *sweep, &ctx.xf, rgba, attr, None); + } + for (tip, from) in &g.arrows { + self.arrowhead(style.dimblk, *tip, *from, s.asz, ctx, rgba, attr); + } + if let Some(label) = g.label { + let content = crate::cad::MText { + insertion: crate::cad::Vec3::new(label.at[0], label.at[1], 0.0), + height: s.txt, + attachment: 5, + style: text_style, + rotation: label.rotation, + text: label.text, + ..crate::cad::MText::default() }; - self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); - match label.face { - font::Face::Stroke => self.push_stroke_text(&run), - font::Face::TrueType(_) => self.scene.push_text(run), + self.mtext(&content, ctx, rgba, attr); + } + } + + /// A TOLERANCE's feature control frame: a row per `^J`, a box per + /// `%%v`, each its text and the style's gap around it, the GD&T codes as + /// the symbols they stand for; hung from the middle of the first row's + /// left side, along its direction. + fn tolerance(&mut self, t: &crate::cad::Tolerance, ctx: &Ctx, rgba: u32, attr: u32) { + let style = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&t.dimstyle)); + let header = &self.doc.header; + let (txt, gap, scale) = match style { + Some(s) => (s.dimtxt, s.dimgap, s.dimscale), + None => (header.dimtxt, header.dimgap, header.dimscale), + }; + let scale = if scale > 0.0 && scale.is_finite() { + scale + } else { + 1.0 + }; + let height = if txt > 0.0 { txt * scale } else { 0.18 }; + let gap = gap.abs() * scale; + let face = style + .and_then(|s| { + self.doc + .text_styles + .iter() + .find(|ts| ts.handle == s.dimtxsty && !s.dimtxsty.is_null()) + }) + .map_or(font::Face::Stroke, |ts| self.style_face(&ts.name)); + let metrics = font::Metrics::for_face(face); + let rotation = t.x_direction.y.atan2(t.x_direction.x); + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |a: f64, b: f64| { + ctx.xf.apply( + t.insertion.x + a * cos_r - b * sin_r, + t.insertion.y + a * sin_r + b * cos_r, + ) + }; + let row_height = height + 2.0 * gap; + for (r, row) in t.text.split("^J").enumerate() { + // The first row's middle is on the insertion point. + let top = row_height / 2.0 - r as f64 * row_height; + let mut left = 0.0; + for cell in row.split("%%v") { + let shown = text::decode_special(&gdt_codes(cell)); + if shown.is_empty() { + continue; + } + let width = metrics.measure(&shown) * height + 2.0 * gap; + let corners = [ + (left, top), + (left + width, top), + (left + width, top - row_height), + (left, top - row_height), + ] + .map(|(a, b)| at(a, b)); + self.scene.push_polyline(&corners, true, rgba, attr); + let (x, y) = ( + t.insertion.x + (left + gap) * cos_r - (top - gap - height) * sin_r, + t.insertion.y + (left + gap) * sin_r + (top - gap - height) * cos_r, + ); + let frame = text::frame(height, rotation, 1.0, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(shown, x, y, frame, l, b, ctx, rgba, attr, face); + left += width; } } } + /// A POINT in the drawing's `$PDMODE`: a dot (0), nothing (1), a plus + /// (2), a cross (3), a tick up (4), and around it a circle (+32) or a + /// square (+64), `$PDSIZE` across: units when above 0, else a share of + /// the drawing's height (5% at 0, -n for n%), as AutoCAD takes the + /// window's. + fn point(&mut self, p: &crate::cad::Point, xf: &Affine, rgba: u32, attr: u32) { + let h = &self.doc.header; + let mode = h.pdmode; + let at = xf.apply(p.location.x, p.location.y); + if mode & 7 == 0 { + // A zero-length stroke, which the renderer draws as a round dot. + self.scene + .push_stroke(at[0], at[1], at[0], at[1], rgba, attr); + } + if mode & !(7 | 32 | 64) != 0 || matches!(mode & 7, 0 | 1) && mode & 96 == 0 { + return; + } + let tall = h.extmax.y - h.extmin.y; + let tall = if tall.is_finite() && tall > 0.0 { + tall + } else { + 20.0 + }; + let size = match h.pdsize { + s if s > 0.0 => s, + s if s < 0.0 => tall * -s / 100.0, + _ => tall * 0.05, + }; + let r = size / 2.0; + let (sin_a, cos_a) = p.x_axis_angle.sin_cos(); + let local = |x: f64, y: f64| { + xf.apply( + p.location.x + x * cos_a - y * sin_a, + p.location.y + x * sin_a + y * cos_a, + ) + }; + let segments: &[[(f64, f64); 2]] = match mode & 7 { + 2 => &[[(-r, 0.0), (r, 0.0)], [(0.0, -r), (0.0, r)]], + 3 => &[[(-r, -r), (r, r)], [(-r, r), (r, -r)]], + 4 => &[[(0.0, 0.0), (0.0, r)]], + _ => &[], + }; + for [(x0, y0), (x1, y1)] in segments { + let (a, b) = (local(*x0, *y0), local(*x1, *y1)); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + if mode & 32 != 0 { + let ring: Vec<[f64; 2]> = curves::flatten_circle(0.0, 0.0, r) + .into_iter() + .map(|q| local(q[0], q[1])) + .collect(); + self.scene.push_polyline(&ring, true, rgba, attr); + } + if mode & 64 != 0 { + let square = [(-r, -r), (r, -r), (r, r), (-r, r)].map(|(x, y)| local(x, y)); + self.scene.push_polyline(&square, true, rgba, attr); + } + } + #[allow(clippy::too_many_arguments)] fn insert( &mut self, @@ -2168,9 +3221,12 @@ impl<'a> Tessellator<'a> { ins.insertion.y + dx * sin_r + dy * cos_r, ]; let child = Affine::new(p[0], p[1], sx, sy, ins.rotation); + let xf = ctx.xf.mul(&child); + let mark = self.scene.mark(); + let infinite = self.pending_infinite.len(); self.expand_block( &ins.block_name, - &ctx.xf.mul(&child), + &xf, lid, rgba, contrast, @@ -2179,6 +3235,22 @@ impl<'a> Tessellator<'a> { ctx.lt_scale, ctx.depth, ); + if let Some(outline) = self.xclip_outline(ins, &xf) { + if let Some(shape) = ClipShape::polygon(outline.clone()) { + // The block's rays and xlines end at the boundary rather + // than at the drawing's extents. + let (min, max) = shape.bounds(); + for line in self.pending_infinite.split_off(infinite) { + self.push_infinite(&line, min, max); + } + self.scene.clip_since(mark, &shape); + } + if self.doc.header.xclipframe != 0 { + let flags = if contrast { FLAG_CONTRAST } else { 0 }; + let attr = pack_attr(lid, lw, flags); + self.push_path(&outline, true, rgba, attr, None); + } + } } // The insert's attributes (title-block fields, room tags) are not in @@ -2203,6 +3275,29 @@ impl<'a> Tessellator<'a> { } } + /// An insert's XCLIP boundary in world coordinates, when clipping is on: + /// it is in block coordinates, so the block's base point comes off as + /// for its entities. Two points are the corners of a rectangle. + fn xclip_outline(&self, ins: &crate::cad::Insert, xf: &Affine) -> Option> { + let clip = ins.clip.as_ref().filter(|c| c.enabled)?; + let base = self.block(&ins.block_name)?.base_point; + let local: Vec<[f64; 2]> = match clip.boundary.as_slice() { + [a, b] => { + let (x0, x1) = (a.x.min(b.x), a.x.max(b.x)); + let (y0, y1) = (a.y.min(b.y), a.y.max(b.y)); + vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]] + } + ps if ps.len() >= 3 => ps.iter().map(|p| [p.x, p.y]).collect(), + _ => return None, + }; + Some( + local + .iter() + .map(|p| xf.apply(p[0] - base.x, p[1] - base.y)) + .collect(), + ) + } + #[allow(clippy::too_many_arguments)] fn expand_block( &mut self, @@ -2235,6 +3330,43 @@ impl<'a> Tessellator<'a> { return; }; + // An xref's content is in another file, which this viewer was not + // given: its path is drawn at the insertion point, as AutoCAD shows + // an xref it cannot find. + if block.is_xref() && block.entities.is_empty() { + let file = if block.xref_path.is_empty() { + &block.name + } else { + &block.xref_path + }; + self.warnings.external(file); + let at = Ctx { + xf: *xf, + layer: lid, + rgba, + contrast, + lw, + lt, + lt_scale, + depth, + }; + let flags = if contrast { FLAG_CONTRAST } else { 0 }; + let face = self.style_face(&self.doc.header.textstyle); + self.push_text_run( + file.clone(), + 0.0, + 0.0, + text::frame(self.doc.header.textsize, 0.0, 1.0, 0.0), + text::HAlign::Left, + text::VAlign::Baseline, + &at, + rgba, + pack_attr(lid, lw, flags), + face, + ); + return; + } + // Block geometry is defined relative to the block's base point. let base = block.base_point; let local = xf.mul(&Affine { @@ -2463,6 +3595,17 @@ impl<'a> Tessellator<'a> { if truncated { self.warnings.hatch_patterns_truncated = self.warnings.hatch_patterns_truncated.saturating_add(1); + // Too dense to draw line by line: the region in the tone so many + // lines average out to, rather than some of them and a gap. + let alpha = ((rgba >> 24) as f64 * TRUNCATED_PATTERN_TONE).round() as u32; + let tone = (rgba & 0x00FF_FFFF) | (alpha << 24); + for t in fill::fill_region(&loops, island_style(h)).chunks(3) { + if t.len() == 3 { + let [a, b, c] = [t[0], t[1], t[2]].map(|p| ctx.xf.apply(p[0], p[1])); + self.scene.push_triangle(a, b, c, tone, attr); + } + } + return; } // Record the pattern's spacing so the renderer can fade these lines @@ -2537,12 +3680,16 @@ fn spline_points(s: &crate::cad::Spline) -> Vec<[f64; 2]> { ) } +/// What "Default" stands for: AutoCAD's `LWDEFAULT`, 0.25 mm unless a user +/// changed it, which is a setting of their AutoCAD, not of the drawing. +const DEFAULT_LINEWEIGHT: u8 = 25; + /// Resolve a lineweight to hundredths of a millimetre, 0 meaning hairline. fn lineweight_value(lw: LineWeight, inherited: u8) -> u8 { match lw { LineWeight::Value(v) if v > 0 => v.min(211) as u8, LineWeight::Value(_) => 0, - LineWeight::Default => 0, + LineWeight::Default => DEFAULT_LINEWEIGHT, LineWeight::ByLayer | LineWeight::ByBlock => inherited, } } @@ -2650,8 +3797,11 @@ mod tests { #[test] fn a_minsert_asking_past_the_budget_is_drawn_up_to_it() { - // 160,000 rings of about seventy strokes: eleven million asked for. - let doc = minsert(400, 400); + // 810,000 cells of three rings of four arc strokes: 9.7 million + // asked for, under the cell cap. + let mut doc = minsert(900, 900); + let ring = doc.blocks[1].entities[0].clone(); + doc.blocks[1].entities.extend([ring.clone(), ring]); let mut t = Tessellator::new(&doc); t.run(); // Stopped between entities: over by one circle at most, and not short. @@ -2715,6 +3865,80 @@ mod tests { t.scene } + fn fill_area(s: &Scene) -> f64 { + s.fills + .chunks(3) + .map(|t| { + ((t[1].x - t[0].x) * (t[2].y - t[0].y) - (t[2].x - t[0].x) * (t[1].y - t[0].y)) + .abs() + / 2.0 + }) + .sum() + } + + /// A polyline with width fills its band, constant width standing for + /// every vertex's; one without stays strokes. + #[test] + fn a_wide_polyline_is_a_filled_band() { + use crate::cad::{LwPolyline, LwVertex, Vec2}; + let line = |w: f64| LwPolyline { + constant_width: w, + vertices: vec![ + LwVertex { + point: Vec2::new(0.0, 0.0), + ..LwVertex::default() + }, + LwVertex { + point: Vec2::new(10.0, 0.0), + ..LwVertex::default() + }, + ], + ..LwPolyline::default() + }; + let wide = tessellated(EntityKind::LwPolyline(line(2.0))); + // Only the band's four edges, which anti-alias it. + assert_eq!(wide.strokes.len(), 4); + assert!(wide + .strokes + .iter() + .all(|s| (s.attr >> 16) as u8 == super::super::scene::FILL_EDGE)); + assert!( + (fill_area(&wide) - 20.0).abs() < 1e-9, + "{}", + fill_area(&wide) + ); + let thin = tessellated(EntityKind::LwPolyline(line(0.0))); + assert_eq!((thin.strokes.len(), thin.fills.len()), (1, 0)); + } + + /// `$FILLMODE` 0 draws the band's edges instead. + #[test] + fn without_fillmode_a_wide_polyline_is_its_outline() { + use crate::cad::{LwPolyline, LwVertex, Vec2}; + let mut doc = CadDrawing::default(); + doc.header.fillmode = false; + let mut t = Tessellator::new(&doc); + t.tessellate_entity(&Entity { + kind: EntityKind::LwPolyline(LwPolyline { + constant_width: 2.0, + vertices: vec![ + LwVertex { + point: Vec2::new(0.0, 0.0), + ..LwVertex::default() + }, + LwVertex { + point: Vec2::new(10.0, 0.0), + ..LwVertex::default() + }, + ], + ..LwPolyline::default() + }), + ..Entity::default() + }); + assert!(t.scene.fills.is_empty()); + assert_eq!(t.scene.strokes.len(), 4); + } + /// TRACE is filled like SOLID, its corners in the same Z order. #[test] fn a_trace_is_filled_like_a_solid() { @@ -2800,7 +4024,7 @@ mod tests { #[test] fn lineweight_resolution() { assert_eq!(lineweight_value(LineWeight::Value(50), 9), 50); - assert_eq!(lineweight_value(LineWeight::Default, 9), 0); + assert_eq!(lineweight_value(LineWeight::Default, 9), DEFAULT_LINEWEIGHT); assert_eq!(lineweight_value(LineWeight::ByLayer, 9), 9); assert_eq!(lineweight_value(LineWeight::ByBlock, 13), 13); // Out-of-range values clamp rather than wrapping through the u8 cast. diff --git a/crates/exav-render/src/formats/dwg/text.rs b/crates/exav-render/src/formats/dwg/text.rs index 26efbbe..d64ae48 100644 --- a/crates/exav-render/src/formats/dwg/text.rs +++ b/crates/exav-render/src/formats/dwg/text.rs @@ -39,10 +39,11 @@ pub enum VAlign { pub struct TextRun { pub x: f64, pub y: f64, - pub height: f64, - pub rotation: f64, - pub width_factor: f64, - pub oblique: f64, + /// One cap height of advance along the baseline, width factor applied. + pub u: [f64; 2], + /// One cap height up the glyphs, oblique applied. With `u`, it carries + /// rotation, mirroring and a block's non-uniform scale. + pub v: [f64; 2], pub rgba: u32, pub attr: u32, pub h_align: HAlign, @@ -52,6 +53,101 @@ pub struct TextRun { pub face: super::font::Face, /// Position in the drawing's draw order. pub order: u32, + /// The clip region the run is drawn through, from 1 (`Scene::clips`), + /// or 0: a run that crosses a viewport's edge or an XCLIP boundary is + /// cut there by the renderer, which knows how wide it is. + pub clip: u16, +} + +impl TextRun { + /// Cap height on the sheet. + pub fn height(&self) -> f64 { + self.v[0].hypot(self.v[1]) + } + + /// The longer of the frame's two units, for bounding what the run covers. + pub fn unit(&self) -> f64 { + self.u[0].hypot(self.u[1]).max(self.height()) + } +} + +/// A run's frame (`TextRun::u`, `TextRun::v`) from a height, a rotation, a +/// width factor and an oblique angle (radians, leaning right). +pub fn frame(height: f64, rotation: f64, width_factor: f64, oblique: f64) -> ([f64; 2], [f64; 2]) { + let (s, c) = rotation.sin_cos(); + let height = if height > 0.0 { height } else { 1.0 }; + let w = if width_factor > 0.0 { + width_factor + } else { + 1.0 + }; + let t = if oblique.abs() < std::f64::consts::FRAC_PI_2 { + oblique.tan() + } else { + 0.0 + }; + ( + [c * height * w, s * height * w], + [(c * t - s) * height, (s * t + c) * height], + ) +} + +/// A TEXT's or ATTRIB's frame, mirrored as its generation flags say: 2 +/// backward (mirrored along the baseline), 4 upside down. A mirror turns the +/// slant the other way too. +pub fn generated_frame( + height: f64, + rotation: f64, + width_factor: f64, + oblique: f64, + generation: i16, +) -> ([f64; 2], [f64; 2]) { + let backward = generation & 2 != 0; + let upside_down = generation & 4 != 0; + let lean = if backward != upside_down { + -oblique + } else { + oblique + }; + let (mut u, mut v) = frame(height, rotation, width_factor, lean); + if backward { + u = [-u[0], -u[1]]; + } + if upside_down { + v = [-v[0], -v[1]]; + } + (u, v) +} + +/// How far below the baseline a face's descenders reach, in cap heights: +/// where Bottom alignment puts the baseline. The outline faces' are their +/// OS/2 descenders over their cap heights; the stroke font stands in for +/// SHX fonts, whose usual one (txt.shx: 2 below 6 above) is a third. +pub fn descent(face: Face) -> f64 { + match face { + Face::Stroke => 1.0 / 3.0, + Face::TrueType(f) => match f.family { + super::font::Family::Sans => 434.0 / 1409.0, + super::font::Family::Serif => 442.0 / 1341.0, + super::font::Family::Mono => 615.0 / 1349.0, + }, + } +} + +/// Where the baseline sits from the anchor, in cap heights up: Bottom puts +/// the descenders on it, Middle centres the capitals, Top their tops, and +/// the Middle horizontal alignment (TEXT's MIDDLE, unlike Middle Center) +/// centres capitals and descenders together. +pub fn baseline_offset(h: HAlign, v: VAlign, face: Face) -> f64 { + if h == HAlign::Middle { + return -(1.0 - descent(face)) / 2.0; + } + match v { + VAlign::Baseline => 0.0, + VAlign::Bottom => descent(face), + VAlign::Middle => -0.5, + VAlign::Top => -1.0, + } } pub const TEXT_RECORD_BYTES: usize = 48; @@ -224,6 +320,24 @@ pub fn decode_special(s: &str) -> String { // %%u, %%o and %%k toggle under/over/strike; we drop the styling. Some('u') | Some('U') | Some('o') | Some('O') | Some('k') | Some('K') => {} Some('%') => out.push('%'), + // %%nnn: the character of that number, three digits. + Some(d) if d.is_ascii_digit() => { + let mut code = d.to_digit(10).unwrap_or(0); + let mut digits = 1; + while digits < 3 { + match chars.peek().and_then(|c| c.to_digit(10)) { + Some(n) => { + code = code * 10 + n; + chars.next(); + digits += 1; + } + None => break, + } + } + if let Some(c) = u8::try_from(code).ok().and_then(windows_1252) { + out.push(c); + } + } Some(other) => { out.push('%'); out.push('%'); @@ -235,11 +349,24 @@ pub fn decode_special(s: &str) -> String { out } -/// Strip MTEXT inline formatting and split into lines. -/// -/// Formatting is dropped rather than honoured: mixed fonts, per-run colour and -/// stacked fractions are a documented gap. Stacked fractions degrade to `a/b` -/// so the content survives even though the layout does not. +/// A Windows-1252 byte as a character; None for a control character or +/// one of the five the code page leaves out. +fn windows_1252(b: u8) -> Option { + const HIGH: [u16; 32] = [ + 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, + 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, + 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178, + ]; + match b { + 0..=0x1F | 0x7F => None, + 0x80..=0x9F => { + char::from_u32(u32::from(HIGH[usize::from(b - 0x80)])).filter(|&c| c != '\0') + } + _ => Some(char::from(b)), + } +} + +/// MTEXT as plain lines: the formatting gone, a stack as `a/b`. pub fn mtext_lines(raw: &str) -> Vec { mtext_spans(raw) .into_iter() @@ -265,13 +392,35 @@ pub enum SpanHeight { /// /// The code is not documented by Autodesk; this follows the arguments the /// format is widely understood to use: `l` a left indent applying to every -/// line, `i` an extra indent on the first line only, and `t` a list of tab -/// stops. `q` justification and `x` line spacing are read and ignored. +/// line, `i` an extra indent on the first line only, `t` a list of tab stops +/// and `q` the justification. `x` line spacing is ignored. #[derive(Clone, Debug, PartialEq, Default)] pub struct Paragraph { pub indent: f64, pub first_indent: f64, pub tabs: Vec, + /// `q`: the lines aligned in the box this way rather than by the + /// attachment point (justified and distributed taken as left). + pub align: Option, +} + +/// A stacked fraction from `\S`. +#[derive(Clone, Debug, PartialEq)] +pub struct Stack { + pub upper: String, + pub lower: String, + pub kind: StackKind, +} + +/// The character between a stack's parts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StackKind { + /// `/`: one above the other across a bar. + Fraction, + /// `#`: side by side across a slash. + Diagonal, + /// `^`: one above the other, no bar, left-aligned (tolerances). + Tolerance, } /// One laid-out line: its runs and the paragraph settings in force. @@ -295,6 +444,14 @@ pub struct Span { pub underline: bool, pub overline: bool, pub strike: bool, + /// `\W`: a width factor in place of the style's. + pub width: Option, + /// `\Q`: an oblique angle (radians) in place of the style's. + pub oblique: Option
{ + let (r, q) = (sub(a1, a0), sub(b1, b0)); + let den = r[0] * q[1] - r[1] * q[0]; + if den.abs() < 1e-12 * len(r) * len(q) || !den.is_finite() { + return None; + } + let t = ((b0[0] - a0[0]) * q[1] - (b0[1] - a0[1]) * q[0]) / den; + Some(add(a0, mul(r, t))) +} + +fn angular(d: &Dimension, s: &Style) -> Option { + use std::f64::consts::{PI, TAU}; + // The two legs, as the rays out of the centre and the feature points + // along each. + let (centre, legs, on) = match d.kind { + DimensionKind::Angular3Point => { + let c = p(d.point15); + ( + c, + [[c, p(d.point13)], [c, p(d.point14)]], + p(d.definition_point), + ) + } + _ => { + let l1 = [p(d.point13), p(d.point14)]; + let l2 = [p(d.point15), p(d.definition_point)]; + ( + crossing(l1[0], l1[1], l2[0], l2[1])?, + [l1, l2], + p(d.point16), + ) + } + }; + let radius = len(sub(on, centre)); + if !(radius > 1e-12) { + return None; + } + let theta = (on[1] - centre[1]).atan2(on[0] - centre[0]); + let (start, sweep) = match d.kind { + DimensionKind::Angular3Point => { + let a1 = angle_of(sub(legs[0][1], centre))?; + let a2 = angle_of(sub(legs[1][1], centre))?; + let ccw = (a2 - a1).rem_euclid(TAU); + if (theta - a1).rem_euclid(TAU) <= ccw { + (a1, ccw) + } else { + (a2, TAU - ccw) + } + } + _ => { + // The sector of the four the two lines make that holds the arc's + // point. + let a1 = angle_of(sub(legs[0][1], legs[0][0]))?; + let a2 = angle_of(sub(legs[1][1], legs[1][0]))?; + let mut rays = [a1, a1 + PI, a2, a2 + PI].map(|a| a.rem_euclid(TAU)); + rays.sort_by(f64::total_cmp); + let t = theta.rem_euclid(TAU); + let i = rays.iter().rposition(|r| *r <= t).unwrap_or(3); + let (lo, hi) = (rays[i], if i == 3 { rays[0] + TAU } else { rays[i + 1] }); + (lo, hi - lo) + } + }; + let mut g = Graphics::default(); + g.arcs.push((centre, radius, start, sweep)); + let e1 = add(centre, mul(polar(start), radius)); + let e2 = add(centre, mul(polar(start + sweep), radius)); + // An extension line from the leg's nearer feature point out to the arc, + // when the arc lies beyond the leg. + for (end, ray) in [(e1, polar(start)), (e2, polar(start + sweep))] { + let leg = legs.iter().find(|l| { + let dir = sub(l[1], l[0]); + (dir[0] * ray[1] - dir[1] * ray[0]).abs() < 1e-9 * len(dir) + }); + if let Some(leg) = leg { + let ts: Vec<(f64, P)> = leg + .iter() + .map(|q| (dot(sub(*q, centre), ray), *q)) + .collect(); + let near = ts + .iter() + .min_by(|a, b| (a.0 - radius).abs().total_cmp(&(b.0 - radius).abs()))?; + let (lo, hi) = (ts[0].0.min(ts[1].0), ts[0].0.max(ts[1].0)); + if radius > hi + 1e-9 || radius < lo - 1e-9 { + extension(&mut g, near.1, end, s); + } + } + } + if s.tsz > 0.0 { + ends(&mut g, e1, e2, s); + } else { + let t1 = perp(polar(start)); + let t2 = perp(polar(start + sweep)); + g.arrows.push((e1, add(e1, t1))); + g.arrows.push((e2, sub(e2, t2))); + } + let mid = start + sweep / 2.0; + let rotation = if d.text_rotation != 0.0 { + d.text_rotation + } else { + readable(mid + PI / 2.0) + }; + let lift = if s.above { s.gap + s.txt / 2.0 } else { 0.0 }; + let degrees = number(sweep.to_degrees(), s.adec, 0, s.dsep) + "%%d"; + g.label = content(d, degrees).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| add(centre, mul(polar(mid), radius + lift))), + text, + rotation, + }); + Some(g) +} + +fn angle_of(v: P) -> Option { + unit(v).map(|u| u[1].atan2(u[0])) +} + +fn ordinate(d: &Dimension, s: &Style) -> Option { + use std::f64::consts::FRAC_PI_2; + let (origin, feature, end) = (p(d.definition_point), p(d.point13), p(d.point14)); + let x_type = d.flags & 64 != 0; + let mut g = Graphics::default(); + extension(&mut g, feature, end, s); + let measured = if x_type { + feature[0] - origin[0] + } else { + feature[1] - origin[1] + }; + let rotation = if x_type { FRAC_PI_2 } else { 0.0 }; + let dir = unit(sub(end, feature)).unwrap_or(polar(rotation)); + g.label = content(d, number(measured.abs() * s.lfac, s.dec, s.zin, s.dsep)).map(|text| Label { + at: stored_text(d).unwrap_or_else(|| add(end, mul(dir, s.gap + s.txt))), + text, + rotation, + }); + Some(g) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_follow_the_style() { + assert_eq!(number(12.5, 4, 0, '.'), "12.5000"); + assert_eq!(number(12.5, 4, 8, '.'), "12.5"); + assert_eq!(number(12.0, 2, 8, ','), "12"); + assert_eq!(number(0.25, 2, 4, ','), ",25"); + assert_eq!(number(-0.0001, 2, 0, '.'), "0.00"); + assert_eq!(number(100.5, 0, 0, '.'), "101"); + assert_eq!(number(2.25, 1, 0, '.'), "2.3"); + } + + #[test] + fn readable_text_never_runs_upside_down() { + use std::f64::consts::PI; + assert!((readable(PI) - 0.0).abs() < 1e-12); + assert!((readable(-PI / 2.0) - PI / 2.0).abs() < 1e-12); + assert!((readable(PI / 2.0) - PI / 2.0).abs() < 1e-12); + assert!((readable(0.3) - 0.3).abs() < 1e-12); + } +} diff --git a/crates/exav-render/src/formats/dwg/font.rs b/crates/exav-render/src/formats/dwg/font.rs index 9b8a8a3..d2a85e2 100644 --- a/crates/exav-render/src/formats/dwg/font.rs +++ b/crates/exav-render/src/formats/dwg/font.rs @@ -409,6 +409,73 @@ fn table_for(f: TrueTypeFace) -> (&'static [u16], &'static [(u32, u16)], f32) { } } +/// A font whose letters stand for other characters. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Symbols { + /// Symbol's Greek and mathematical signs; the SHX Greek fonts' letters. + Symbol, + /// greeks.shx, greekc.shx: Greek letters only. + Greek, + /// gdt.shx: the geometric tolerancing signs on lowercase letters. + Gdt, +} + +/// The symbol set a font file draws, by name. +pub fn symbols_of(file: &str) -> Option { + let lower = file.trim().to_ascii_lowercase(); + let base = lower.rsplit(['/', '\\']).next().unwrap_or(&lower); + let stem = base.rsplit_once('.').map_or(base, |(s, _)| s); + match stem { + "symbol" => Some(Symbols::Symbol), + "greeks" | "greekc" => Some(Symbols::Greek), + "gdt" | "amgdt" => Some(Symbols::Gdt), + _ => None, + } +} + +/// Greek for the Latin letters, in the order Symbol puts them: A Alpha, C +/// Chi, F Phi, J theta symbol, Q Theta, V final sigma, W Omega... +const GREEK_UPPER: [char; 26] = [ + 'Α', 'Î’', 'Χ', 'Δ', 'Ε', 'Φ', 'Γ', 'Η', 'Ι', 'Ï‘', 'Κ', 'Λ', 'Μ', 'Î�', 'Ο', 'Î ', 'Θ', 'Ρ', 'Σ', + 'Τ', 'Î¥', 'Ï‚', 'Ω', 'Ξ', 'Ψ', 'Ζ', +]; +const GREEK_LOWER: [char; 26] = [ + 'α', 'β', 'χ', 'δ', 'ε', 'φ', 'γ', 'η', 'ι', 'Ï•', 'κ', 'λ', 'μ', 'ν', 'ο', 'Ï€', 'θ', 'Ï�', 'σ', + 'Ï„', 'Ï…', 'Ï–', 'ω', 'ξ', 'ψ', 'ζ', +]; +/// gdt.shx's lowercase letters: a angularity, b perpendicularity, c +/// flatness, d surface profile, e circularity, f parallelism, g +/// cylindricity, h circular runout, i symmetry, j position, k line profile, +/// l LMC, m MMC, n diameter, o square, p projected zone, q centre line, r +/// concentricity, s RFS, t total runout, u straightness, v counterbore, w +/// countersink, x depth, y conical taper, z slope. +const GDT: [char; 26] = [ + '∠', '⊥', 'â�¥', '⌓', 'â—‹', '∥', 'âŒ', '↗', '⌯', '⌖', '⌒', 'â“�', 'â“‚', '⌀', 'â–¡', 'â“…', 'â„„', 'â—Ž', 'Ⓢ', + '⌰', 'â�¤', '⌴', '⌵', '↧', '⌲', '⌳', +]; + +/// What a string in a symbol font shows. +pub fn map_symbols(s: &str, set: Symbols) -> String { + let letter = |c: char, table: &[char; 26]| table[(c as u8 - b'a') as usize]; + s.chars() + .map(|c| match (set, c) { + (Symbols::Symbol | Symbols::Greek, 'A'..='Z') => GREEK_UPPER[(c as u8 - b'A') as usize], + (Symbols::Symbol | Symbols::Greek, 'a'..='z') => letter(c, &GREEK_LOWER), + (Symbols::Gdt, 'a'..='z') => letter(c, &GDT), + (Symbols::Symbol, '"') => '∀', + (Symbols::Symbol, '$') => '∃', + (Symbols::Symbol, '\'') => '∋', + (Symbols::Symbol, '*') => '∗', + (Symbols::Symbol, '-') => '−', + (Symbols::Symbol, '@') => '≅', + (Symbols::Symbol, '\\') => '∴', + (Symbols::Symbol, '^') => '⊥', + (Symbols::Symbol, '~') => '∼', + _ => c, + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -420,6 +487,28 @@ mod tests { } } + /// Greek and Cyrillic have their own widths, not the fallback's. + #[test] + fn greek_and_cyrillic_are_measured() { + let m = Metrics::for_face(Face::default()); + assert!(m.advance('Ж') > m.advance('г') * 1.5); + assert!(m.advance('Ω') > m.advance('ι') * 1.5); + assert_eq!(m.advance('Ω'), m.advance('\u{03A9}')); + } + + #[test] + fn symbol_fonts_draw_what_their_letters_stand_for() { + assert_eq!(symbols_of("SYMBOL.TTF"), Some(Symbols::Symbol)); + assert_eq!(symbols_of("fonts\\greeks.shx"), Some(Symbols::Greek)); + assert_eq!(symbols_of("gdt"), Some(Symbols::Gdt)); + assert_eq!(symbols_of("arial.ttf"), None); + assert_eq!(map_symbols("abg D=@", Symbols::Symbol), "αβγ Δ=≅"); + // The SHX Greek fonts change letters only. + assert_eq!(map_symbols("abg D=@", Symbols::Greek), "αβγ Δ=@"); + // GD&T: diameter 0.05, position, MMC; capitals stay. + assert_eq!(map_symbols("n0.05 jm A", Symbols::Gdt), "⌀0.05 ⌖Ⓜ A"); + } + /// The case the corpus is full of: a stock stroke font, stored with no /// extension at all. Testing for ".shx" would miss every one of these. #[test] diff --git a/crates/exav-render/src/formats/dwg/linetype.rs b/crates/exav-render/src/formats/dwg/linetype.rs index 6e61370..14e720f 100644 --- a/crates/exav-render/src/formats/dwg/linetype.rs +++ b/crates/exav-render/src/formats/dwg/linetype.rs @@ -10,27 +10,74 @@ /// segments for something that reads as solid anyway. const MAX_DASHES: usize = 20_000; -/// Text carried by one element of a complex linetype. +/// Text or a shape carried by one element of a complex linetype. /// /// A linetype like GAZ draws its dashes and stamps "GAS" into the gaps at every /// repetition. Without this the line is drawn as plain dashes and the label /// that says what the pipe carries is missing. #[derive(Clone, Debug)] pub struct Label { - /// Index into `runs` of the element that carries the text. + /// Index into `runs` of the element that carries the label, which is + /// placed at that element's end. pub run: usize, - pub text: String, - /// Text height in drawing units. + pub mark: Mark, + /// Text height, or the shape's unit, in drawing units. pub height: f64, - /// Offset from the element's position: along the line, then across it. + /// Offset from the element's end: along the line, then across it. pub offset: [f64; 2], pub rotation: f64, /// Rotation is world-absolute rather than relative to the line. pub absolute_rotation: bool, - /// The face the label's text style resolves to. These are usually stroke - /// fonts, since a linetype that stamps a symbol along a line is drawn from - /// an SHX shape file. - pub face: super::font::Face, +} + +#[derive(Clone, Debug)] +pub enum Mark { + /// `face` is what the label's text style resolves to. + Text { + text: String, + face: super::font::Face, + }, + /// A shape of ltypeshp.shx, as `ltypeshp_shape` draws it. + Shape(&'static [ShapePart]), +} + +/// A piece of a shape, in shape units from its insertion point, the line +/// running along +x. +#[derive(Debug, PartialEq)] +pub enum ShapePart { + Line(&'static [[f64; 2]]), + /// Centre, radius, start angle and signed sweep (radians). + Arc([f64; 2], f64, f64, f64), +} + +/// The shapes of ltypeshp.shx, which AutoCAD's own complex linetypes +/// (TRACKS, ZIGZAG, FENCELINE1 and 2, BATTING) stamp, redrawn here: the file +/// itself cannot be shipped, any more than an SHX font. Others are left out. +pub fn ltypeshp_shape(number: i16) -> Option<&'static [ShapePart]> { + use std::f64::consts::PI; + const TRACK1: &[ShapePart] = &[ShapePart::Line(&[[0.0, 1.0], [0.0, -1.0]])]; + const ZIG: &[ShapePart] = &[ShapePart::Line(&[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]])]; + const BOX: &[ShapePart] = &[ShapePart::Line(&[ + [0.0, -1.0], + [2.0, -1.0], + [2.0, 1.0], + [0.0, 1.0], + [0.0, -1.0], + ])]; + const CIRC1: &[ShapePart] = &[ShapePart::Arc([1.0, 0.0], 1.0, PI, -2.0 * PI)]; + const BAT: &[ShapePart] = &[ + ShapePart::Line(&[[0.0, 0.0], [-1.0, 2.0]]), + ShapePart::Arc([1.0, 2.0], 2.0, PI, -PI), + ShapePart::Line(&[[3.0, 2.0], [2.0, 0.0]]), + ]; + match number { + 130 => Some(TRACK1), + 131 => Some(ZIG), + 132 => Some(BOX), + 133 => Some(CIRC1), + 134 => Some(BAT), + _ => None, + } } /// One placed copy of a label, in drawing coordinates. @@ -215,7 +262,8 @@ pub fn dash_polyline( Some(out) } -/// Where each copy of the pattern's embedded text lands along a polyline. +/// Where each copy of the pattern's embedded text and shapes lands along a +/// polyline: the end of the element carrying it, shifted by its offset. /// /// The phase is the same walk the dashes use, so a label sits in the gap its /// definition puts it in rather than drifting against the dashes. @@ -241,7 +289,7 @@ pub fn dash_labels(pts: &[[f64; 2]], closed: bool, pattern: &Pattern) -> Vec len { continue; } @@ -323,12 +371,14 @@ mod embedded_text { &[10.0, -5.0], &[Label { run: 1, - text: "GAS".to_string(), + mark: Mark::Text { + text: "GAS".to_string(), + face: super::super::font::Face::Stroke, + }, height: 2.54, offset: [-2.54, -1.27], rotation: 0.0, absolute_rotation: false, - face: super::super::font::Face::Stroke, }], scale, ) @@ -361,9 +411,9 @@ mod embedded_text { fn the_offsets_place_it_along_and_across_the_line() { let places = dash_labels(&[[0.0, 0.0], [100.0, 0.0]], false, &gaz(1.0)); let first = &places[0]; - // The dash runs 0..10, so the gap starts at 10 and the label sits - // 2.54 back along it and 1.27 below it. - assert!((first.x - (10.0 - 2.54)).abs() < 1e-6, "x {}", first.x); + // The gap carrying it runs 10..15, and the label sits 2.54 back from + // its end and 1.27 below the line. + assert!((first.x - (15.0 - 2.54)).abs() < 1e-6, "x {}", first.x); assert!((first.y - -1.27).abs() < 1e-6, "y {}", first.y); assert!(first.angle.abs() < 1e-9, "angle {}", first.angle); } @@ -374,7 +424,7 @@ mod embedded_text { assert!((p.labels[0].height - 5.08).abs() < 1e-9); let places = dash_labels(&[[0.0, 0.0], [200.0, 0.0]], false, &p); assert!( - (places[0].x - (20.0 - 5.08)).abs() < 1e-6, + (places[0].x - (30.0 - 5.08)).abs() < 1e-6, "x {}", places[0].x ); @@ -418,12 +468,14 @@ mod tests { assert!(dash_polyline(&pts, false, &p).is_none()); let label = Label { run: 1, - text: "X".to_string(), + mark: Mark::Text { + text: "X".to_string(), + face: super::super::font::Face::Stroke, + }, height: 1.0, offset: [0.0, 0.0], rotation: 0.0, absolute_rotation: false, - face: super::super::font::Face::Stroke, }; let labelled = Pattern::with_labels(&[1.0, -1.0], &[label], 1.0).unwrap(); assert!(dash_labels(&pts, false, &labelled).is_empty()); diff --git a/crates/exav-render/src/formats/dwg/metrics.rs b/crates/exav-render/src/formats/dwg/metrics.rs index 700e5c7..89ad8fc 100644 --- a/crates/exav-render/src/formats/dwg/metrics.rs +++ b/crates/exav-render/src/formats/dwg/metrics.rs @@ -49,13 +49,187 @@ pub static SANS: [u16; 560] = [ 3186, 2278, 2732, 2048, 1430, 2804, 1504, 910, 3632, 3632, 2732, 2958, 2048, 2278, 2502, 2048, 2048, 2378, 1856, 2732, 2958, 2736, 2732, 2278, 2048, 910, 3020, 2278, 2958, 1364, 2732, 2048, ]; -pub static SANS_SPARSE: [(u32, u16); 22] = [ +pub static SANS_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2734), + (0x0387, 1138), + (0x0388, 3212), + (0x0389, 3432), + (0x038a, 1572), + (0x038c, 3172), + (0x038e, 3504), + (0x038f, 3082), + (0x0390, 910), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2256), + (0x0394, 2736), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2732), + (0x039b, 2736), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2662), + (0x039f, 3186), + (0x03a0, 2958), + (0x03a1, 2732), + (0x03a3, 2532), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3268), + (0x03a7, 2732), + (0x03a8, 3422), (0x03a9, 3062), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2368), + (0x03ad, 1826), + (0x03ae, 2278), + (0x03af, 910), + (0x03b0, 2240), + (0x03b1, 2368), + (0x03b2, 2356), + (0x03b3, 2048), + (0x03b4, 2280), + (0x03b5, 1826), + (0x03b6, 1806), + (0x03b7, 2278), + (0x03b8, 2278), + (0x03b9, 910), + (0x03ba, 2048), + (0x03bb, 2048), (0x03bc, 2360), + (0x03bd, 2048), + (0x03be, 1834), + (0x03bf, 2278), + (0x03c0, 2826), + (0x03c1, 2330), + (0x03c2, 1974), + (0x03c3, 2528), + (0x03c4, 1618), + (0x03c5, 2240), + (0x03c6, 2656), + (0x03c7, 2150), + (0x03c8, 2920), + (0x03c9, 3198), + (0x03ca, 910), + (0x03cb, 2240), + (0x03cc, 2278), + (0x03cd, 2240), + (0x03ce, 3198), + (0x03cf, 2732), + (0x03d0, 2356), + (0x03d1, 2240), + (0x03d2, 3164), + (0x03d3, 3924), + (0x03d4, 3164), + (0x03d5, 2294), + (0x03d6, 3198), + (0x0400, 2732), + (0x0401, 2734), + (0x0402, 3542), + (0x0403, 2218), + (0x0404, 2944), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2048), + (0x0409, 4330), + (0x040a, 4138), + (0x040b, 3498), + (0x040c, 2386), + (0x040d, 2944), + (0x040e, 2602), + (0x040f, 2944), + (0x0410, 2732), + (0x0411, 2688), + (0x0412, 2732), + (0x0413, 2218), + (0x0414, 2774), + (0x0415, 2732), + (0x0416, 3782), + (0x0417, 2474), + (0x0418, 2944), + (0x0419, 2944), + (0x041a, 2386), + (0x041b, 2688), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2944), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2602), + (0x0424, 3114), + (0x0425, 2732), + (0x0426, 3030), + (0x0427, 2730), + (0x0428, 3754), + (0x0429, 3840), + (0x042a, 3242), + (0x042b, 3626), + (0x042c, 2688), + (0x042d, 2944), + (0x042e, 4138), + (0x042f, 2958), + (0x0430, 2278), + (0x0431, 2346), + (0x0432, 2176), + (0x0433, 1494), + (0x0434, 2390), + (0x0435, 2278), + (0x0436, 2740), + (0x0437, 1878), + (0x0438, 2288), + (0x0439, 2288), + (0x043a, 1792), + (0x043b, 2390), + (0x043c, 2816), + (0x043d, 2262), + (0x043e, 2278), + (0x043f, 2218), + (0x0440, 2278), + (0x0441, 2048), + (0x0442, 1876), + (0x0443, 2048), + (0x0444, 3370), + (0x0445, 2048), + (0x0446, 2346), + (0x0447, 2134), + (0x0448, 3286), + (0x0449, 3370), + (0x044a, 2560), + (0x044b, 2944), + (0x044c, 2134), + (0x044d, 2090), + (0x044e, 3072), + (0x044f, 2218), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2278), + (0x0453, 1494), + (0x0454, 2090), + (0x0455, 2048), + (0x0456, 910), + (0x0457, 1138), + (0x0458, 910), + (0x0459, 3712), + (0x045a, 3328), + (0x045b, 2278), + (0x045c, 1792), + (0x045d, 2288), + (0x045e, 2048), + (0x045f, 2260), (0x2013, 2278), (0x2014, 4096), (0x2018, 910), @@ -69,6 +243,7 @@ pub static SANS_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -110,13 +285,187 @@ pub static SANS_BOLD: [u16; 560] = [ 3186, 2502, 2732, 2278, 2044, 3414, 2072, 1138, 3906, 3906, 2958, 2958, 2278, 2502, 2502, 2278, 2048, 2642, 1982, 2958, 2958, 2732, 2732, 2278, 2278, 1138, 3174, 2502, 2958, 1594, 2732, 2278, ]; -pub static SANS_BOLD_SPARSE: [(u32, u16); 22] = [ +pub static SANS_BOLD_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1904), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3494), + (0x0389, 3710), + (0x038a, 1940), + (0x038c, 3378), + (0x038e, 3798), + (0x038f, 3432), + (0x0390, 1138), + (0x0391, 2958), + (0x0392, 2958), + (0x0393, 2462), + (0x0394, 2944), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2958), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2636), + (0x039f, 3186), + (0x03a0, 2958), + (0x03a1, 2732), + (0x03a3, 2458), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3362), + (0x03a7, 2732), + (0x03a8, 3314), (0x03a9, 3284), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2518), + (0x03ad, 1848), + (0x03ae, 2502), + (0x03af, 1138), + (0x03b0, 2384), + (0x03b1, 2518), + (0x03b2, 2500), + (0x03b3, 2278), + (0x03b4, 2484), + (0x03b5, 1944), + (0x03b6, 1886), + (0x03b7, 2502), + (0x03b8, 2214), + (0x03b9, 1138), + (0x03ba, 2284), + (0x03bb, 2278), (0x03bc, 2506), + (0x03bd, 2278), + (0x03be, 1824), + (0x03bf, 2502), + (0x03c0, 3136), + (0x03c1, 2534), + (0x03c2, 2130), + (0x03c3, 2802), + (0x03c4, 1828), + (0x03c5, 2384), + (0x03c6, 2930), + (0x03c7, 2358), + (0x03c8, 3086), + (0x03c9, 3460), + (0x03ca, 1138), + (0x03cb, 2384), + (0x03cc, 2502), + (0x03cd, 2384), + (0x03ce, 3460), + (0x03cf, 2958), + (0x03d0, 2500), + (0x03d1, 2384), + (0x03d2, 3154), + (0x03d3, 4100), + (0x03d4, 3154), + (0x03d5, 3070), + (0x03d6, 3460), + (0x0400, 2732), + (0x0401, 2740), + (0x0402, 3626), + (0x0403, 2322), + (0x0404, 2914), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1130), + (0x0408, 2278), + (0x0409, 4480), + (0x040a, 4352), + (0x040b, 3584), + (0x040c, 2500), + (0x040d, 2944), + (0x040e, 2548), + (0x040f, 2944), + (0x0410, 2958), + (0x0411, 2944), + (0x0412, 2958), + (0x0413, 2322), + (0x0414, 2918), + (0x0415, 2732), + (0x0416, 3702), + (0x0417, 2566), + (0x0418, 2944), + (0x0419, 2944), + (0x041a, 2500), + (0x041b, 2874), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2944), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2548), + (0x0424, 3496), + (0x0425, 2732), + (0x0426, 2992), + (0x0427, 2878), + (0x0428, 4116), + (0x0429, 4174), + (0x042a, 3562), + (0x042b, 4010), + (0x042c, 2944), + (0x042d, 2914), + (0x042e, 4224), + (0x042f, 2944), + (0x0430, 2278), + (0x0431, 2530), + (0x0432, 2518), + (0x0433, 1706), + (0x0434, 2600), + (0x0435, 2278), + (0x0436, 2904), + (0x0437, 2036), + (0x0438, 2518), + (0x0439, 2518), + (0x043a, 2050), + (0x043b, 2602), + (0x043c, 3030), + (0x043d, 2474), + (0x043e, 2502), + (0x043f, 2474), + (0x0440, 2502), + (0x0441, 2278), + (0x0442, 2006), + (0x0443, 2278), + (0x0444, 3584), + (0x0445, 2278), + (0x0446, 2518), + (0x0447, 2378), + (0x0448, 3414), + (0x0449, 3456), + (0x044a, 2986), + (0x044b, 3498), + (0x044c, 2518), + (0x044d, 2262), + (0x044e, 3498), + (0x044f, 2390), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2502), + (0x0453, 1706), + (0x0454, 2262), + (0x0455, 2278), + (0x0456, 1138), + (0x0457, 1152), + (0x0458, 1138), + (0x0459, 3968), + (0x045a, 3712), + (0x045b, 2502), + (0x045c, 2050), + (0x045d, 2518), + (0x045e, 2278), + (0x045f, 2474), (0x2013, 2278), (0x2014, 4096), (0x2018, 1138), @@ -130,6 +479,7 @@ pub static SANS_BOLD_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -171,13 +521,187 @@ pub static SANS_ITALIC: [u16; 560] = [ 3186, 2278, 2732, 2048, 1204, 2576, 1424, 910, 3618, 3608, 2732, 2958, 2048, 2278, 2502, 2048, 2048, 2378, 1856, 2732, 2958, 2732, 2732, 2278, 2048, 910, 3092, 2278, 2958, 1364, 2732, 2048, ]; -pub static SANS_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SANS_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2732), + (0x0387, 1138), + (0x0388, 3230), + (0x0389, 3466), + (0x038a, 1592), + (0x038c, 3252), + (0x038e, 3544), + (0x038f, 3174), + (0x0390, 910), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2336), + (0x0394, 2748), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2732), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2656), + (0x039f, 3186), + (0x03a0, 2968), + (0x03a1, 2732), + (0x03a3, 2456), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3428), + (0x03a7, 2732), + (0x03a8, 3402), (0x03a9, 3116), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2336), + (0x03ad, 1800), + (0x03ae, 2274), + (0x03af, 910), + (0x03b0, 2252), + (0x03b1, 2336), + (0x03b2, 2340), + (0x03b3, 2048), + (0x03b4, 2276), + (0x03b5, 1800), + (0x03b6, 1898), + (0x03b7, 2274), + (0x03b8, 2222), + (0x03b9, 910), + (0x03ba, 2048), + (0x03bb, 2016), (0x03bc, 2244), + (0x03bd, 2048), + (0x03be, 1830), + (0x03bf, 2278), + (0x03c0, 2744), + (0x03c1, 2346), + (0x03c2, 1992), + (0x03c3, 2470), + (0x03c4, 1532), + (0x03c5, 2252), + (0x03c6, 2672), + (0x03c7, 2236), + (0x03c8, 2980), + (0x03c9, 3190), + (0x03ca, 910), + (0x03cb, 2252), + (0x03cc, 2278), + (0x03cd, 2252), + (0x03ce, 3190), + (0x03cf, 2732), + (0x03d0, 2068), + (0x03d1, 2252), + (0x03d2, 2932), + (0x03d3, 3612), + (0x03d4, 2932), + (0x03d5, 2278), + (0x03d6, 3190), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3452), + (0x0403, 2228), + (0x0404, 2898), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2048), + (0x0409, 4366), + (0x040a, 4024), + (0x040b, 3458), + (0x040c, 2414), + (0x040d, 2930), + (0x040e, 2618), + (0x040f, 2964), + (0x0410, 2732), + (0x0411, 2666), + (0x0412, 2732), + (0x0413, 2228), + (0x0414, 2884), + (0x0415, 2732), + (0x0416, 3756), + (0x0417, 2514), + (0x0418, 2930), + (0x0419, 2930), + (0x041a, 2414), + (0x041b, 2808), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2968), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2618), + (0x0424, 3256), + (0x0425, 2732), + (0x0426, 2978), + (0x0427, 2758), + (0x0428, 3770), + (0x0429, 3780), + (0x042a, 3298), + (0x042b, 3630), + (0x042c, 2666), + (0x042d, 2842), + (0x042e, 4188), + (0x042f, 2792), + (0x0430, 2278), + (0x0431, 2304), + (0x0432, 2138), + (0x0433, 2020), + (0x0434, 2264), + (0x0435, 2278), + (0x0436, 2818), + (0x0437, 1904), + (0x0438, 2278), + (0x0439, 2278), + (0x043a, 1934), + (0x043b, 2310), + (0x043c, 2808), + (0x043d, 2254), + (0x043e, 2278), + (0x043f, 2254), + (0x0440, 2278), + (0x0441, 2048), + (0x0442, 3412), + (0x0443, 2048), + (0x0444, 3422), + (0x0445, 2048), + (0x0446, 2342), + (0x0447, 2120), + (0x0448, 3400), + (0x0449, 3486), + (0x044a, 2544), + (0x044b, 3014), + (0x044c, 2154), + (0x044d, 2014), + (0x044e, 3080), + (0x044f, 2188), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2278), + (0x0453, 1548), + (0x0454, 2030), + (0x0455, 2048), + (0x0456, 910), + (0x0457, 910), + (0x0458, 910), + (0x0459, 3728), + (0x045a, 3390), + (0x045b, 2278), + (0x045c, 1934), + (0x045d, 2278), + (0x045e, 2048), + (0x045f, 2278), (0x2013, 2278), (0x2014, 4096), (0x2018, 910), @@ -191,6 +715,7 @@ pub static SANS_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -232,13 +757,187 @@ pub static SANS_BOLD_ITALIC: [u16; 560] = [ 3186, 2502, 2732, 2278, 1852, 3216, 2086, 1138, 3906, 3898, 2958, 2958, 2278, 2502, 2502, 2278, 2048, 2642, 1982, 2958, 2958, 2732, 2732, 2278, 2278, 1138, 3198, 2502, 2958, 1594, 2732, 2278, ]; -pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1364), (0x00b3, 1364), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3498), + (0x0389, 3710), + (0x038a, 1938), + (0x038c, 3458), + (0x038e, 3810), + (0x038f, 3470), + (0x0390, 1138), + (0x0391, 2958), + (0x0392, 2958), + (0x0393, 2498), + (0x0394, 2750), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 3186), + (0x0399, 1138), + (0x039a, 2958), + (0x039b, 2732), + (0x039c, 3412), + (0x039d, 2958), + (0x039e, 2692), + (0x039f, 3186), + (0x03a0, 2940), + (0x03a1, 2732), + (0x03a3, 2418), + (0x03a4, 2502), + (0x03a5, 2732), + (0x03a6, 3368), + (0x03a7, 2732), + (0x03a8, 3394), (0x03a9, 3198), + (0x03aa, 1138), + (0x03ab, 2732), + (0x03ac, 2540), + (0x03ad, 1960), + (0x03ae, 2502), + (0x03af, 1138), + (0x03b0, 2422), + (0x03b1, 2540), + (0x03b2, 2542), + (0x03b3, 2278), + (0x03b4, 2500), + (0x03b5, 1960), + (0x03b6, 2014), + (0x03b7, 2502), + (0x03b8, 2284), + (0x03b9, 1138), + (0x03ba, 2318), + (0x03bb, 2278), (0x03bc, 2470), + (0x03bd, 2278), + (0x03be, 1842), + (0x03bf, 2502), + (0x03c0, 2916), + (0x03c1, 2480), + (0x03c2, 2180), + (0x03c3, 2718), + (0x03c4, 1674), + (0x03c5, 2422), + (0x03c6, 2882), + (0x03c7, 2366), + (0x03c8, 3166), + (0x03c9, 3418), + (0x03ca, 1138), + (0x03cb, 2422), + (0x03cc, 2502), + (0x03cd, 2422), + (0x03ce, 3418), + (0x03cf, 2958), + (0x03d0, 2310), + (0x03d1, 2422), + (0x03d2, 3066), + (0x03d3, 4066), + (0x03d4, 3066), + (0x03d5, 3102), + (0x03d6, 3418), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3628), + (0x0403, 2514), + (0x0404, 2944), + (0x0405, 2732), + (0x0406, 1138), + (0x0407, 1138), + (0x0408, 2278), + (0x0409, 4482), + (0x040a, 4268), + (0x040b, 3496), + (0x040c, 2518), + (0x040d, 2946), + (0x040e, 2774), + (0x040f, 2946), + (0x0410, 2958), + (0x0411, 2900), + (0x0412, 2958), + (0x0413, 2514), + (0x0414, 2958), + (0x0415, 2732), + (0x0416, 3798), + (0x0417, 2632), + (0x0418, 2946), + (0x0419, 2946), + (0x041a, 2518), + (0x041b, 2814), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 3186), + (0x041f, 2946), + (0x0420, 2732), + (0x0421, 2958), + (0x0422, 2502), + (0x0423, 2774), + (0x0424, 3198), + (0x0425, 2732), + (0x0426, 2986), + (0x0427, 2900), + (0x0428, 4010), + (0x0429, 4050), + (0x042a, 3498), + (0x042b, 4096), + (0x042c, 2900), + (0x042d, 2944), + (0x042e, 4268), + (0x042f, 2986), + (0x0430, 2278), + (0x0431, 2536), + (0x0432, 2474), + (0x0433, 2186), + (0x0434, 2532), + (0x0435, 2278), + (0x0436, 3016), + (0x0437, 2090), + (0x0438, 2502), + (0x0439, 2502), + (0x043a, 2076), + (0x043b, 2548), + (0x043c, 3030), + (0x043d, 2474), + (0x043e, 2502), + (0x043f, 2502), + (0x0440, 2502), + (0x0441, 2278), + (0x0442, 3642), + (0x0443, 2278), + (0x0444, 3624), + (0x0445, 2278), + (0x0446, 2646), + (0x0447, 2388), + (0x0448, 3642), + (0x0449, 3830), + (0x044a, 2896), + (0x044b, 3498), + (0x044c, 2432), + (0x044d, 2260), + (0x044e, 3542), + (0x044f, 2412), + (0x0450, 2278), + (0x0451, 2278), + (0x0452, 2502), + (0x0453, 1922), + (0x0454, 2306), + (0x0455, 2278), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 3970), + (0x045a, 3710), + (0x045b, 2502), + (0x045c, 2076), + (0x045d, 2502), + (0x045e, 2278), + (0x045f, 2502), (0x2013, 2278), (0x2014, 4096), (0x2018, 1138), @@ -252,6 +951,7 @@ pub static SANS_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2392), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -293,13 +993,187 @@ pub static SERIF: [u16; 560] = [ 2958, 2048, 2958, 2048, 1138, 2048, 1298, 1138, 3170, 3164, 2958, 2732, 2048, 2502, 2502, 1594, 1818, 2186, 1628, 2732, 2958, 2970, 2502, 1818, 1594, 1138, 2876, 2048, 2732, 1364, 2958, 2048, ]; -pub static SERIF_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1138), + (0x0388, 2842), + (0x0389, 3310), + (0x038a, 1684), + (0x038c, 2958), + (0x038e, 3342), + (0x038f, 3044), + (0x0390, 1102), + (0x0391, 2958), + (0x0392, 2732), + (0x0393, 2368), + (0x0394, 2634), + (0x0395, 2502), + (0x0396, 2502), + (0x0397, 2958), + (0x0398, 2958), + (0x0399, 1364), + (0x039a, 2958), + (0x039b, 2970), + (0x039c, 3642), + (0x039d, 2958), + (0x039e, 2634), + (0x039f, 2958), + (0x03a0, 2958), + (0x03a1, 2278), + (0x03a3, 2384), + (0x03a4, 2502), + (0x03a5, 2958), + (0x03a6, 2994), + (0x03a7, 2958), + (0x03a8, 3022), (0x03a9, 3044), + (0x03aa, 1364), + (0x03ab, 2958), + (0x03ac, 2146), + (0x03ad, 1720), + (0x03ae, 2142), + (0x03af, 1102), + (0x03b0, 2028), + (0x03b1, 2146), + (0x03b2, 2084), + (0x03b3, 1810), + (0x03b4, 1930), + (0x03b5, 1720), + (0x03b6, 1696), + (0x03b7, 2142), + (0x03b8, 1962), + (0x03b9, 1102), + (0x03ba, 2064), + (0x03bb, 1986), (0x03bc, 2196), + (0x03bd, 1852), + (0x03be, 1826), + (0x03bf, 2048), + (0x03c0, 2068), + (0x03c1, 2044), + (0x03c2, 1622), + (0x03c3, 2208), + (0x03c4, 1646), + (0x03c5, 2028), + (0x03c6, 2364), + (0x03c7, 1818), + (0x03c8, 2564), + (0x03c9, 2696), + (0x03ca, 1102), + (0x03cb, 2028), + (0x03cc, 2048), + (0x03cd, 2028), + (0x03ce, 2696), + (0x03cf, 2958), + (0x03d0, 2084), + (0x03d1, 2028), + (0x03d2, 2958), + (0x03d3, 3646), + (0x03d4, 2958), + (0x03d5, 2156), + (0x03d6, 2696), + (0x0400, 2502), + (0x0401, 2502), + (0x0402, 3080), + (0x0403, 2368), + (0x0404, 2704), + (0x0405, 2278), + (0x0406, 1364), + (0x0407, 1364), + (0x0408, 1594), + (0x0409, 3572), + (0x040a, 3572), + (0x040b, 3036), + (0x040c, 2732), + (0x040d, 2958), + (0x040e, 2900), + (0x040f, 2958), + (0x0410, 2958), + (0x0411, 2352), + (0x0412, 2732), + (0x0413, 2368), + (0x0414, 2794), + (0x0415, 2502), + (0x0416, 3670), + (0x0417, 2052), + (0x0418, 2958), + (0x0419, 2958), + (0x041a, 2732), + (0x041b, 2778), + (0x041c, 3642), + (0x041d, 2958), + (0x041e, 2958), + (0x041f, 2958), + (0x0420, 2278), + (0x0421, 2732), + (0x0422, 2502), + (0x0423, 2900), + (0x0424, 3236), + (0x0425, 2958), + (0x0426, 2958), + (0x0427, 2662), + (0x0428, 4132), + (0x0429, 4132), + (0x042a, 2892), + (0x042b, 3572), + (0x042c, 2352), + (0x042d, 2704), + (0x042e, 4210), + (0x042f, 2732), + (0x0430, 1818), + (0x0431, 2084), + (0x0432, 1934), + (0x0433, 1680), + (0x0434, 2084), + (0x0435, 1818), + (0x0436, 2830), + (0x0437, 1618), + (0x0438, 2192), + (0x0439, 2192), + (0x043a, 1990), + (0x043b, 2044), + (0x043c, 2592), + (0x043d, 2192), + (0x043e, 2048), + (0x043f, 2192), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 1790), + (0x0443, 2048), + (0x0444, 2654), + (0x0445, 2048), + (0x0446, 2192), + (0x0447, 2060), + (0x0448, 3154), + (0x0449, 3154), + (0x044a, 2118), + (0x044b, 2752), + (0x044c, 1868), + (0x044d, 1758), + (0x044e, 3060), + (0x044f, 1884), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 1978), + (0x0453, 1680), + (0x0454, 1758), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2978), + (0x045a, 2962), + (0x045b, 2048), + (0x045c, 1990), + (0x045d, 2192), + (0x045e, 2048), + (0x045f, 2192), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -313,6 +1187,7 @@ pub static SERIF_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2310), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -354,13 +1229,187 @@ pub static SERIF_BOLD: [u16; 560] = [ 3186, 2048, 2958, 2048, 1138, 2278, 1478, 1364, 3370, 3370, 2958, 2958, 2048, 2732, 2732, 1594, 1818, 2180, 1908, 2732, 2958, 2936, 2732, 1818, 2048, 1364, 3378, 2278, 2958, 1818, 2958, 2048, ]; -pub static SERIF_BOLD_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_BOLD_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2958), + (0x0387, 1364), + (0x0388, 3272), + (0x0389, 3724), + (0x038a, 2138), + (0x038c, 3186), + (0x038e, 3542), + (0x038f, 3280), + (0x0390, 1270), + (0x0391, 2958), + (0x0392, 2732), + (0x0393, 2606), + (0x0394, 2568), + (0x0395, 2732), + (0x0396, 2732), + (0x0397, 3186), + (0x0398, 3186), + (0x0399, 1594), + (0x039a, 3186), + (0x039b, 2936), + (0x039c, 3866), + (0x039d, 2958), + (0x039e, 2774), + (0x039f, 3186), + (0x03a0, 3186), + (0x03a1, 2502), + (0x03a3, 2678), + (0x03a4, 2732), + (0x03a5, 2958), + (0x03a6, 3396), + (0x03a7, 2958), + (0x03a8, 3194), (0x03a9, 3280), + (0x03aa, 1594), + (0x03ab, 2958), + (0x03ac, 2286), + (0x03ad, 1750), + (0x03ae, 2326), + (0x03af, 1270), + (0x03b0, 2126), + (0x03b1, 2286), + (0x03b2, 2158), + (0x03b3, 1916), + (0x03b4, 2126), + (0x03b5, 1750), + (0x03b6, 1692), + (0x03b7, 2326), + (0x03b8, 2138), + (0x03b9, 1270), + (0x03ba, 2274), + (0x03bb, 2012), (0x03bc, 2322), + (0x03bd, 1830), + (0x03be, 1826), + (0x03bf, 2048), + (0x03c0, 2244), + (0x03c1, 2192), + (0x03c2, 1728), + (0x03c3, 2228), + (0x03c4, 1888), + (0x03c5, 2126), + (0x03c6, 2556), + (0x03c7, 1892), + (0x03c8, 2818), + (0x03c9, 2994), + (0x03ca, 1270), + (0x03cb, 2126), + (0x03cc, 2048), + (0x03cd, 2126), + (0x03ce, 2994), + (0x03cf, 3186), + (0x03d0, 2158), + (0x03d1, 2454), + (0x03d2, 2958), + (0x03d3, 3782), + (0x03d4, 2958), + (0x03d5, 2556), + (0x03d6, 2994), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3276), + (0x0403, 2606), + (0x0404, 2778), + (0x0405, 2278), + (0x0406, 1594), + (0x0407, 1594), + (0x0408, 2048), + (0x0409, 4108), + (0x040a, 4104), + (0x040b, 3276), + (0x040c, 2970), + (0x040d, 3186), + (0x040e, 3006), + (0x040f, 3186), + (0x0410, 2958), + (0x0411, 2708), + (0x0412, 2732), + (0x0413, 2606), + (0x0414, 2818), + (0x0415, 2732), + (0x0416, 4050), + (0x0417, 2162), + (0x0418, 3186), + (0x0419, 3186), + (0x041a, 2970), + (0x041b, 3052), + (0x041c, 3866), + (0x041d, 3186), + (0x041e, 3186), + (0x041f, 3186), + (0x0420, 2502), + (0x0421, 2958), + (0x0422, 2732), + (0x0423, 3006), + (0x0424, 3518), + (0x0425, 2958), + (0x0426, 3186), + (0x0427, 3006), + (0x0428, 4498), + (0x0429, 4498), + (0x042a, 3134), + (0x042b, 4022), + (0x042c, 2708), + (0x042d, 2778), + (0x042e, 4612), + (0x042f, 2958), + (0x0430, 2048), + (0x0431, 2048), + (0x0432, 2212), + (0x0433, 1860), + (0x0434, 2072), + (0x0435, 1818), + (0x0436, 2970), + (0x0437, 1646), + (0x0438, 2360), + (0x0439, 2360), + (0x043a, 2360), + (0x043b, 2298), + (0x043c, 2790), + (0x043d, 2360), + (0x043e, 2048), + (0x043f, 2360), + (0x0440, 2278), + (0x0441, 1818), + (0x0442, 2012), + (0x0443, 2048), + (0x0444, 2834), + (0x0445, 2048), + (0x0446, 2360), + (0x0447, 2310), + (0x0448, 3458), + (0x0449, 3458), + (0x044a, 2388), + (0x044b, 3198), + (0x044c, 2166), + (0x044d, 1770), + (0x044e, 3130), + (0x044f, 2216), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 2200), + (0x0453, 1860), + (0x0454, 1770), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1364), + (0x0459, 3194), + (0x045a, 3264), + (0x045b, 2278), + (0x045c, 2360), + (0x045d, 2360), + (0x045e, 2048), + (0x045f, 2360), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -374,6 +1423,7 @@ pub static SERIF_BOLD_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2334), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -415,13 +1465,187 @@ pub static SERIF_ITALIC: [u16; 560] = [ 2958, 2048, 2278, 1818, 1138, 2048, 1138, 1138, 3082, 3088, 2502, 2732, 2048, 2278, 2278, 1594, 1594, 1994, 1786, 2502, 2958, 2474, 2502, 1818, 1818, 1138, 2900, 2048, 2502, 1594, 2278, 1818, ]; -pub static SERIF_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2502), + (0x0387, 1364), + (0x0388, 2782), + (0x0389, 3244), + (0x038a, 1646), + (0x038c, 2958), + (0x038e, 2704), + (0x038f, 2974), + (0x0390, 1138), + (0x0391, 2502), + (0x0392, 2502), + (0x0393, 2330), + (0x0394, 2404), + (0x0395, 2502), + (0x0396, 2278), + (0x0397, 2958), + (0x0398, 2958), + (0x0399, 1364), + (0x039a, 2732), + (0x039b, 2474), + (0x039c, 3412), + (0x039d, 2732), + (0x039e, 2618), + (0x039f, 2958), + (0x03a0, 2958), + (0x03a1, 2502), + (0x03a3, 2434), + (0x03a4, 2278), + (0x03a5, 2278), + (0x03a6, 3118), + (0x03a7, 2502), + (0x03a8, 2880), (0x03a9, 2962), + (0x03aa, 1364), + (0x03ab, 2278), + (0x03ac, 2150), + (0x03ad, 1614), + (0x03ae, 2032), + (0x03af, 1138), + (0x03b0, 1880), + (0x03b1, 2150), + (0x03b2, 2040), + (0x03b3, 1614), + (0x03b4, 1904), + (0x03b5, 1614), + (0x03b6, 1658), + (0x03b7, 2032), + (0x03b8, 2012), + (0x03b9, 1138), + (0x03ba, 1966), + (0x03bb, 1774), (0x03bc, 2056), + (0x03bd, 1818), + (0x03be, 1754), + (0x03bf, 2048), + (0x03c0, 2052), + (0x03c1, 1966), + (0x03c2, 1662), + (0x03c3, 2020), + (0x03c4, 1466), + (0x03c5, 1880), + (0x03c6, 2266), + (0x03c7, 1802), + (0x03c8, 2544), + (0x03c9, 2892), + (0x03ca, 1138), + (0x03cb, 1880), + (0x03cc, 2048), + (0x03cd, 1880), + (0x03ce, 2858), + (0x03cf, 2732), + (0x03d0, 2040), + (0x03d1, 2010), + (0x03d2, 2606), + (0x03d3, 3108), + (0x03d4, 2606), + (0x03d5, 2120), + (0x03d6, 2892), + (0x0400, 2502), + (0x0401, 2502), + (0x0402, 3264), + (0x0403, 2330), + (0x0404, 2740), + (0x0405, 2048), + (0x0406, 1364), + (0x0407, 1364), + (0x0408, 1818), + (0x0409, 3798), + (0x040a, 3772), + (0x040b, 3272), + (0x040c, 2720), + (0x040d, 2958), + (0x040e, 2756), + (0x040f, 2958), + (0x0410, 2502), + (0x0411, 2416), + (0x0412, 2502), + (0x0413, 2330), + (0x0414, 2606), + (0x0415, 2502), + (0x0416, 3752), + (0x0417, 2028), + (0x0418, 2958), + (0x0419, 2958), + (0x041a, 2720), + (0x041b, 2774), + (0x041c, 3412), + (0x041d, 2958), + (0x041e, 2958), + (0x041f, 2958), + (0x0420, 2502), + (0x0421, 2732), + (0x0422, 2278), + (0x0423, 2756), + (0x0424, 3294), + (0x0425, 2502), + (0x0426, 2958), + (0x0427, 2822), + (0x0428, 4272), + (0x0429, 4272), + (0x042a, 2768), + (0x042b, 3572), + (0x042c, 2416), + (0x042d, 2682), + (0x042e, 4228), + (0x042f, 2692), + (0x0430, 2048), + (0x0431, 2060), + (0x0432, 1778), + (0x0433, 1598), + (0x0434, 2072), + (0x0435, 1818), + (0x0436, 3810), + (0x0437, 1598), + (0x0438, 2048), + (0x0439, 2048), + (0x043a, 1916), + (0x043b, 1786), + (0x043c, 2614), + (0x043d, 2032), + (0x043e, 2048), + (0x043f, 2048), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 2958), + (0x0443, 1818), + (0x0444, 2908), + (0x0445, 1818), + (0x0446, 2048), + (0x0447, 1954), + (0x0448, 3092), + (0x0449, 3092), + (0x044a, 2126), + (0x044b, 2774), + (0x044c, 1864), + (0x044d, 1806), + (0x044e, 2888), + (0x044f, 1904), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 1962), + (0x0453, 1544), + (0x0454, 1786), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2782), + (0x045a, 2916), + (0x045b, 2048), + (0x045c, 1916), + (0x045d, 2048), + (0x045e, 1818), + (0x045f, 2048), (0x2013, 2048), (0x2014, 3642), (0x2018, 1364), @@ -435,6 +1659,7 @@ pub static SERIF_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2764), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -476,13 +1701,187 @@ pub static SERIF_BOLD_ITALIC: [u16; 560] = [ 2958, 2048, 2502, 1818, 1138, 2278, 1138, 1138, 2998, 3010, 2732, 2732, 2048, 2502, 2502, 1594, 1594, 2084, 1908, 2732, 2958, 2732, 2732, 1818, 2048, 1138, 3214, 2048, 2732, 1594, 2502, 1818, ]; -pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ +pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 197] = [ (0x00b0, 1638), (0x00b1, 2248), (0x00b2, 1228), (0x00b3, 1228), + (0x0384, 1364), + (0x0385, 1364), + (0x0386, 2732), + (0x0387, 1364), + (0x0388, 3182), + (0x0389, 3666), + (0x038a, 2072), + (0x038c, 2994), + (0x038e, 3072), + (0x038f, 3216), + (0x0390, 1138), + (0x0391, 2732), + (0x0392, 2732), + (0x0393, 2474), + (0x0394, 2568), + (0x0395, 2732), + (0x0396, 2502), + (0x0397, 3186), + (0x0398, 3126), + (0x0399, 1594), + (0x039a, 2732), + (0x039b, 2732), + (0x039c, 3642), + (0x039d, 2958), + (0x039e, 2700), + (0x039f, 2958), + (0x03a0, 3186), + (0x03a1, 2502), + (0x03a3, 2536), + (0x03a4, 2502), + (0x03a5, 2502), + (0x03a6, 3232), + (0x03a7, 2732), + (0x03a8, 3276), (0x03a9, 3056), + (0x03aa, 1594), + (0x03ab, 2502), + (0x03ac, 2266), + (0x03ad, 1680), + (0x03ae, 2248), + (0x03af, 1138), + (0x03b0, 2006), + (0x03b1, 2266), + (0x03b2, 2098), + (0x03b3, 1704), + (0x03b4, 2098), + (0x03b5, 1680), + (0x03b6, 1806), + (0x03b7, 2248), + (0x03b8, 2126), + (0x03b9, 1138), + (0x03ba, 2170), + (0x03bb, 1818), (0x03bc, 2278), + (0x03bd, 1818), + (0x03be, 1840), + (0x03bf, 2048), + (0x03c0, 2236), + (0x03c1, 2110), + (0x03c2, 1826), + (0x03c3, 2208), + (0x03c4, 1818), + (0x03c5, 2006), + (0x03c6, 2396), + (0x03c7, 1904), + (0x03c8, 2736), + (0x03c9, 2962), + (0x03ca, 1138), + (0x03cb, 2006), + (0x03cc, 2048), + (0x03cd, 2006), + (0x03ce, 2962), + (0x03cf, 2732), + (0x03d0, 2098), + (0x03d1, 2160), + (0x03d2, 2968), + (0x03d3, 3444), + (0x03d4, 2968), + (0x03d5, 2370), + (0x03d6, 3098), + (0x0400, 2732), + (0x0401, 2732), + (0x0402, 3350), + (0x0403, 2474), + (0x0404, 2692), + (0x0405, 2278), + (0x0406, 1594), + (0x0407, 1594), + (0x0408, 2048), + (0x0409, 3720), + (0x040a, 3998), + (0x040b, 3322), + (0x040c, 2778), + (0x040d, 3186), + (0x040e, 2850), + (0x040f, 3186), + (0x0410, 2732), + (0x0411, 2602), + (0x0412, 2732), + (0x0413, 2474), + (0x0414, 2798), + (0x0415, 2732), + (0x0416, 3760), + (0x0417, 2084), + (0x0418, 3186), + (0x0419, 3186), + (0x041a, 2778), + (0x041b, 2810), + (0x041c, 3642), + (0x041d, 3186), + (0x041e, 2958), + (0x041f, 3186), + (0x0420, 2502), + (0x0421, 2732), + (0x0422, 2502), + (0x0423, 2850), + (0x0424, 3482), + (0x0425, 2732), + (0x0426, 3186), + (0x0427, 3064), + (0x0428, 4612), + (0x0429, 4612), + (0x042a, 3114), + (0x042b, 3970), + (0x042c, 2596), + (0x042d, 2666), + (0x042e, 4326), + (0x042f, 2822), + (0x0430, 2048), + (0x0431, 2044), + (0x0432, 1830), + (0x0433, 1512), + (0x0434, 2032), + (0x0435, 1818), + (0x0436, 3900), + (0x0437, 1598), + (0x0438, 2278), + (0x0439, 2278), + (0x043a, 2076), + (0x043b, 2102), + (0x043c, 2806), + (0x043d, 2274), + (0x043e, 2048), + (0x043f, 2278), + (0x0440, 2048), + (0x0441, 1818), + (0x0442, 3186), + (0x0443, 1818), + (0x0444, 2932), + (0x0445, 2048), + (0x0446, 2278), + (0x0447, 2150), + (0x0448, 3412), + (0x0449, 3412), + (0x044a, 2158), + (0x044b, 2842), + (0x044c, 1872), + (0x044d, 1700), + (0x044e, 2990), + (0x044f, 2110), + (0x0450, 1818), + (0x0451, 1818), + (0x0452, 2142), + (0x0453, 1610), + (0x0454, 1700), + (0x0455, 1594), + (0x0456, 1138), + (0x0457, 1138), + (0x0458, 1138), + (0x0459, 2700), + (0x045a, 2920), + (0x045b, 2278), + (0x045c, 2076), + (0x045d, 2278), + (0x045e, 1818), + (0x045f, 2278), (0x2013, 2048), (0x2014, 4096), (0x2018, 1364), @@ -496,6 +1895,7 @@ pub static SERIF_BOLD_ITALIC_SPARSE: [(u32, u16); 22] = [ (0x2192, 4096), (0x2193, 2048), (0x2206, 2506), + (0x2212, 2482), (0x2260, 2248), (0x2264, 2248), (0x2265, 2248), @@ -537,13 +1937,186 @@ pub static MONO: [u16; 560] = [ 2458, 2458, 2458, 2458, 2458, 2458, 2458, 0, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, 2458, ]; -pub static MONO_SPARSE: [(u32, u16); 22] = [ +pub static MONO_SPARSE: [(u32, u16); 196] = [ (0x00b0, 2458), (0x00b1, 2458), (0x00b2, 2458), (0x00b3, 2458), + (0x0384, 2458), + (0x0385, 2458), + (0x0386, 2458), + (0x0387, 2458), + (0x0388, 2458), + (0x0389, 2458), + (0x038a, 2458), + (0x038c, 2458), + (0x038e, 2458), + (0x038f, 2458), + (0x0390, 2458), + (0x0391, 2458), + (0x0392, 2458), + (0x0393, 2458), + (0x0394, 2458), + (0x0395, 2458), + (0x0396, 2458), + (0x0397, 2458), + (0x0398, 2458), + (0x0399, 2458), + (0x039a, 2458), + (0x039b, 2458), + (0x039c, 2458), + (0x039d, 2458), + (0x039e, 2458), + (0x039f, 2458), + (0x03a0, 2458), + (0x03a1, 2458), + (0x03a3, 2458), + (0x03a4, 2458), + (0x03a5, 2458), + (0x03a6, 2458), + (0x03a7, 2458), + (0x03a8, 2458), (0x03a9, 2458), + (0x03aa, 2458), + (0x03ab, 2458), + (0x03ac, 2458), + (0x03ad, 2458), + (0x03ae, 2458), + (0x03af, 2458), + (0x03b0, 2458), + (0x03b1, 2458), + (0x03b2, 2458), + (0x03b3, 2458), + (0x03b4, 2458), + (0x03b5, 2458), + (0x03b6, 2458), + (0x03b7, 2458), + (0x03b8, 2458), + (0x03b9, 2458), + (0x03ba, 2458), + (0x03bb, 2458), (0x03bc, 2458), + (0x03bd, 2458), + (0x03be, 2458), + (0x03bf, 2458), + (0x03c0, 2458), + (0x03c1, 2458), + (0x03c2, 2458), + (0x03c3, 2458), + (0x03c4, 2458), + (0x03c5, 2458), + (0x03c6, 2458), + (0x03c7, 2458), + (0x03c8, 2458), + (0x03c9, 2458), + (0x03ca, 2458), + (0x03cb, 2458), + (0x03cc, 2458), + (0x03cd, 2458), + (0x03ce, 2458), + (0x03d0, 2458), + (0x03d1, 2458), + (0x03d2, 2458), + (0x03d3, 2458), + (0x03d4, 2458), + (0x03d5, 2458), + (0x03d6, 2458), + (0x0400, 2458), + (0x0401, 2458), + (0x0402, 2458), + (0x0403, 2458), + (0x0404, 2458), + (0x0405, 2458), + (0x0406, 2458), + (0x0407, 2458), + (0x0408, 2458), + (0x0409, 2458), + (0x040a, 2458), + (0x040b, 2458), + (0x040c, 2458), + (0x040d, 2458), + (0x040e, 2458), + (0x040f, 2458), + (0x0410, 2458), + (0x0411, 2458), + (0x0412, 2458), + (0x0413, 2458), + (0x0414, 2458), + (0x0415, 2458), + (0x0416, 2458), + (0x0417, 2458), + (0x0418, 2458), + (0x0419, 2458), + (0x041a, 2458), + (0x041b, 2458), + (0x041c, 2458), + (0x041d, 2458), + (0x041e, 2458), + (0x041f, 2458), + (0x0420, 2458), + (0x0421, 2458), + (0x0422, 2458), + (0x0423, 2458), + (0x0424, 2458), + (0x0425, 2458), + (0x0426, 2458), + (0x0427, 2458), + (0x0428, 2458), + (0x0429, 2458), + (0x042a, 2458), + (0x042b, 2458), + (0x042c, 2458), + (0x042d, 2458), + (0x042e, 2458), + (0x042f, 2458), + (0x0430, 2458), + (0x0431, 2458), + (0x0432, 2458), + (0x0433, 2458), + (0x0434, 2458), + (0x0435, 2458), + (0x0436, 2458), + (0x0437, 2458), + (0x0438, 2458), + (0x0439, 2458), + (0x043a, 2458), + (0x043b, 2458), + (0x043c, 2458), + (0x043d, 2458), + (0x043e, 2458), + (0x043f, 2458), + (0x0440, 2458), + (0x0441, 2458), + (0x0442, 2458), + (0x0443, 2458), + (0x0444, 2458), + (0x0445, 2458), + (0x0446, 2458), + (0x0447, 2458), + (0x0448, 2458), + (0x0449, 2458), + (0x044a, 2458), + (0x044b, 2458), + (0x044c, 2458), + (0x044d, 2458), + (0x044e, 2458), + (0x044f, 2458), + (0x0450, 2458), + (0x0451, 2458), + (0x0452, 2458), + (0x0453, 2458), + (0x0454, 2458), + (0x0455, 2458), + (0x0456, 2458), + (0x0457, 2458), + (0x0458, 2458), + (0x0459, 2458), + (0x045a, 2458), + (0x045b, 2458), + (0x045c, 2458), + (0x045d, 2458), + (0x045e, 2458), + (0x045f, 2458), (0x2013, 2458), (0x2014, 2458), (0x2018, 2458), @@ -557,6 +2130,7 @@ pub static MONO_SPARSE: [(u32, u16); 22] = [ (0x2192, 2458), (0x2193, 2458), (0x2206, 2458), + (0x2212, 2458), (0x2260, 2458), (0x2264, 2458), (0x2265, 2458), diff --git a/crates/exav-render/src/formats/dwg/mod.rs b/crates/exav-render/src/formats/dwg/mod.rs index 5916d10..1a76103 100644 --- a/crates/exav-render/src/formats/dwg/mod.rs +++ b/crates/exav-render/src/formats/dwg/mod.rs @@ -18,6 +18,7 @@ mod clip; mod curves; +mod dimension; mod fill; mod font; mod gradient; @@ -31,6 +32,7 @@ pub mod stroke_font; mod tessellate; mod text; mod tiles; +mod wide; pub use curves::{flatten_arc, flatten_circle, flatten_ellipse}; pub use fill::fill_even_odd; @@ -39,8 +41,10 @@ pub use font::{ }; pub use hatch::nesting_depths; pub use palette::Palette; -pub use scene::{Affine, Scene, FILL_BYTES, STROKE_BYTES}; -pub use tessellate::{expand_bulges, LayerInfo, LayoutInfo, Tessellator, Warnings, SCENE_BUDGET}; +pub use scene::{Affine, Scene, FILL_BYTES, FILL_EDGE, STROKE_BYTES}; +pub use tessellate::{ + expand_bulges, LayerInfo, LayoutInfo, Sheet, Tessellator, Warnings, SCENE_BUDGET, +}; pub use text::{mtext_lines, HAlign, TextRun, VAlign, TEXT_RECORD_BYTES}; /// Why a file did not parse. @@ -198,24 +202,31 @@ impl Document { String::new() } }; + // Everything read from the scene before `buffers`, which empties it. let (texts, text_strings) = t.scene.text_buffers(); + let (origin, extents) = (t.scene.origin(), t.scene.extents_local()); + let clips = t.scene.clip_buffer(); + let b = t.scene.buffers(); Drawing { layout: drawn, - strokes: t.scene.stroke_buffer(), - fills: t.scene.fill_buffer(), + strokes: b.strokes, + fills: b.fills, texts, text_strings, - origin: t.scene.origin(), - extents: t.scene.extents_local(), + origin, + extents, max_order: t.scene.max_order(), - opaque_strokes: t.scene.opaque_strokes(), - opaque_fills: t.scene.opaque_fills(), - stroke_tiles: t.scene.stroke_tiles(), - fill_tiles: t.scene.fill_tiles(), - opaque_stroke_tiles: t.scene.opaque_stroke_tiles(), - opaque_fill_tiles: t.scene.opaque_fill_tiles(), + opaque_strokes: b.opaque_strokes, + opaque_fills: b.opaque_fills, + clips, + stroke_tiles: b.stroke_tiles, + fill_tiles: b.fill_tiles, + opaque_stroke_tiles: b.opaque_stroke_tiles, + opaque_fill_tiles: b.opaque_fill_tiles, + translucent_runs: b.translucent_runs, layers: t.layers, warnings: t.warnings, + sheet: t.sheet, } } } @@ -236,8 +247,9 @@ pub struct Drawing { /// for does not exist. pub layout: String, /// Stroke instances, [`STROKE_BYTES`] each: `f32 x0, y0, x1, y1 | u32 rgba - /// | u32 attr | u32 order`, little-endian. `attr` packs the layer id, the - /// lineweight and the flags (see `scene.rs`). + /// | u32 attr | u32 order | f32 bulge`, little-endian. `attr` packs the + /// layer id, the lineweight and the flags (see `scene.rs`); `bulge` is 0 + /// for a line, else the stroke is a circular arc (`Stroke::bulge`). pub strokes: Vec, /// Fill vertices, [`FILL_BYTES`] each: `f32 x, y | u32 rgba | u32 attr | /// u32 order`. @@ -245,12 +257,16 @@ pub struct Drawing { /// Text placement records, [`TEXT_RECORD_BYTES`] each, indexing into /// `text_strings`: `f32 x, y, height, rotation, widthFactor, oblique | u32 /// rgba, attr, strOffset | u16 strLen | u8 hAlign, vAlign | u32 order | u8 - /// face | 3 bytes padding`. Only runs in an outline face appear here; a + /// face | u8 padding | u16 clip`. Only runs in an outline face appear here; a /// stroke-font run is drawn as strokes, so that the drawing's lineweight /// decides how thick its letters are. pub texts: Vec, /// UTF-8, every run's characters back to back. pub text_strings: Vec, + /// The regions text runs are clipped to (a viewport's window, an XCLIP + /// boundary), which a run's `clip` numbers from 1: for each, `u32` + /// triangle count, then `f32 x, y` per corner relative to `origin`. + pub clips: Vec, /// World coordinates the buffers are relative to. pub origin: [f64; 2], /// `[min_x, min_y, max_x, max_y]`, relative to `origin`. @@ -258,22 +274,29 @@ pub struct Drawing { /// Highest draw-order index, for normalising depth. pub max_order: u32, /// Leading strokes and fill vertices that are opaque; the rest are - /// translucent and are drawn after, without writing depth. + /// translucent, in draw order, and are drawn after. pub opaque_strokes: usize, pub opaque_fills: usize, - /// Spatial buckets: `u32 start, count | f32 min_x, min_y, max_x, max_y`, - /// 24 bytes each, `start` and `count` in strokes (in vertices for fills). - /// The box is the true extent of the contents, which can reach outside - /// the bucket's grid square. + /// Tiles: `u32 start, count | f32 min_x, min_y, max_x, max_y`, 24 bytes + /// each, `start` and `count` in strokes (in vertices for fills). The + /// opaque half's are spatial buckets, the translucent half's runs of it + /// in draw order. The box is the true extent of the contents, which can + /// reach outside the bucket's grid square. pub stroke_tiles: Vec, pub fill_tiles: Vec, /// Leading entries of each table covering the opaque half. pub opaque_stroke_tiles: usize, pub opaque_fill_tiles: usize, + /// The order of the translucent pass: for each run, `u32` 1 for fill + /// tiles or 0 for stroke tiles, then the first tile and the one past the + /// last, indices into that kind's table. + pub translucent_runs: Vec, /// The layer table; a vertex's layer id indexes it. pub layers: Vec, /// What could not be drawn. pub warnings: Warnings, + /// What the tab is drawn on. + pub sheet: Sheet, } impl Drawing { @@ -310,11 +333,38 @@ impl Drawing { format!("[{}]", items.join(",")) } + /// [`Sheet`] as JSON: `{"mmPerUnit", "lineweights"}`. + pub fn sheet_json(&self) -> String { + let s = &self.sheet; + // The paper relative to `origin`, as the buffers are. + let paper = match s.paper { + Some(p) => format!( + "[{},{},{},{}]", + json_number(p[0] - self.origin[0]), + json_number(p[1] - self.origin[1]), + json_number(p[2] - self.origin[0]), + json_number(p[3] - self.origin[1]) + ), + None => "null".to_string(), + }; + format!( + r#"{{"mmPerUnit":{},"lineweights":{},"paper":{paper}}}"#, + json_number(s.mm_per_unit), + s.lineweights + ) + } + /// [`Warnings`] as JSON. All zeroes means everything was drawn. pub fn warnings_json(&self) -> String { let w = &self.warnings; + let not_drawn: Vec = w + .not_drawn + .iter() + .map(|(k, n)| format!("{}:{n}", json_string(k))) + .collect(); + let files: Vec = w.external_files.iter().map(|f| json_string(f)).collect(); format!( - r#"{{"unknownEntities":{},"missingBlocks":{},"textRuns":{},"strokeGlyphs":{},"hatchPatternsMissing":{},"hatchPatternsTruncated":{},"externalReferences":{},"depthExceeded":{},"sceneTruncated":{},"proxyWithoutGraphics":{}}}"#, + r#"{{"unknownEntities":{},"missingBlocks":{},"textRuns":{},"strokeGlyphs":{},"hatchPatternsMissing":{},"hatchPatternsTruncated":{},"externalReferences":{},"depthExceeded":{},"sceneTruncated":{},"proxyWithoutGraphics":{},"notDrawn":{{{}}},"externalFiles":[{}]}}"#, w.unknown_entities, w.missing_blocks, w.text_runs, @@ -324,11 +374,22 @@ impl Drawing { w.external_references, w.depth_exceeded, w.scene_truncated, - w.proxy_without_graphics + w.proxy_without_graphics, + not_drawn.join(","), + files.join(",") ) } } +/// A number JSON can hold: a non-finite one is 0. +fn json_number(v: f64) -> String { + if v.is_finite() { + format!("{v}") + } else { + "0".to_string() + } +} + /// JSON string escaping. Layer and layout names are file data and routinely /// hold quotes, backslashes and control characters. fn json_string(s: &str) -> String { diff --git a/crates/exav-render/src/formats/dwg/scene.rs b/crates/exav-render/src/formats/dwg/scene.rs index ce74c9d..13a41f9 100644 --- a/crates/exav-render/src/formats/dwg/scene.rs +++ b/crates/exav-render/src/formats/dwg/scene.rs @@ -4,6 +4,8 @@ //! precision, then re-emitted as f32 relative to a per-drawing origin. See //! DESIGN.md section 4.4. +use super::tiles::Tile; + /// 2D affine transform, applied as the block/insert stack is walked. /// /// x' = a*x + c*y + e @@ -127,7 +129,8 @@ impl Affine { } } -/// One line segment, pre-transform-applied, still in drawing coordinates. +/// One line segment, or circular arc, pre-transform-applied, still in drawing +/// coordinates. #[derive(Clone, Copy)] pub struct Stroke { pub x0: f64, @@ -139,6 +142,32 @@ pub struct Stroke { pub attr: u32, /// Position in the drawing's draw order. See `Scene::order`. pub order: u32, + /// 0 for a line; for an arc from the first point to the second, the tan + /// of a quarter of its sweep, positive counter-clockwise (DXF's bulge), + /// at most [`MAX_ARC_BULGE`]: the renderer draws it exactly at any zoom. + pub bulge: f32, +} + +/// The largest bulge a stroke carries, a quarter turn's: an arc stroke then +/// lies within the box of its ends widened by a fifth of its chord, and +/// within its sector the shader's test is two cross products. +pub const MAX_ARC_BULGE: f64 = 0.414_213_562_373_095_1; + +impl Stroke { + /// How far an arc stroke's curve stands off its chord, at most. + pub fn sagitta(&self) -> f64 { + (self.bulge.abs() as f64) * (self.x1 - self.x0).hypot(self.y1 - self.y0) / 2.0 + } + + /// The stroke as straight segments: itself, or its arc in `n` pieces. + pub fn flatten(&self, n: usize) -> Vec<[[f64; 2]; 2]> { + let (a, b) = ([self.x0, self.y0], [self.x1, self.y1]); + if self.bulge == 0.0 { + return vec![[a, b]]; + } + let pts = super::curves::bulge_points(a, b, self.bulge as f64, n); + pts.windows(2).map(|w| [w[0], w[1]]).collect() + } } /// One triangle corner. @@ -151,7 +180,7 @@ pub struct FillVertex { pub order: u32, } -pub const STROKE_BYTES: usize = 28; +pub const STROKE_BYTES: usize = 32; pub const FILL_BYTES: usize = 20; /// Fill flag: paint the viewer's background colour instead of the vertex's. @@ -160,6 +189,10 @@ pub const FILL_BYTES: usize = 20; /// background, which the tessellator does not know. The renderer substitutes it. pub const FILL_FLAG_BACKGROUND: u8 = 1; +/// The lineweight code of a fill's edge, a stroke of no width whose fringe is +/// the fill's anti-aliasing (`shaders.ts`). Lineweights stop at 211. +pub const FILL_EDGE: u8 = 255; + /// Flag: this colour is AutoCAD's colour 7, so it flips with the background. /// /// Colour 7 means "white or black, whichever contrasts". A true colour that @@ -178,15 +211,22 @@ pub const FLAG_CONTRAST: u8 = 0x80; /// should average out to. 0 means "do not fade". #[inline] pub fn encode_fade_spacing(spacing: f64) -> u8 { - if !spacing.is_finite() || spacing <= 1.0 { + if !spacing.is_finite() || spacing <= 0.0 { return 0; } - // Logarithmic, eight steps to the octave, which covers millimetres through - // to tens of metres in the seven bits left beside the contrast flag. - let code = (spacing.log2() * 8.0).round(); + // Logarithmic, five steps to the octave from 2^-9: in the seven bits left + // beside the contrast flag, 0.002 to 86,000 units, a pattern a few + // millimetres apart in a drawing in metres to one metres apart in a + // drawing in millimetres. `spacingFade` in shaders.ts decodes it. + let code = ((spacing.log2() + FADE_OCTAVES_BELOW_ONE) * FADE_STEPS_PER_OCTAVE).round(); code.clamp(1.0, 127.0) as u8 } +/// Octaves below a unit `encode_fade_spacing` reaches, and its steps per +/// octave. +const FADE_OCTAVES_BELOW_ONE: f64 = 9.0; +const FADE_STEPS_PER_OCTAVE: f64 = 5.0; + #[inline] pub fn pack_attr(layer: u16, lineweight: u8, flags: u8) -> u32 { (layer as u32) | ((lineweight as u32) << 16) | ((flags as u32) << 24) @@ -217,11 +257,15 @@ pub struct Scene { pub strokes: Vec, pub fills: Vec, pub texts: Vec, + /// Clip regions as triangles, a text run's `clip` indexing them from 1. + pub clips: Vec>, /// Draw order counter. AutoCAD paints entities in this sequence, and later /// entities cover earlier ones: a small solid fill placed over a hatch is /// how a roof vent masks the tiles beneath it. Without it a viewer that /// batches all fills before all strokes draws the hatch back on top. pub order: u32, + /// Fill vertices `edge_fills` has outlined. + edged: usize, min_x: f64, min_y: f64, max_x: f64, @@ -234,7 +278,9 @@ impl Scene { strokes: Vec::new(), fills: Vec::new(), texts: Vec::new(), + clips: Vec::new(), order: 0, + edged: 0, min_x: f64::INFINITY, min_y: f64::INFINITY, max_x: f64::NEG_INFINITY, @@ -245,7 +291,10 @@ impl Scene { /// Record a text run. Only the anchor grows the extents: the rendered width /// depends on font metrics the renderer has and this side does not. pub fn push_text(&mut self, run: super::text::TextRun) { - if !(run.x.is_finite() && run.y.is_finite() && run.height.is_finite()) { + if ![run.x, run.y, run.u[0], run.u[1], run.v[0], run.v[1]] + .iter() + .all(|c| c.is_finite()) + { return; } if run.text.is_empty() { @@ -286,9 +335,65 @@ impl Scene { rgba, attr, order, + bulge: 0.0, }); } + /// Push a circular arc as arc strokes of at most a quarter turn each: + /// centre, radius, start angle and signed sweep (positive + /// counter-clockwise), in drawing coordinates. + #[allow(clippy::too_many_arguments)] + pub fn push_arc( + &mut self, + cx: f64, + cy: f64, + r: f64, + start: f64, + sweep: f64, + rgba: u32, + attr: u32, + ) { + if ![cx, cy, r, start, sweep].iter().all(|v| v.is_finite()) || r <= 0.0 || sweep == 0.0 { + return; + } + let n = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize; + let step = sweep / n as f64; + let bulge = (step / 4.0).tan() as f32; + debug_assert!(f64::from(bulge.abs()) <= MAX_ARC_BULGE + 1e-6); + let at = |a: f64| [cx + r * a.cos(), cy + r * a.sin()]; + // The extents: the arc's ends and the quadrant points it passes. + let (lo, hi) = if sweep > 0.0 { + (start, start + sweep) + } else { + (start + sweep, start) + }; + let first = (lo / std::f64::consts::FRAC_PI_2).ceil() as i64; + let last = (hi / std::f64::consts::FRAC_PI_2).floor() as i64; + for k in first..=last.min(first + 4) { + let p = at(k as f64 * std::f64::consts::FRAC_PI_2); + self.grow(p[0], p[1]); + } + let order = self.order; + for i in 0..n { + let (a, b) = ( + at(start + step * i as f64), + at(start + step * (i + 1) as f64), + ); + self.grow(a[0], a[1]); + self.grow(b[0], b[1]); + self.strokes.push(Stroke { + x0: a[0], + y0: a[1], + x1: b[0], + y1: b[1], + rgba, + attr, + order, + bulge, + }); + } + } + /// Push a flattened polyline as a run of segments. pub fn push_polyline(&mut self, pts: &[[f64; 2]], closed: bool, rgba: u32, attr: u32) { if pts.len() < 2 { @@ -355,6 +460,60 @@ impl Scene { } /// Where each buffer currently ends, for a later `clip_since`. + /// Outline the opaque fills of one colour pushed since the last call with + /// `FILL_EDGE` strokes, whose fringe anti-aliases the fill's edge: drawn + /// at the fill's depth, each is hidden where the fill is and shows only + /// outside it. An edge two triangles share is left out; a translucent, + /// shaded or background fill is not outlined. + pub fn edge_fills(&mut self) { + let from = self.edged.min(self.fills.len()); + self.edged = self.fills.len(); + let tris = self.fills[from..].as_chunks::<3>().0; + if tris.is_empty() { + return; + } + let key = |a: &FillVertex, b: &FillVertex| { + let (pa, pb) = ( + [a.x.to_bits(), a.y.to_bits()], + [b.x.to_bits(), b.y.to_bits()], + ); + let (lo, hi) = if pa <= pb { (pa, pb) } else { (pb, pa) }; + (a.rgba, a.attr, a.order, lo, hi) + }; + let mut edges = Vec::new(); + let mut uses: std::collections::HashMap<_, u32> = std::collections::HashMap::new(); + for t in tris { + let one_colour = t[1].rgba == t[0].rgba && t[2].rgba == t[0].rgba; + let opaque = t[0].rgba >> 24 == 255; + let background = (t[0].attr >> 24) as u8 & FILL_FLAG_BACKGROUND != 0; + if !one_colour || !opaque || background { + continue; + } + for (a, b) in [(&t[0], &t[1]), (&t[1], &t[2]), (&t[2], &t[0])] { + *uses.entry(key(a, b)).or_insert(0) += 1; + edges.push((*a, *b)); + } + } + for (a, b) in edges { + if uses[&key(&a, &b)] != 1 { + continue; + } + let attr = (a.attr & 0xFFFF) + | u32::from(FILL_EDGE) << 16 + | (a.attr & (u32::from(FLAG_CONTRAST) << 24)); + self.strokes.push(Stroke { + x0: a.x, + y0: a.y, + x1: b.x, + y1: b.y, + rgba: a.rgba, + attr, + order: a.order, + bulge: 0.0, + }); + } + } + pub fn mark(&self) -> Mark { Mark { strokes: self.strokes.len(), @@ -381,15 +540,30 @@ impl Scene { } continue; } - shape.clip_segment(a, b, &mut spans); - for (t0, t1) in spans.iter().copied() { - kept.push(Stroke { - x0: a[0] + (b[0] - a[0]) * t0, - y0: a[1] + (b[1] - a[1]) * t0, - x1: a[0] + (b[0] - a[0]) * t1, - y1: a[1] + (b[1] - a[1]) * t1, - ..s - }); + // An arc wholly inside stays one; one crossing the edge is cut as + // the straight pieces of its curve. + if s.bulge != 0.0 { + let [x0, y0, x1, y1] = stroke_box(&s); + if [[x0, y0], [x1, y0], [x1, y1], [x0, y1]] + .iter() + .all(|&c| shape.contains(c)) + { + kept.push(s); + continue; + } + } + for [a, b] in s.flatten(16) { + shape.clip_segment(a, b, &mut spans); + for (t0, t1) in spans.iter().copied() { + kept.push(Stroke { + x0: a[0] + (b[0] - a[0]) * t0, + y0: a[1] + (b[1] - a[1]) * t0, + x1: a[0] + (b[0] - a[0]) * t1, + y1: a[1] + (b[1] - a[1]) * t1, + bulge: 0.0, + ..s + }); + } } } self.strokes.extend(kept); @@ -411,12 +585,87 @@ impl Scene { } } self.fills.extend(kept_fills); - - // A run is kept or dropped whole: its rendered width lives on the other - // side of the worker boundary, so there is no box here to cut against. + // Outlined before they were cut, and the outlines cut with the + // strokes. + self.edged = self.fills.len(); + + // A run's glyphs are laid out on the other side of the worker + // boundary, so it cannot be cut here: one that may cross the shape's + // edge is drawn through a clip region, which the renderer masks it + // with. One wholly outside is dropped, one wholly inside is left be. let tail: Vec = self.texts.drain(mark.texts..).collect(); - self.texts - .extend(tail.into_iter().filter(|r| shape.contains([r.x, r.y]))); + let (min, max) = shape.bounds(); + let mut regions: std::collections::HashMap> = Default::default(); + for mut r in tail { + let reach = text_reach(&r); + if r.x + reach < min[0] + || r.x - reach > max[0] + || r.y + reach < min[1] + || r.y - reach > max[1] + { + continue; + } + let corners = [ + [r.x - reach, r.y - reach], + [r.x + reach, r.y - reach], + [r.x + reach, r.y + reach], + [r.x - reach, r.y + reach], + ]; + if !corners.iter().all(|c| shape.contains(*c)) { + let region = *regions + .entry(r.clip) + .or_insert_with(|| self.clip_region(r.clip, shape)); + match region { + Some(id) => r.clip = id, + // Nothing of the earlier region is inside this one. + None => continue, + } + } + self.texts.push(r); + } + } + + /// A new clip region: the shape, or the part of region `within` it, as + /// its id; None when that is empty, or when ids have run out (the run is + /// then kept or dropped by its anchor, as before regions). + fn clip_region(&mut self, within: u16, shape: &super::clip::ClipShape) -> Option { + let triangles = if within == 0 { + shape.triangles() + } else { + let mut out = Vec::new(); + let mut piece = Vec::new(); + for t in self + .clips + .get(within as usize - 1) + .cloned() + .unwrap_or_default() + { + shape.clip_triangle(t, &mut piece); + out.extend_from_slice(&piece); + } + out + }; + if triangles.is_empty() || self.clips.len() >= u16::MAX as usize { + return None; + } + self.clips.push(triangles); + Some(self.clips.len() as u16) + } + + /// The clip regions as the renderer takes them: for each, a `u32` + /// triangle count, then `f32 x, y` for each corner, relative to + /// `origin()`. + pub fn clip_buffer(&self) -> Vec { + let o = self.origin(); + let mut out = Vec::new(); + for region in &self.clips { + out.extend_from_slice(&(region.len() as u32).to_le_bytes()); + for p in region.iter().flatten() { + out.extend_from_slice(&((p[0] - o[0]) as f32).to_le_bytes()); + out.extend_from_slice(&((p[1] - o[1]) as f32).to_le_bytes()); + } + } + out } /// Declare the extents instead of deriving them from the geometry. @@ -452,6 +701,48 @@ impl Scene { } } + /// Narrow the extents to where the drawing is when a few strays lie far + /// off it (a point left at the origin of a drawing in national grid + /// coordinates): the f32 origin, the fitted view and the culling grid + /// then serve the drawing, not the empty space between it and them, + /// which cost it centimetres of precision and showed it as a speck. + /// The core is what lies between the 0.5th and 99.5th percentiles on + /// each axis, with a margin; the strays are still drawn. + pub fn focus_on_core(&mut self) { + let points = self.strokes.len() * 2 + self.fills.len() + self.texts.len(); + if points < 200 || !self.min_x.is_finite() { + return; + } + let stride = (points / 50_000).max(1); + let all = self + .strokes + .iter() + .flat_map(|s| [[s.x0, s.y0], [s.x1, s.y1]]) + .chain(self.fills.iter().map(|v| [v.x, v.y])) + .chain(self.texts.iter().map(|r| [r.x, r.y])); + let (mut xs, mut ys): (Vec, Vec) = + all.step_by(stride).map(|p| (p[0], p[1])).unzip(); + let span = |v: &mut Vec| -> (f64, f64) { + let n = v.len(); + let (lo, hi) = (n / 200, (n - 1) - n / 200); + let a = *v.select_nth_unstable_by(lo, f64::total_cmp).1; + let b = *v.select_nth_unstable_by(hi, f64::total_cmp).1; + (a, b) + }; + let ((x0, x1), (y0, y1)) = (span(&mut xs), span(&mut ys)); + let margin = (x1 - x0).max(y1 - y0) * 0.25; + let core = [x0 - margin, y0 - margin, x1 + margin, y1 + margin]; + let (full_w, full_h) = (self.max_x - self.min_x, self.max_y - self.min_y); + let (core_w, core_h) = (core[2] - core[0], core[3] - core[1]); + if !(core_w > 0.0 && core_h > 0.0) || (full_w <= 4.0 * core_w && full_h <= 4.0 * core_h) { + return; + } + self.min_x = self.min_x.max(core[0]); + self.min_y = self.min_y.max(core[1]); + self.max_x = self.max_x.min(core[2]); + self.max_y = self.max_y.min(core[3]); + } + /// Highest draw-order value used, for normalising depth. pub fn max_order(&self) -> u32 { self.order @@ -492,173 +783,285 @@ impl Scene { [self.min_x, self.min_y, self.max_x, self.max_y] } - /// Interleaved stroke instance buffer, `STROKE_BYTES` per segment. + /// The geometry as the renderer takes it (see [`Buffers`]). /// - /// Opaque segments come first, then translucent ones, and each half is - /// sorted into spatial buckets so the renderer can skip what a view does - /// not touch. The halves stay whole: the opaque pass writes depth and the - /// translucent pass only tests it, so interleaving them by bucket would let - /// one bucket's opaque geometry paint over another's translucent. - pub fn stroke_buffer(&self) -> Vec { + /// Opaque geometry comes first, sorted into spatial buckets so the + /// renderer can skip what a view does not touch, then translucent + /// geometry in draw order: blending is drawn in order, and the opaque + /// pass must be done before any of it, so the halves stay whole. + /// + /// It takes the strokes and fills out of the scene as it encodes them, + /// so each list is freed once its bytes are made: read the origin, the + /// extents, the text and the clip regions first. + pub fn buffers(&mut self) -> Buffers { let o = self.origin(); - let mut out = Vec::with_capacity(self.strokes.len() * STROKE_BYTES); - for half in self.stroke_halves() { - let (order, _) = self.bucket_strokes(&half); - for i in order { - let s = half[i as usize]; - out.extend_from_slice(&((s.x0 - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.y0 - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.x1 - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((s.y1 - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&s.rgba.to_le_bytes()); - out.extend_from_slice(&s.attr.to_le_bytes()); - out.extend_from_slice(&s.order.to_le_bytes()); + // Indices rather than copies: a stroke is 48 bytes, its index 4. + let [opaque_strokes, strokes] = self.stroke_halves(); + let [opaque_fills, fills] = self.fill_halves(); + let (stroke_order, mut stroke_tiles) = self.bucket_strokes(&opaque_strokes); + let (fill_order, mut fill_tiles) = self.bucket_fills(&opaque_fills); + let translucent = self.translucent(&strokes, &fills); + + let mut stroke_bytes = Vec::with_capacity(self.strokes.len() * STROKE_BYTES); + let ordered = stroke_order + .iter() + .map(|&i| opaque_strokes[i as usize]) + .chain(strokes.iter().copied()); + for s in ordered.map(|i| &self.strokes[i as usize]) { + stroke_bytes.extend_from_slice(&((s.x0 - o[0]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.y0 - o[1]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.x1 - o[0]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&((s.y1 - o[1]) as f32).to_le_bytes()); + stroke_bytes.extend_from_slice(&s.rgba.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.attr.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.order.to_le_bytes()); + stroke_bytes.extend_from_slice(&s.bulge.to_le_bytes()); + } + drop((stroke_order, strokes)); + self.strokes = Vec::new(); + let mut fill_bytes = Vec::with_capacity(self.fills.len() * FILL_BYTES); + let ordered = fill_order + .iter() + .map(|&i| opaque_fills[i as usize]) + .chain(fills.iter().copied()); + for t in ordered { + for v in &self.fills[t as usize * 3..t as usize * 3 + 3] { + fill_bytes.extend_from_slice(&((v.x - o[0]) as f32).to_le_bytes()); + fill_bytes.extend_from_slice(&((v.y - o[1]) as f32).to_le_bytes()); + fill_bytes.extend_from_slice(&v.rgba.to_le_bytes()); + fill_bytes.extend_from_slice(&v.attr.to_le_bytes()); + fill_bytes.extend_from_slice(&v.order.to_le_bytes()); } } - out - } + drop((fill_order, fills)); + self.fills = Vec::new(); + + // Tiles count strokes, and fill vertices (for drawArrays); the + // translucent ones start past the opaque half. + for tile in &mut fill_tiles { + tile.start *= 3; + tile.count *= 3; + } + let (opaque_stroke_tiles, opaque_fill_tiles) = (stroke_tiles.len(), fill_tiles.len()); + for mut tile in translucent.strokes { + tile.start += opaque_strokes.len() as u32; + stroke_tiles.push(tile); + } + for mut tile in translucent.fills { + tile.start = (opaque_fills.len() as u32 + tile.start) * 3; + tile.count *= 3; + fill_tiles.push(tile); + } + let mut runs = Vec::with_capacity(translucent.runs.len() * 12); + for (fill, from, to) in translucent.runs { + let base = if fill { + opaque_fill_tiles + } else { + opaque_stroke_tiles + } as u32; + runs.extend_from_slice(&u32::from(fill).to_le_bytes()); + runs.extend_from_slice(&(base + from).to_le_bytes()); + runs.extend_from_slice(&(base + to).to_le_bytes()); + } - /// Interleaved fill vertex buffer, `FILL_BYTES` per vertex. - pub fn fill_buffer(&self) -> Vec { - let o = self.origin(); - let mut out = Vec::with_capacity(self.fills.len() * FILL_BYTES); - for half in self.fill_halves() { - let (order, _) = self.bucket_fills(&half); - for i in order { - for v in half[i as usize] { - out.extend_from_slice(&((v.x - o[0]) as f32).to_le_bytes()); - out.extend_from_slice(&((v.y - o[1]) as f32).to_le_bytes()); - out.extend_from_slice(&v.rgba.to_le_bytes()); - out.extend_from_slice(&v.attr.to_le_bytes()); - out.extend_from_slice(&v.order.to_le_bytes()); - } - } + Buffers { + strokes: stroke_bytes, + fills: fill_bytes, + opaque_strokes: opaque_strokes.len(), + opaque_fills: opaque_fills.len() * 3, + stroke_tiles: super::tiles::encode(&stroke_tiles, o), + fill_tiles: super::tiles::encode(&fill_tiles, o), + opaque_stroke_tiles, + opaque_fill_tiles, + translucent_runs: runs, } - out } - /// Strokes split into the opaque half and the translucent half, in that - /// order. Both buffer emission and the tile table walk this, so they cannot - /// disagree about what went where. - fn stroke_halves(&self) -> [Vec; 2] { - let (a, b): (Vec, Vec) = - self.strokes.iter().partition(|s| is_opaque(s.rgba)); + /// The strokes of the opaque half and of the translucent half, by + /// index, the translucent one in draw order. Both buffer emission and + /// the tile table walk this, so they cannot disagree about what went + /// where. + fn stroke_halves(&self) -> [Vec; 2] { + let all = 0..self.strokes.len() as u32; + let (a, mut b): (Vec, Vec) = + all.partition(|&i| is_opaque(self.strokes[i as usize].rgba)); + b.sort_by_key(|&i| self.strokes[i as usize].order); [a, b] } - /// The same for fills, kept as whole triangles. A gradient gives its - /// corners different colours but the same alpha, so one corner decides. - fn fill_halves(&self) -> [Vec<[FillVertex; 3]>; 2] { - let tris: Vec<[FillVertex; 3]> = self.fills.as_chunks::<3>().0.to_vec(); - let (a, b): (Vec<_>, Vec<_>) = tris.into_iter().partition(|t| is_opaque(t[0].rgba)); + /// The same for fills, by triangle. A gradient gives its corners + /// different colours but the same alpha, so one corner decides. + fn fill_halves(&self) -> [Vec; 2] { + let all = 0..(self.fills.len() / 3) as u32; + let (a, mut b): (Vec, Vec) = + all.partition(|&t| is_opaque(self.fills[t as usize * 3].rgba)); + b.sort_by_key(|&t| self.fills[t as usize * 3].order); [a, b] } - fn bucket_strokes(&self, half: &[Stroke]) -> (Vec, Vec) { + fn triangle(&self, t: u32) -> &[FillVertex; 3] { + let at = t as usize * 3; + self.fills[at..at + 3].try_into().expect("three vertices") + } + + /// The translucent halves' tiles, and the order to draw them in. A tile + /// is a run of the half in draw order, so it is no spatial bucket, but + /// neighbours in draw order are often neighbours on the sheet too. Fills + /// and strokes interleave by draw order, a run of tiles of one kind at a + /// time; past [`MAX_TRANSLUCENT_RUNS`] runs, the fills then the strokes. + fn translucent(&self, strokes: &[u32], fills: &[u32]) -> Translucent { + let stroke = |i: u32| &self.strokes[i as usize]; + let mut t = Translucent::default(); + let (mut i, mut j) = (0, 0); + while i < strokes.len() || j < fills.len() { + let fill = match (strokes.get(i), fills.get(j)) { + (Some(&s), Some(&f)) => self.triangle(f)[0].order <= stroke(s).order, + (None, _) => true, + (Some(_), None) => false, + }; + if fill { + t.push(true, j, fill_box(self.triangle(fills[j]))); + j += 1; + } else { + t.push(false, i, stroke_box(stroke(strokes[i]))); + i += 1; + } + } + if t.runs.len() > MAX_TRANSLUCENT_RUNS { + t = Translucent::default(); + for (j, &f) in fills.iter().enumerate() { + t.push(true, j, fill_box(self.triangle(f))); + } + for (i, &s) in strokes.iter().enumerate() { + t.push(false, i, stroke_box(stroke(s))); + } + } + t + } + + fn bucket_strokes(&self, half: &[u32]) -> (Vec, Vec) { + let stroke = |i: &u32| &self.strokes[*i as usize]; super::tiles::bucket( half, self.world_extent(), - |s| [(s.x0 + s.x1) / 2.0, (s.y0 + s.y1) / 2.0], - |s| { - [ - s.x0.min(s.x1), - s.y0.min(s.y1), - s.x0.max(s.x1), - s.y0.max(s.y1), - ] + |i| { + let s = stroke(i); + [(s.x0 + s.x1) / 2.0, (s.y0 + s.y1) / 2.0] }, + |i| stroke_box(stroke(i)), ) } - fn bucket_fills(&self, half: &[[FillVertex; 3]]) -> (Vec, Vec) { + fn bucket_fills(&self, half: &[u32]) -> (Vec, Vec) { super::tiles::bucket( half, self.world_extent(), - |t| { + |&i| { + let t = self.triangle(i); [ (t[0].x + t[1].x + t[2].x) / 3.0, (t[0].y + t[1].y + t[2].y) / 3.0, ] }, - |t| { - [ - t[0].x.min(t[1].x).min(t[2].x), - t[0].y.min(t[1].y).min(t[2].y), - t[0].x.max(t[1].x).max(t[2].x), - t[0].y.max(t[1].y).max(t[2].y), - ] - }, + |&i| fill_box(self.triangle(i)), ) } +} - /// Tile table for `stroke_buffer`, counted in strokes. The translucent - /// half's tiles are offset past the opaque half, so a start index addresses - /// the whole buffer. - pub fn stroke_tiles(&self) -> Vec { - let halves = self.stroke_halves(); - let mut tiles = Vec::new(); - let mut base = 0u32; - for half in &halves { - let (_, mut t) = self.bucket_strokes(half); - for tile in &mut t { - tile.start += base; - } - base += half.len() as u32; - tiles.extend(t); - } - super::tiles::encode(&tiles, self.origin()) - } - - /// Tile table for `fill_buffer`, counted in vertices, so it can be handed - /// straight to `drawArrays`. - pub fn fill_tiles(&self) -> Vec { - let halves = self.fill_halves(); - let mut tiles = Vec::new(); - let mut base = 0u32; - for half in &halves { - let (_, mut t) = self.bucket_fills(half); - for tile in &mut t { - tile.start = base + tile.start * 3; - tile.count *= 3; - } - base += half.len() as u32 * 3; - tiles.extend(t); - } - super::tiles::encode(&tiles, self.origin()) - } +/// What [`Scene::buffers`] hands the renderer; see [`super::Drawing`]. +pub struct Buffers { + pub strokes: Vec, + pub fills: Vec, + pub opaque_strokes: usize, + pub opaque_fills: usize, + pub stroke_tiles: Vec, + pub fill_tiles: Vec, + pub opaque_stroke_tiles: usize, + pub opaque_fill_tiles: usize, + pub translucent_runs: Vec, +} - /// Where the translucent tiles begin, as an index into the tile table. - pub fn opaque_stroke_tiles(&self) -> usize { - self.bucket_strokes(&self.stroke_halves()[0]).1.len() - } +/// Elements in one tile of a translucent half. +const TRANSLUCENT_TILE: u32 = 4096; +/// Most runs the translucent pass alternates fills and strokes in, each a +/// few draw calls. +const MAX_TRANSLUCENT_RUNS: usize = 1024; - pub fn opaque_fill_tiles(&self) -> usize { - self.bucket_fills(&self.fill_halves()[0]).1.len() - } +/// The translucent halves' tiles, `start` and `count` in elements of the +/// half, and the runs of tiles to draw: (fills, first tile, end tile). +#[derive(Default)] +struct Translucent { + strokes: Vec, + fills: Vec, + runs: Vec<(bool, u32, u32)>, +} - /// How many leading strokes of `stroke_buffer` are opaque. - pub fn opaque_strokes(&self) -> usize { - self.strokes.iter().filter(|s| is_opaque(s.rgba)).count() +impl Translucent { + /// Element `index` of a half, next in draw order. + fn push(&mut self, fill: bool, index: usize, b: [f64; 4]) { + let tiles = if fill { + &mut self.fills + } else { + &mut self.strokes + }; + let same_run = self.runs.last().is_some_and(|r| r.0 == fill); + if let Some(t) = tiles + .last_mut() + .filter(|t| same_run && t.count < TRANSLUCENT_TILE) + { + t.count += 1; + t.min_x = t.min_x.min(b[0]); + t.min_y = t.min_y.min(b[1]); + t.max_x = t.max_x.max(b[2]); + t.max_y = t.max_y.max(b[3]); + return; + } + tiles.push(Tile { + start: index as u32, + count: 1, + min_x: b[0], + min_y: b[1], + max_x: b[2], + max_y: b[3], + }); + let n = tiles.len() as u32; + match self.runs.last_mut() { + Some(r) if same_run => r.2 = n, + _ => self.runs.push((fill, n - 1, n)), + } } +} - /// How many leading vertices of `fill_buffer` are opaque. - pub fn opaque_fills(&self) -> usize { - self.fills - .as_chunks::<3>() - .0 - .iter() - .filter(|t| is_opaque(t[0].rgba)) - .count() - * 3 - } +fn stroke_box(s: &Stroke) -> [f64; 4] { + let h = s.sagitta(); + [ + s.x0.min(s.x1) - h, + s.y0.min(s.y1) - h, + s.x0.max(s.x1) + h, + s.y0.max(s.y1) + h, + ] } -/// Fully opaque geometry can write depth; translucent geometry cannot. -/// -/// The draw order is resolved through the depth buffer, so a translucent -/// entity that writes depth rejects everything drawn behind it afterwards -/// instead of letting it blend through. Splitting the buffers lets the -/// renderer draw the translucent tail with depth writes off. +fn fill_box(t: &[FillVertex; 3]) -> [f64; 4] { + [ + t[0].x.min(t[1].x).min(t[2].x), + t[0].y.min(t[1].y).min(t[2].y), + t[0].x.max(t[1].x).max(t[2].x), + t[0].y.max(t[1].y).max(t[2].y), + ] +} + +/// How far from its anchor a run can reach, whatever its alignment and +/// rotation: every character an em wide, which no bundled face exceeds, and +/// a line's height. +fn text_reach(r: &super::text::TextRun) -> f64 { + let chars = r.text.chars().count() as f64; + r.unit() * (chars + 1.0) +} + +/// Fully opaque geometry can be drawn in any order, the depth buffer +/// deciding what covers what; translucent geometry blends over what is +/// already drawn, so it comes after, in draw order. #[inline] fn is_opaque(rgba: u32) -> bool { rgba >> 24 == 255 @@ -736,23 +1139,119 @@ mod tests { s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); s.push_triangle([2.0, 0.0], [3.0, 0.0], [2.0, 1.0], solid, 0); - assert_eq!(s.opaque_strokes(), 1); - assert_eq!(s.opaque_fills(), 3); + let b = s.buffers(); + assert_eq!(b.opaque_strokes, 1); + assert_eq!(b.opaque_fills, 3); // The opaque one comes first in the buffer whatever order it arrived in. - let buf = s.stroke_buffer(); - let first_rgba = u32::from_le_bytes(buf[16..20].try_into().unwrap()); + let first_rgba = u32::from_le_bytes(b.strokes[16..20].try_into().unwrap()); assert_eq!(first_rgba, solid); - let fills = s.fill_buffer(); - let first_fill_rgba = u32::from_le_bytes(fills[8..12].try_into().unwrap()); + let first_fill_rgba = u32::from_le_bytes(b.fills[8..12].try_into().unwrap()); assert_eq!(first_fill_rgba, solid); } + fn u32s(b: &[u8]) -> Vec { + b.as_chunks::<4>() + .0 + .iter() + .map(|c| u32::from_le_bytes(*c)) + .collect() + } + + /// An opaque fill is outlined by edges of no width, which anti-alias it: + /// a square of two triangles by its four sides, not their diagonal. A + /// translucent fill and a background one are not. + #[test] + fn opaque_fills_are_outlined_for_their_edges() { + let mut s = Scene::new(); + let red = pack_rgba(255, 0, 0, 255); + let attr = pack_attr(3, 25, FLAG_CONTRAST); + s.push_triangle([0.0, 0.0], [1.0, 0.0], [1.0, 1.0], red, attr); + s.push_triangle([0.0, 0.0], [1.0, 1.0], [0.0, 1.0], red, attr); + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + for k in &s.strokes { + assert_eq!(k.attr, pack_attr(3, FILL_EDGE, FLAG_CONTRAST)); + assert_eq!(k.rgba, red); + assert!(k.x0 == k.x1 || k.y0 == k.y1, "a side, not the diagonal"); + } + // Each fill once: a second call outlines nothing more. + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + s.push_triangle( + [5.0, 0.0], + [6.0, 0.0], + [6.0, 1.0], + pack_rgba(255, 0, 0, 128), + attr, + ); + s.push_triangle( + [7.0, 0.0], + [8.0, 0.0], + [8.0, 1.0], + red, + pack_attr(3, 25, FILL_FLAG_BACKGROUND), + ); + s.edge_fills(); + assert_eq!(s.strokes.len(), 4); + } + + /// Translucent geometry is drawn in draw order, fills and strokes + /// interleaved, wherever it was pushed from. + #[test] + fn translucent_geometry_keeps_draw_order() { + let mut s = Scene::new(); + let clear = pack_rgba(255, 0, 0, 128); + for (order, fill) in [(3, false), (1, true), (2, false), (4, true), (0, false)] { + s.order = order; + if fill { + s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); + } else { + s.push_stroke(0.0, 0.0, 1.0, 0.0, clear, 0); + } + } + // An opaque stroke elsewhere gives the stroke table a bucket first. + s.order = 9; + s.push_stroke(50.0, 50.0, 60.0, 50.0, pack_rgba(0, 0, 0, 255), 0); + let b = s.buffers(); + let orders: Vec = b + .strokes + .chunks(STROKE_BYTES) + .map(|c| u32s(&c[24..28])[0]) + .collect(); + assert_eq!(orders, [9, 0, 2, 3]); + // Strokes 0, fill 1, strokes 2 and 3, fill 4: stroke tiles from 1 + // (past the opaque bucket), fill tiles from 0. + assert_eq!( + u32s(&b.translucent_runs), + [0, 1, 2, 1, 0, 1, 0, 2, 3, 1, 1, 2] + ); + let tiles = u32s(&b.stroke_tiles); + let starts: Vec = tiles.chunks(6).map(|t| t[0]).collect(); + let counts: Vec = tiles.chunks(6).map(|t| t[1]).collect(); + assert_eq!((starts, counts), (vec![0, 1, 2], vec![1, 1, 2])); + } + + /// Past the most runs, the fills then the strokes. + #[test] + fn translucent_runs_have_a_ceiling() { + let mut s = Scene::new(); + let clear = pack_rgba(255, 0, 0, 128); + for i in 0..MAX_TRANSLUCENT_RUNS as u32 { + s.order = 2 * i; + s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], clear, 0); + s.order = 2 * i + 1; + s.push_stroke(0.0, 0.0, 1.0, 0.0, clear, 0); + } + let runs = u32s(&s.buffers().translucent_runs); + assert_eq!(runs, [1, 0, 1, 0, 0, 1]); + } + #[test] fn the_contrast_flag_and_the_fade_code_share_one_byte() { // Both live in the flags byte, so a hatch on colour 7 has to carry its // spacing and its flip without either eating the other. - for spacing in [1.5f64, 16.0, 500.0, 50_000.0] { + for spacing in [0.01, 0.5, 1.5f64, 16.0, 500.0, 50_000.0] { let fade = encode_fade_spacing(spacing); assert!( fade > 0 && fade < FLAG_CONTRAST, @@ -765,18 +1264,18 @@ mod tests { assert_eq!(a & 0xFFFF, 7); assert_eq!((a >> 16) & 0xFF, 25); } - assert_eq!(encode_fade_spacing(0.5), 0); + assert_eq!(encode_fade_spacing(0.0), 0); } #[test] fn the_fade_code_still_resolves_a_useful_spacing_range() { - // Seven bits, eight steps to the octave: a millimetre up to tens of - // metres, which is every hatch spacing a drawing uses. - let decode = |code: u8| (code as f64 / 8.0).exp2(); - for spacing in [2.0f64, 40.0, 5000.0] { + // Seven bits, five steps to the octave from 2^-9: a pattern + // millimetres apart in metres, or metres apart in millimetres. + let decode = |code: u8| (code as f64 / 5.0 - 9.0).exp2(); + for spacing in [0.003f64, 0.05, 0.5, 2.0, 40.0, 5000.0, 80_000.0] { let back = decode(encode_fade_spacing(spacing)); assert!( - (back / spacing - 1.0).abs() < 0.05, + (back / spacing - 1.0).abs() < 0.075, "{spacing} came back as {back}" ); } @@ -796,7 +1295,7 @@ mod tests { let o = s.origin(); assert!((o[0] - 6_500_000.5).abs() < 1e-6); - let buf = s.stroke_buffer(); + let buf = s.buffers().strokes; let x0 = f32::from_le_bytes(buf[0..4].try_into().unwrap()); let x1 = f32::from_le_bytes(buf[8..12].try_into().unwrap()); // Relative coordinates keep the 1-unit separation intact. @@ -814,13 +1313,66 @@ mod tests { ); } + /// A circle is four quarter arcs, its extents its own box exactly; an + /// arc inside a clip stays one, one across it is cut along its curve. + #[test] + fn arcs_are_strokes_of_a_quarter_turn() { + let mut s = Scene::new(); + s.push_arc(10.0, 0.0, 2.0, 0.0, std::f64::consts::TAU, 0, 0); + assert_eq!(s.strokes.len(), 4); + let quarter = (std::f64::consts::PI / 8.0).tan() as f32; + assert!(s.strokes.iter().all(|k| (k.bulge - quarter).abs() < 1e-6)); + let o = s.origin(); + assert_eq!( + s.extents_local().map(|v| (v * 1e4).round() / 1e4), + [-2.0, -2.0, 2.0, 2.0] + ); + assert_eq!(o, [10.0, 0.0]); + // The encoding carries the bulge last. + let b = s.buffers(); + let at = STROKE_BYTES - 4; + assert_eq!( + f32::from_le_bytes(b.strokes[at..at + 4].try_into().unwrap()), + quarter + ); + + let mut s = Scene::new(); + s.push_arc(0.0, 0.0, 1.0, 0.0, std::f64::consts::FRAC_PI_2, 0, 0); + let mark = Mark { + strokes: 0, + fills: 0, + texts: 0, + }; + s.clip_since( + mark, + &super::super::clip::ClipShape::rect([0.0, 0.0], 10.0, 10.0), + ); + assert_eq!(s.strokes.len(), 1, "inside: still one arc"); + s.clip_since( + mark, + &super::super::clip::ClipShape::rect([1.0, 0.0], 1.0, 10.0), + ); + assert!( + s.strokes.len() > 1 + && s.strokes + .iter() + .all(|k| k.bulge == 0.0 && k.x0 >= 0.5 - 1e-9) + ); + // On the curve, to the flattening's sagitta. + assert!(s + .strokes + .iter() + .all(|k| (k.x0.hypot(k.y0) - 1.0).abs() < 2e-3)); + } + #[test] fn buffers_have_the_documented_stride() { let mut s = Scene::new(); s.push_stroke(0.0, 0.0, 1.0, 1.0, 0, 0); s.push_triangle([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], 0, 0); - assert_eq!(s.stroke_buffer().len(), STROKE_BYTES); - assert_eq!(s.fill_buffer().len(), 3 * FILL_BYTES); + let b = s.buffers(); + assert_eq!(b.strokes.len(), STROKE_BYTES); + assert_eq!(b.fills.len(), 3 * FILL_BYTES); } #[test] diff --git a/crates/exav-render/src/formats/dwg/tessellate.rs b/crates/exav-render/src/formats/dwg/tessellate.rs index c83f158..bedfe8d 100644 --- a/crates/exav-render/src/formats/dwg/tessellate.rs +++ b/crates/exav-render/src/formats/dwg/tessellate.rs @@ -1,6 +1,6 @@ //! Walk a drawing model and turn drawable entities into GPU buffers. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::cad::{ Color, Drawing as CadDrawing, Edge, Entity, EntityKind, HAlign, Handle, LineWeight, @@ -9,6 +9,7 @@ use crate::cad::{ use super::clip::ClipShape; use super::curves; +use super::dimension; use super::fill; use super::font; use super::gradient::{self, Gradient}; @@ -20,6 +21,7 @@ use super::scene::{ encode_fade_spacing, pack_attr, pack_rgba, Affine, Scene, FILL_FLAG_BACKGROUND, FLAG_CONTRAST, }; use super::text; +use super::wide::{self, WideVertex}; fn h_align_of(a: HAlign) -> text::HAlign { match a { @@ -94,6 +96,230 @@ fn island_style(h: &crate::cad::Hatch) -> fill::IslandStyle { /// step AutoCAD's own editor appears to use and is the width of a typical list /// indent; it is a guess, and it is only ever reached by text that tabs without /// declaring where to. +/// An arrowhead one unit long, its tip at the origin pointing along +x: +/// triangles to fill and lines to stroke. +struct ArrowShape { + filled: Vec<[f64; 2]>, + lines: Vec>, +} + +/// AutoCAD's built-in arrowheads by block name (`_OPEN`...); an empty or +/// unknown name is the closed filled one. +fn arrow_shape(name: &str) -> ArrowShape { + let circle = |r: f64, cx: f64| -> Vec<[f64; 2]> { + let mut c = curves::flatten_circle(cx, 0.0, r); + if let Some(&first) = c.first() { + c.push(first); + } + c + }; + let disc = |r: f64| -> Vec<[f64; 2]> { + let ring = curves::flatten_circle(0.0, 0.0, r); + (0..ring.len()) + .flat_map(|i| [[0.0, 0.0], ring[i], ring[(i + 1) % ring.len()]]) + .collect() + }; + let triangle = |w: f64, l: f64| vec![[0.0, 0.0], [-l, w], [-l, -w]]; + let (filled, lines) = match name { + "_NONE" => (vec![], vec![]), + "_OPEN" => ( + vec![], + vec![vec![[-1.0, 1.0 / 6.0], [0.0, 0.0], [-1.0, -1.0 / 6.0]]], + ), + "_OPEN30" => ( + vec![], + vec![vec![[-1.0, 0.268], [0.0, 0.0], [-1.0, -0.268]]], + ), + "_OPEN90" => (vec![], vec![vec![[-0.5, 0.5], [0.0, 0.0], [-0.5, -0.5]]]), + "_CLOSEDBLANK" => ( + vec![], + vec![{ + let mut t = triangle(1.0 / 6.0, 1.0); + t.push([0.0, 0.0]); + t + }], + ), + "_CLOSED" => ( + vec![], + vec![ + { + let mut t = triangle(1.0 / 6.0, 1.0); + t.push([0.0, 0.0]); + t + }, + vec![[-1.0, 0.0], [0.0, 0.0]], + ], + ), + "_SMALL" => (triangle(1.0 / 12.0, 0.5), vec![]), + "_DOT" => (disc(0.25), vec![]), + "_DOTSMALL" => (disc(1.0 / 16.0), vec![]), + "_DOTBLANK" => (vec![], vec![circle(0.25, 0.0)]), + "_ORIGIN" => (vec![], vec![circle(0.5, 0.0)]), + "_ORIGIN2" => (vec![], vec![circle(0.5, 0.0), circle(0.25, 0.0)]), + "_OBLIQUE" | "_ARCHTICK" => (vec![], vec![vec![[-0.5, -0.5], [0.5, 0.5]]]), + "_BOXFILLED" => ( + vec![ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + ], + vec![], + ), + "_BOXBLANK" => ( + vec![], + vec![vec![ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + [-0.5, -0.5], + ]], + ), + "_DATUMFILLED" => (vec![[0.0, 0.577], [0.0, -0.577], [-1.0, 0.0]], vec![]), + "_DATUMBLANK" => ( + vec![], + vec![vec![[0.0, 0.577], [0.0, -0.577], [-1.0, 0.0], [0.0, 0.577]]], + ), + _ => (triangle(1.0 / 6.0, 1.0), vec![]), + }; + ArrowShape { filled, lines } +} + +/// A tolerance box's text with its GD&T font runs (`{\Fgdt;j}`, the font +/// code in any case) as the symbols they stand for, the braces gone. +fn gdt_codes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.to_ascii_lowercase().find("\\fgdt;") { + let before = &rest[..at]; + out.push_str(before.strip_suffix('{').unwrap_or(before)); + let after = &rest[at + "\\fgdt;".len()..]; + let end = after.find('}').unwrap_or(after.len()); + out.push_str(&font::map_symbols(&after[..end], font::Symbols::Gdt)); + rest = after.get(end + 1..).unwrap_or(""); + } + out.push_str(rest); + out +} + +/// A polygon mesh's lines: along M and along N, closed as its flags say +/// (1 in M, 32 in N). +fn polygon_mesh_edges(p: &crate::cad::Polyline) -> Vec> { + let (m, n) = (p.m_count.max(0) as usize, p.n_count.max(0) as usize); + let pts: Vec<[f64; 2]> = p + .vertices + .iter() + .map(|v| [v.location.x, v.location.y]) + .collect(); + if m < 1 || n < 1 || pts.len() < m * n { + return Vec::new(); + } + let at = |i: usize, j: usize| pts[i * n + j]; + let mut out = Vec::new(); + for i in 0..m { + let mut row: Vec<[f64; 2]> = (0..n).map(|j| at(i, j)).collect(); + if p.flags & 32 != 0 && n > 2 { + row.push(at(i, 0)); + } + out.push(row); + } + for j in 0..n { + let mut column: Vec<[f64; 2]> = (0..m).map(|i| at(i, j)).collect(); + if p.flags & 1 != 0 && m > 2 { + column.push(at(0, j)); + } + out.push(column); + } + out +} + +/// A polyface mesh's visible edges: each face's sides, but those whose +/// starting index is negative, each side once. +fn polyface_edges(p: &crate::cad::Polyline) -> Vec> { + let corners: Vec<[f64; 2]> = p + .vertices + .iter() + .filter(|v| v.flags & 64 != 0) + .map(|v| [v.location.x, v.location.y]) + .collect(); + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for face in p + .vertices + .iter() + .filter(|v| v.flags & 64 == 0 && v.flags & 128 != 0) + { + let ids: Vec = face + .indices + .iter() + .copied() + .take_while(|&i| i != 0) + .collect(); + for k in 0..ids.len() { + let (a, b) = (ids[k], ids[(k + 1) % ids.len()]); + if a < 0 || ids.len() < 2 { + continue; + } + let (a, b) = (a.unsigned_abs() as usize, b.unsigned_abs() as usize); + let (Some(&pa), Some(&pb)) = (corners.get(a - 1), corners.get(b - 1)) else { + continue; + }; + if seen.insert((a.min(b), a.max(b))) { + out.push(vec![pa, pb]); + } + } + } + out +} + +/// Opacity of the fill drawn for a pattern too dense to draw: about what +/// sub-pixel lines, faded by their spacing, add up to on screen. +const TRUNCATED_PATTERN_TONE: f64 = 0.5; + +/// The scale, turn and mirroring of a transform that keeps circles round +/// (rotation, uniform scale, perhaps a mirror); None for any other. +fn similarity(xf: &Affine) -> Option<(f64, f64, bool)> { + let scale = xf.a.hypot(xf.b); + let tolerance = 1e-9 * scale.max(1e-300); + let turn = xf.b.atan2(xf.a); + if (xf.a - xf.d).abs() <= tolerance && (xf.b + xf.c).abs() <= tolerance { + return (scale > 0.0).then_some((scale, turn, false)); + } + if (xf.a + xf.d).abs() <= tolerance && (xf.b - xf.c).abs() <= tolerance { + return (scale > 0.0).then_some((scale, turn, true)); + } + None +} + +/// A stack's parts against the text height: AutoCAD's default (`TSTACKSIZE`). +const STACK_SIZE: f64 = 0.7; + +/// A run's height: the entity's, times a `\H...x` factor, or a `\H` height. +fn span_height(s: &text::Span, base: f64) -> f64 { + match s.height { + text::SpanHeight::Factor(f) if f > 0.0 => base * f, + text::SpanHeight::Absolute(h) if h > 0.0 => h, + _ => base, + } +} + +/// How wide a stack is drawn: its wider part, or both parts and the slash +/// of a diagonal one. +fn stack_width(s: &text::Stack, metrics: &font::Metrics, height: f64, w: f64) -> f64 { + let part = height * STACK_SIZE; + let (upper, lower) = ( + metrics.measure(&s.upper) * part * w, + metrics.measure(&s.lower) * part * w, + ); + match s.kind { + text::StackKind::Diagonal => upper + 0.5 * part + lower, + _ => upper.max(lower), + } +} + fn next_tab_stop(cursor: f64, paragraph: &text::Paragraph, height: f64) -> f64 { if let Some(stop) = paragraph.tabs.iter().find(|s| **s > cursor + 1e-9) { return *stop; @@ -130,41 +356,69 @@ struct LineTypeDef { labels: Vec, } -/// The text a complex linetype embeds, ready to scale. +/// The text and shapes a complex linetype embeds, ready to scale. /// -/// Shape elements are skipped: they name a glyph in an SHX shape file, which -/// cannot be shipped any more than an SHX font can (DESIGN.md section 5.3). +/// A shape names a glyph in an SHX shape file, which cannot be shipped any +/// more than an SHX font can (DESIGN.md section 5.3): only ltypeshp.shx's, +/// which AutoCAD's own linetypes use, are drawn, redrawn here. fn embedded_labels(doc: &CadDrawing, lt: &crate::cad::Linetype) -> Vec { let mut out = Vec::new(); for (run, e) in lt.elements.iter().enumerate() { - if !e.has_text() || e.text.is_empty() || !e.scale.is_finite() || e.scale <= 0.0 { + if !e.scale.is_finite() || e.scale <= 0.0 { continue; } - // The element's scale multiplies the style's fixed height, or is the - // height itself when the style leaves it free, which is the usual case. let style = doc .text_styles .iter() .find(|s| !e.style.is_null() && s.handle == e.style); - let style_height = style.map(|s| s.height).unwrap_or(0.0); - let height = if style_height > 0.0 { - style_height * e.scale + let (mark, height) = if e.has_text() && !e.text.is_empty() { + // The element's scale multiplies the style's fixed height, or is + // the height itself when the style leaves it free, which is the + // usual case. + let style_height = style.map(|s| s.height).unwrap_or(0.0); + let height = if style_height > 0.0 { + style_height * e.scale + } else { + e.scale + }; + let face = style.map(font::resolve_style).unwrap_or(font::Face::Stroke); + ( + linetype::Mark::Text { + text: text::decode_special(&e.text), + face, + }, + height, + ) + } else if e.has_shape() { + let Some(shape) = style + .filter(|s| is_ltypeshp(&s.font_file)) + .and_then(|_| linetype::ltypeshp_shape(e.shape_number)) + else { + continue; + }; + (linetype::Mark::Shape(shape), e.scale) } else { - e.scale + continue; }; out.push(linetype::Label { run, - text: text::decode_special(&e.text), + mark, height, offset: [e.offset.x, e.offset.y], rotation: e.rotation, absolute_rotation: e.absolute_rotation(), - face: style.map(font::resolve_style).unwrap_or(font::Face::Stroke), }); } out } +/// A style's font file is ltypeshp.shx, under any path or case. +fn is_ltypeshp(file: &str) -> bool { + let name = file.rsplit(['/', '\\']).next().unwrap_or(file); + let stem = name.split('.').next().unwrap_or(name); + stem.eq_ignore_ascii_case("ltypeshp") +} + /// A RAY or XLINE, held back until the drawing's extents are known. struct InfiniteLine { base: [f64; 2], @@ -219,6 +473,32 @@ pub struct LayoutInfo { pub tab_order: i16, /// The sheet, as `[min_x, min_y, max_x, max_y]` in paper units. pub limits: Option<[f64; 4]>, + /// Millimetres per paper unit: 25.4 for a sheet set up in inches. + pub mm_per_unit: f64, +} + +/// What a tab is drawn on, beyond its geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct Sheet { + /// Millimetres per drawing unit, which a lineweight on a layout is a + /// width in: 25.4 for a sheet in inches. 0 for model space, where a + /// lineweight is a width on the screen. + pub mm_per_unit: f64, + /// `$LWDISPLAY`: the drawing is shown with its lineweights. + pub lineweights: bool, + /// A layout's paper, `[min_x, min_y, max_x, max_y]` in drawing units: + /// its limits. None for model space. + pub paper: Option<[f64; 4]>, +} + +impl Default for Sheet { + fn default() -> Self { + Sheet { + mm_per_unit: 0.0, + lineweights: true, + paper: None, + } + } } pub struct LayerInfo { @@ -259,6 +539,33 @@ pub struct Warnings { /// without proxy graphics, or with graphics that draw nothing: only the /// application that made them can show them. pub proxy_without_graphics: u32, + /// What `unknown_entities` counts, by DXF type, at most + /// [`MAX_NAMED`] types. + pub not_drawn: BTreeMap, + /// The files external references name (xrefs, images, underlays), at + /// most [`MAX_NAMED`]. + pub external_files: BTreeSet, +} + +/// Most names a warning lists. +pub const MAX_NAMED: usize = 16; + +impl Warnings { + fn not_drawn(&mut self, kind: &str) { + self.unknown_entities = self.unknown_entities.saturating_add(1); + if let Some(n) = self.not_drawn.get_mut(kind) { + *n = n.saturating_add(1); + } else if self.not_drawn.len() < MAX_NAMED { + self.not_drawn.insert(kind.to_string(), 1); + } + } + + fn external(&mut self, file: &str) { + self.external_references = self.external_references.saturating_add(1); + if !file.is_empty() && self.external_files.len() < MAX_NAMED { + self.external_files.insert(file.to_string()); + } + } } /// Default for [`Tessellator::budget`]: strokes and fill vertices one layout @@ -318,13 +625,14 @@ pub struct Tessellator<'a> { /// because a drawing names a handful of styles and uses them thousands of /// times. style_faces: HashMap, - /// The same, by style handle: entities that reference a style by name are - /// the common case, but leaders and dimensions point at one by handle. - style_faces_by_handle: HashMap, + /// Styles whose font draws symbols for letters (symbol.ttf, gdt.shx). + style_symbols: HashMap, /// Which ACI table indexed colours resolve through. palette: Palette, /// Block names currently being expanded, to break reference cycles. stack: Vec, + /// What the tab drawn last is drawn on. + pub sheet: Sheet, } fn normalize(name: &str) -> String { @@ -449,13 +757,17 @@ impl<'a> Tessellator<'a> { .iter() .map(|s| (normalize(&s.name), font::resolve_style(s))) .collect(), - style_faces_by_handle: doc + style_symbols: doc .text_styles .iter() - .map(|s| (s.handle.0, font::resolve_style(s))) + .filter_map(|s| Some((normalize(&s.name), font::symbols_of(&s.font_file)?))) .collect(), palette, stack: Vec::new(), + sheet: Sheet { + lineweights: doc.header.lwdisplay, + ..Sheet::default() + }, } } @@ -504,60 +816,74 @@ impl<'a> Tessellator<'a> { maxy += pad; for line in std::mem::take(&mut self.pending_infinite) { - let len = (line.dir[0] * line.dir[0] + line.dir[1] * line.dir[1]).sqrt(); - if !len.is_finite() || len < 1e-12 { - continue; - } - let u = [line.dir[0] / len, line.dir[1] / len]; + self.push_infinite(&line, [minx, miny], [maxx, maxy]); + } + } - // Slab clip: the parameter range where the line is inside the box. - let mut t0 = if line.both_ways { - f64::NEG_INFINITY - } else { - 0.0 - }; - let mut t1 = f64::INFINITY; - let mut outside = false; - for (p, d, lo, hi) in [ - (line.base[0], u[0], minx, maxx), - (line.base[1], u[1], miny, maxy), - ] { - if d.abs() < 1e-12 { - if p < lo || p > hi { - outside = true; - } - continue; + /// The part of a ray or xline inside a box, as a stroke. + fn push_infinite(&mut self, line: &InfiniteLine, min: [f64; 2], max: [f64; 2]) { + let len = (line.dir[0] * line.dir[0] + line.dir[1] * line.dir[1]).sqrt(); + if !len.is_finite() || len < 1e-12 { + return; + } + let u = [line.dir[0] / len, line.dir[1] / len]; + + // Slab clip: the parameter range where the line is inside the box. + let mut t0 = if line.both_ways { + f64::NEG_INFINITY + } else { + 0.0 + }; + let mut t1 = f64::INFINITY; + for (p, d, lo, hi) in [ + (line.base[0], u[0], min[0], max[0]), + (line.base[1], u[1], min[1], max[1]), + ] { + if d.abs() < 1e-12 { + if p < lo || p > hi { + return; } - let (a, b) = ((lo - p) / d, (hi - p) / d); - let (a, b) = if a <= b { (a, b) } else { (b, a) }; - t0 = t0.max(a); - t1 = t1.min(b); - } - if outside || !(t1 > t0) || !t0.is_finite() || !t1.is_finite() { continue; } - - self.scene.push_stroke( - line.base[0] + u[0] * t0, - line.base[1] + u[1] * t0, - line.base[0] + u[0] * t1, - line.base[1] + u[1] * t1, - line.rgba, - line.attr, - ); + let (a, b) = ((lo - p) / d, (hi - p) / d); + let (a, b) = if a <= b { (a, b) } else { (b, a) }; + t0 = t0.max(a); + t1 = t1.min(b); + } + if !(t1 > t0) || !t0.is_finite() || !t1.is_finite() { + return; } + + self.scene.push_stroke( + line.base[0] + u[0] * t0, + line.base[1] + u[1] * t0, + line.base[0] + u[0] * t1, + line.base[1] + u[1] * t1, + line.rgba, + line.attr, + ); } /// Leader lines plus the label they point at. fn multi_leader(&mut self, m: &crate::cad::MultiLeader, ctx: &Ctx, rgba: u32, attr: u32) { let c = &m.context; - // Every leader root contributes one or more polylines. + // Every leader root contributes one or more polylines, each with + // its arrowhead at its first vertex. + let scale = if c.scale > 0.0 { c.scale } else { 1.0 }; + let size = m.arrowhead_size * scale; for root in &c.leaders { for line in &root.lines { - let pts: Vec<[f64; 2]> = line.vertices.iter().map(|p| [p.x, p.y]).collect(); + // From the arrow's tip through the line's vertices to where + // the root lands. + let mut pts: Vec<[f64; 2]> = line.vertices.iter().map(|p| [p.x, p.y]).collect(); + let landing = [root.connection_point.x, root.connection_point.y]; + if pts.last() != Some(&landing) && (root.has_connection_point || pts.len() < 2) { + pts.push(landing); + } if pts.len() >= 2 { self.push_transformed(&pts, false, &ctx.xf, rgba, attr, None); + self.arrowhead(m.arrowhead, pts[0], pts[1], size, ctx, rgba, attr); } } // The landing runs from the last leader point to the content. @@ -582,47 +908,32 @@ impl<'a> Tessellator<'a> { } else { 2.5 }; - let spacing = height - * text::MTEXT_LINE_SPACING - * if c.line_spacing_factor > 0.0 { - c.line_spacing_factor - } else { - 1.0 - }; - let lines = text::mtext_lines(&c.text); - let face = self.style_face_by_handle(c.text_style); - // Attachment codes match MTEXT's. - let (h_align, dy0) = - text::mtext_layout(c.text_attachment as u8, lines.len(), height, spacing); - let (sin_r, cos_r) = rotation.sin_cos(); let text_rgba = match c.text_color { Color::ByLayer | Color::ByBlock => rgba, ref col => colour_rgb(col, self.palette) - .map(|(r, g, b)| pack_rgba(r, g, b, 255)) + .map(|(r, g, b)| pack_rgba(r, g, b, (rgba >> 24) as u8)) .unwrap_or(rgba), }; - - for (i, line) in lines.iter().enumerate() { - if line.is_empty() { - continue; - } - let dy = dy0 - i as f64 * spacing; - self.push_text_run( - line.clone(), - c.text_location.x - dy * sin_r, - c.text_location.y + dy * cos_r, - height, - rotation, - 1.0, - 0.0, - h_align, - text::VAlign::Baseline, - ctx, - text_rgba, - attr, - face, - ); - } + // The content is MTEXT, drawn as one (attachment codes match). + let style = self + .doc + .text_styles + .iter() + .find(|s| s.handle == c.text_style && !c.text_style.is_null()) + .map(|s| s.name.clone()) + .unwrap_or_default(); + let content = crate::cad::MText { + insertion: c.text_location, + height, + reference_width: c.text_width, + attachment: c.text_attachment, + style, + rotation, + line_spacing_factor: c.line_spacing_factor, + text: c.text.clone(), + ..crate::cad::MText::default() + }; + self.mtext(&content, ctx, text_rgba, attr); } } @@ -717,13 +1028,14 @@ impl<'a> Tessellator<'a> { .unwrap_or(font::Face::Stroke) } - /// The same, for an entity that points at its style by handle. - fn style_face_by_handle(&self, handle: Handle) -> font::Face { - self.style_faces_by_handle - .get(&handle.0) - .copied() - .filter(|_| !handle.is_null()) - .unwrap_or(font::Face::Stroke) + /// A TEXT's or ATTRIB's string as its style shows it: the `%%` codes + /// decoded, then a symbol font's letters as the symbols they draw. + fn style_text(&self, style: &str, raw: &str) -> String { + let s = text::decode_special(raw); + match self.style_symbols.get(&normalize(style)) { + Some(&set) => font::map_symbols(&s, set), + None => s, + } } /// MTEXT content as the lines it is drawn on: the explicit `\P` breaks, @@ -755,138 +1067,338 @@ impl<'a> Tessellator<'a> { .collect() } - /// Draw one laid-out MTEXT line, honouring per-run colour, height and rules. + /// A style's width factor and oblique angle, which MTEXT takes unless + /// `\W` and `\Q` say otherwise. + fn style_shape(&self, style: &str) -> (f64, f64) { + self.doc + .text_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(style)) + .map(|s| { + let w = if s.width_factor > 0.0 { + s.width_factor + } else { + 1.0 + }; + (w, s.oblique) + }) + .unwrap_or((1.0, 0.0)) + } + + /// An MTEXT, or a multiline attribute: its lines wrapped to its box and + /// stacked from the attachment point, each aligned as its paragraph + /// says, over its background mask when it has one. + fn mtext(&mut self, m: &crate::cad::MText, ctx: &Ctx, rgba: u32, attr: u32) { + let height = if m.height > 0.0 { m.height } else { 1.0 }; + let face = self.style_face(&m.style); + let shape = self.style_shape(&m.style); + let mut lines = self.wrap_mtext(&m.text, m.reference_width / shape.0, height, face); + if let Some(&set) = self.style_symbols.get(&normalize(&m.style)) { + for s in lines + .iter_mut() + .flat_map(|l| l.spans.iter_mut()) + .filter(|s| s.face.is_none()) + { + s.text = font::map_symbols(&s.text, set); + } + } + let spacing = height + * text::MTEXT_LINE_SPACING + * if m.line_spacing_factor > 0.0 { + m.line_spacing_factor + } else { + 1.0 + }; + + // DWG stores the text direction as a vector when it has one. + let rotation = match m.x_direction { + Some(d) if d.x != 0.0 || d.y != 0.0 => d.y.atan2(d.x), + _ => m.rotation, + }; + let (h_align, dy0) = text::mtext_layout(m.attachment as u8, lines.len(), height, spacing); + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + m.insertion.x + along * cos_r - up * sin_r, + m.insertion.y + along * sin_r + up * cos_r, + ] + }; + + // The box lines align in and the mask covers: the entity's width, + // else its widest line. + let realigned = lines + .iter() + .any(|l| l.paragraph.align.is_some_and(|a| a != h_align)); + let masked = m.background_fill & 0x13 != 0; + let box_width = if m.reference_width > 0.0 { + m.reference_width + } else if realigned || masked { + lines + .iter() + .map(|l| self.mtext_line_layout(l, height, face, shape.0).1) + .fold(0.0, f64::max) + } else { + 0.0 + }; + let left = match h_align { + text::HAlign::Center | text::HAlign::Middle => -box_width / 2.0, + text::HAlign::Right => -box_width, + _ => 0.0, + }; + + if masked { + // A border of (scale - 1) heights around the lines, from the + // first line's capitals to the last line's baseline. + let scale = if m.background_scale > 0.0 { + m.background_scale + } else { + 1.5 + }; + let border = height * (scale - 1.0).max(0.0); + let top = dy0 + height + border; + let bottom = dy0 - (lines.len().max(1) - 1) as f64 * spacing - border; + let corners: Vec<[f64; 2]> = [ + (left - border, bottom), + (left + box_width + border, bottom), + (left + box_width + border, top), + (left - border, top), + ] + .iter() + .map(|&(a, b)| { + let p = at(a, b); + ctx.xf.apply(p[0], p[1]) + }) + .collect(); + self.mtext_background(m, &corners, rgba, attr); + // The text over its own mask: one step later in draw order. + self.scene.order = self.scene.order.saturating_add(1); + if m.background_fill & 0x10 != 0 { + self.scene.push_polyline(&corners, true, rgba, attr); + } + } + + for (i, line) in lines.iter().enumerate() { + if line.spans.is_empty() { + continue; + } + // Lines stack along the text's own down direction. + let dy = dy0 - i as f64 * spacing; + let (align, along) = match line.paragraph.align { + Some(a) if a != h_align && box_width > 0.0 => ( + a, + left + match a { + text::HAlign::Center => box_width / 2.0, + text::HAlign::Right => box_width, + _ => 0.0, + }, + ), + _ => (h_align, 0.0), + }; + let p = at(along, dy); + self.mtext_line( + line, height, p, rotation, align, shape, ctx, rgba, attr, face, + ); + } + } + + /// An MTEXT's background: its colour (group 90 bit 1) or the drawing + /// area's (bit 2), as a WIPEOUT paints it. + fn mtext_background( + &mut self, + m: &crate::cad::MText, + corners: &[[f64; 2]], + rgba: u32, + attr: u32, + ) { + let base = attr & 0x00FF_FFFF; + let colour = (m.background_fill & 2 == 0) + .then(|| colour_rgb(&m.background_color, self.palette)) + .flatten(); + let (fill, fill_attr) = match colour { + Some((r, g, b)) => (pack_rgba(r, g, b, 255), base), + None => (rgba, base | u32::from(FILL_FLAG_BACKGROUND) << 24), + }; + for t in fill::fill_even_odd(&[corners.to_vec()]).chunks(3) { + if t.len() == 3 { + self.scene.push_triangle(t[0], t[1], t[2], fill, fill_attr); + } + } + } + + /// Where each run of an MTEXT line goes, from the line's left edge: the + /// runs as (span, text, start, width), and the line's width. A tab stop + /// is measured from that edge, so alignment comes after. + fn mtext_line_layout( + &self, + line: &text::Line, + base_height: f64, + face: font::Face, + base_width: f64, + ) -> (Vec<(usize, String, f64, f64)>, f64) { + let mut placed = Vec::new(); + // The paragraph's indents; a first line can be indented differently + // from the rest, which is what makes a numbered list hang. + let mut cursor = line.paragraph.indent + + if line.first { + line.paragraph.first_indent + } else { + 0.0 + }; + for (i, span) in line.spans.iter().enumerate() { + let height = span_height(span, base_height); + let w = span.width.unwrap_or(base_width); + // Each run is measured in its own face, since a `\f` in the + // middle of a line changes how wide the rest of it sits. + let metrics = font::Metrics::for_face(span.face.unwrap_or(face)); + if let Some(stack) = &span.stack { + let width = stack_width(stack, &metrics, height, w); + placed.push((i, String::new(), cursor, width)); + cursor += width; + continue; + } + for (n, piece) in span.text.split('\t').enumerate() { + if n > 0 { + cursor = next_tab_stop(cursor, &line.paragraph, base_height); + } + if piece.is_empty() { + continue; + } + let width = metrics.measure(piece) * height * w * span.tracking.unwrap_or(1.0); + placed.push((i, piece.to_string(), cursor, width)); + cursor += width; + } + } + (placed, cursor) + } + + /// Draw one laid-out MTEXT line, honouring per-run colour, height, width + /// factor, slant, tracking, stacks and rules. /// - /// A line of one run is handed to the renderer to align, which measures the - /// string with the real font. A line of several has to be placed here, - /// because each run starts where the one before it ended. + /// A line of plain text is handed to the renderer to align, which + /// measures the string with the real font. Any other has to be placed + /// here, because each run starts where the one before it ended. #[allow(clippy::too_many_arguments)] - fn push_mtext_line( + fn mtext_line( &mut self, line: &text::Line, base_height: f64, - x: f64, - y: f64, + [x, y]: [f64; 2], rotation: f64, h_align: text::HAlign, + (base_width, base_oblique): (f64, f64), ctx: &Ctx, rgba: u32, attr: u32, face: font::Face, ) { let spans = &line.spans; - let height_of = |s: &text::Span| match s.height { - text::SpanHeight::Factor(f) if f > 0.0 => base_height * f, - text::SpanHeight::Absolute(h) if h > 0.0 => h, - _ => base_height, + let plain = |s: &text::Span| { + s.color.is_none() + && !s.underline + && !s.overline + && !s.strike + && s.width.is_none() + && s.oblique.is_none() + && s.tracking.is_none() + && s.stack.is_none() + && !s.text.contains('\t') }; - // A `\f` run draws in the face it names; the rest take the entity's. - let face_of = |s: &text::Span| s.face.unwrap_or(face); - - // Where the line starts, from its paragraph's indents. The first line - // of a paragraph can be indented differently from the rest, which is - // what makes a numbered list hang. - let indent = line.paragraph.indent - + if line.first { - line.paragraph.first_indent - } else { - 0.0 - }; - let tabbed = spans.iter().any(|s| s.text.contains('\t')); - - let styled = tabbed - || indent != 0.0 - || spans.iter().any(|s| { - s.color.is_some() - || s.face.is_some_and(|f| f != face) - || s.underline - || s.overline - || s.strike - || height_of(s) != base_height - }); + let indented = + line.paragraph.indent != 0.0 || line.first && line.paragraph.first_indent != 0.0; + let first = &spans[0]; + let one_style = spans.iter().all(|s| { + s.face.unwrap_or(face) == first.face.unwrap_or(face) + && span_height(s, base_height) == span_height(first, base_height) + }); // Nothing to place: hand the whole line over as one run. - if !styled - || spans.len() == 1 - && !spans[0].underline - && !spans[0].overline - && !spans[0].strike - && spans[0].color.is_none() - { + if !indented && one_style && spans.iter().all(plain) { let text: String = spans.iter().map(|s| s.text.as_str()).collect(); - let height = spans.first().map(height_of).unwrap_or(base_height); - let face = spans.first().map(face_of).unwrap_or(face); self.push_text_run( text, x, y, - height, - rotation, - 1.0, - 0.0, + text::frame( + span_height(first, base_height), + rotation, + base_width, + base_oblique, + ), h_align, text::VAlign::Baseline, ctx, rgba, attr, - face, + first.face.unwrap_or(face), ); return; } - // Lay the line out from its own left edge first. A tab stop is measured - // from there, not from the anchor, so alignment can only be applied - // once the whole line has been placed. - let mut placed: Vec<(usize, String, f64, f64)> = Vec::new(); - let mut cursor = indent; - for (i, span) in spans.iter().enumerate() { - let height = height_of(span); - for (n, piece) in span.text.split('\t').enumerate() { - if n > 0 { - cursor = next_tab_stop(cursor, &line.paragraph, base_height); - } - if piece.is_empty() { - continue; - } - // Each run is measured in its own face, since a `\f` in the - // middle of a line changes how wide the rest of it sits. - let width = font::Metrics::for_face(face_of(span)).measure(piece) * height; - placed.push((i, piece.to_string(), cursor, width)); - cursor += width; - } - } - + let (placed, total) = self.mtext_line_layout(line, base_height, face, base_width); let offset = match h_align { - text::HAlign::Center | text::HAlign::Middle => -cursor / 2.0, - text::HAlign::Right => -cursor, + text::HAlign::Center | text::HAlign::Middle => -total / 2.0, + text::HAlign::Right => -total, _ => 0.0, }; let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + x + along * cos_r - up * sin_r, + y + along * sin_r + up * cos_r, + ] + }; for (i, piece, start, width) in placed { let span = &spans[i]; let along = start + offset; - let width = &width; - let height = height_of(span); + let height = span_height(span, base_height); let (rgba, attr) = match span.color { Some(c) => self.span_colour(c, rgba, attr), None => (rgba, attr), }; - self.push_text_run( - piece, - x + along * cos_r, - y + along * sin_r, - height, - rotation, - 1.0, - 0.0, - text::HAlign::Left, - text::VAlign::Baseline, - ctx, - rgba, - attr, - face_of(span), - ); + let face = span.face.unwrap_or(face); + let w = span.width.unwrap_or(base_width); + let frame = text::frame(height, rotation, w, span.oblique.unwrap_or(base_oblique)); + if let Some(stack) = &span.stack { + let shape = (w, span.oblique.unwrap_or(base_oblique)); + self.mtext_stack( + stack, + at(along, 0.0), + rotation, + height, + shape, + width, + ctx, + rgba, + attr, + face, + ); + } else if let Some(t) = span.tracking.filter(|t| *t != 1.0) { + // Each character at its advance times the tracking. + let metrics = font::Metrics::for_face(face); + let mut pen = along; + for c in piece.chars() { + let p = at(pen, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run( + c.to_string(), + p[0], + p[1], + frame, + l, + b, + ctx, + rgba, + attr, + face, + ); + pen += metrics.advance(c) * height * w * t; + } + } else { + let p = at(along, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(piece, p[0], p[1], frame, l, b, ctx, rgba, attr, face); + } // The three rules, as strokes along the run at the usual offsets. for (on, dy) in [ @@ -897,19 +1409,76 @@ impl<'a> Tessellator<'a> { if !on { continue; } - let a = ctx.xf.apply( - x + along * cos_r - dy * sin_r, - y + along * sin_r + dy * cos_r, - ); - let b = ctx.xf.apply( - x + (along + width) * cos_r - dy * sin_r, - y + (along + width) * sin_r + dy * cos_r, - ); + let (a, b) = (at(along, dy), at(along + width, dy)); + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); } } } + /// A stack on the baseline at `p`, `width` wide (`stack_width`): its + /// parts at [`STACK_SIZE`] of the height, one above the other about the + /// capitals' middle (across a bar for a fraction, left-aligned for a + /// tolerance), or side by side across a slash. + #[allow(clippy::too_many_arguments)] + fn mtext_stack( + &mut self, + s: &text::Stack, + p: [f64; 2], + rotation: f64, + height: f64, + (w, oblique): (f64, f64), + width: f64, + ctx: &Ctx, + rgba: u32, + attr: u32, + face: font::Face, + ) { + let part = height * STACK_SIZE; + let metrics = font::Metrics::for_face(face); + let wide = |t: &str| metrics.measure(t) * part * w; + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |along: f64, up: f64| { + [ + p[0] + along * cos_r - up * sin_r, + p[1] + along * sin_r + up * cos_r, + ] + }; + let frame = text::frame(part, rotation, w, oblique); + // Where each part's baseline starts, and the bar or slash. + let (upper, lower, line) = match s.kind { + text::StackKind::Diagonal => { + let slant = 0.5 * part; + let wu = wide(&s.upper); + ( + (0.0, height - part), + (wu + slant, 0.0), + Some(((wu, 0.0), (wu + slant, height))), + ) + } + kind => { + let (bar, gap) = (0.5 * height, 0.2 * part); + let centre = kind == text::StackKind::Fraction; + let x = |t: &str| if centre { (width - wide(t)) / 2.0 } else { 0.0 }; + ( + (x(&s.upper), bar + gap), + (x(&s.lower), bar - gap - part), + centre.then_some(((0.0, bar), (width, bar))), + ) + } + }; + for (t, (along, up)) in [(&s.upper, upper), (&s.lower, lower)] { + let q = at(along, up); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(t.clone(), q[0], q[1], frame, l, b, ctx, rgba, attr, face); + } + if let Some(((a0, a1), (b0, b1))) = line { + let (a, b) = (at(a0, a1), at(b0, b1)); + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + } + /// Resolve an MTEXT colour escape against the entity's own colour. fn span_colour(&self, c: text::SpanColor, rgba: u32, attr: u32) -> (u32, u32) { let alpha = (rgba >> 24) as u8; @@ -970,8 +1539,17 @@ impl<'a> Tessellator<'a> { && limits[2] > limits[0] && limits[3] > limits[1]) .then_some(limits), + // Group 72: 0 inches, 1 millimetres, 2 pixels (no physical + // size, taken as millimetres). + mm_per_unit: if l.plot.paper_units == 0 { 25.4 } else { 1.0 }, }); } + // Without LAYOUT objects, the sheet is in the drawing's own system. + let mm_per_unit = if self.doc.header.measurement == 0 { + 25.4 + } else { + 1.0 + }; // Some drawings carry no LAYOUT objects at all (R13 and R14 DWG // saved by AutoCAD before layouts): model space still draws, and so @@ -984,6 +1562,7 @@ impl<'a> Tessellator<'a> { is_model: true, tab_order: 0, limits: None, + mm_per_unit, }); } let paper = self @@ -1002,6 +1581,7 @@ impl<'a> Tessellator<'a> { && limits[2] > limits[0] && limits[3] > limits[1]) .then_some(limits), + mm_per_unit, }); } out.sort_by_key(|l| (!l.is_model, l.tab_order)); @@ -1018,10 +1598,16 @@ impl<'a> Tessellator<'a> { return false; }; let block = layout.block.clone(); + if !layout.is_model { + self.sheet.mm_per_unit = layout.mm_per_unit; + } self.run_block(&block, !layout.is_model); // The sheet, not the contents: see `Scene::set_extents`. if let Some(l) = layout.limits.filter(|_| !layout.is_model) { self.scene.set_extents([l[0], l[1]], [l[2], l[3]]); + if l[2] > l[0] && l[3] > l[1] { + self.sheet.paper = Some(l); + } } true } @@ -1071,6 +1657,7 @@ impl<'a> Tessellator<'a> { if paper { self.scene.recompute_extents(); } + self.scene.focus_on_core(); } fn root_ctx(&self) -> Ctx { @@ -1292,19 +1879,21 @@ impl<'a> Tessellator<'a> { // Colour 7 flips with the background; every other colour, including a // true colour that happens to be white, is drawn as stored. - let (rgba, contrast) = match common.color { - Color::ByLayer => (layer.rgba, layer.contrast), - Color::ByBlock => (ctx.rgba, ctx.contrast), + let (rgb, contrast) = match common.color { + Color::ByLayer => (layer.rgba & 0x00ff_ffff, layer.contrast), + Color::ByBlock => (ctx.rgba & 0x00ff_ffff, ctx.contrast), ref c => { let (r, g, b) = colour_rgb(c, self.palette)?; - let alpha = match common.transparency { - Transparency::Alpha(a) => a, - Transparency::ByBlock => (ctx.rgba >> 24) as u8, - Transparency::ByLayer => (layer.rgba >> 24) as u8, - }; - (pack_rgba(r, g, b, alpha), is_contrast_color(c)) + (pack_rgba(r, g, b, 0), is_contrast_color(c)) } }; + // Transparency resolves on its own, whatever the colour does. + let alpha = match common.transparency { + Transparency::Alpha(a) => a, + Transparency::ByBlock => (ctx.rgba >> 24) as u8, + Transparency::ByLayer => (layer.rgba >> 24) as u8, + }; + let rgba = rgb | u32::from(alpha) << 24; let flags = if contrast { FLAG_CONTRAST } else { 0 }; let lw = match common.lineweight { @@ -1317,6 +1906,11 @@ impl<'a> Tessellator<'a> { } fn entity(&mut self, e: &Entity, outer: &Ctx) { + self.entity_geometry(e, outer); + self.scene.edge_fills(); + } + + fn entity_geometry(&mut self, e: &Entity, outer: &Ctx) { // Advance the draw order for every entity, drawn or not, so the // sequence matches the document's own. self.scene.order = self.scene.order.saturating_add(1); @@ -1361,19 +1955,22 @@ impl<'a> Tessellator<'a> { } EntityKind::Circle(c) => { - let pts = curves::flatten_circle(c.center.x, c.center.y, c.radius); - self.push_transformed(&pts, false, xf, rgba, attr, pattern); + let tau = std::f64::consts::TAU; + self.push_curve( + c.center.x, c.center.y, c.radius, 0.0, tau, xf, rgba, attr, pattern, + ); } EntityKind::Arc(a) => { - let pts = curves::flatten_arc( - a.center.x, - a.center.y, - a.radius, - a.start_angle, - a.end_angle, - ); - self.push_transformed(&pts, false, xf, rgba, attr, pattern); + // DWG arcs always run counter-clockwise from start to end. + let sweep = (a.end_angle - a.start_angle).rem_euclid(std::f64::consts::TAU); + let sweep = if sweep > 0.0 { + sweep + } else { + std::f64::consts::TAU + }; + let (c, r) = (a.center, a.radius); + self.push_curve(c.x, c.y, r, a.start_angle, sweep, xf, rgba, attr, pattern); } EntityKind::Ellipse(el) => { @@ -1390,25 +1987,49 @@ impl<'a> Tessellator<'a> { } EntityKind::LwPolyline(p) => { - let pts: Vec<([f64; 2], f64)> = p + // Group 43, the constant width, stands for every vertex's. + let wide: Vec = p .vertices .iter() - .map(|v| ([v.point.x, v.point.y], v.bulge)) + .map(|v| { + let (w0, w1) = if p.constant_width > 0.0 { + (p.constant_width, p.constant_width) + } else { + (v.start_width, v.end_width) + }; + WideVertex { + p: [v.point.x, v.point.y], + bulge: v.bulge, + start_width: w0, + end_width: w1, + } + }) .collect(); - let closed = p.is_closed(); - let flat = expand_bulges(&pts, closed); - self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + let pattern = pattern.map(|q| (q, p.flags & 128 != 0)); + self.polyline(&wide, p.is_closed(), xf, rgba, attr, pattern); } EntityKind::Polyline(p) if p.kind() == PolylineKind::Polyline2D => { - let pts: Vec<([f64; 2], f64)> = p + // A vertex without widths of its own takes the polyline's. + let wide: Vec = p .vertices .iter() - .map(|v| ([v.location.x, v.location.y], v.bulge)) + .map(|v| { + let (w0, w1) = if v.start_width == 0.0 && v.end_width == 0.0 { + (p.default_start_width, p.default_end_width) + } else { + (v.start_width, v.end_width) + }; + WideVertex { + p: [v.location.x, v.location.y], + bulge: v.bulge, + start_width: w0, + end_width: w1, + } + }) .collect(); - let closed = p.is_closed(); - let flat = expand_bulges(&pts, closed); - self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + let pattern = pattern.map(|q| (q, p.flags & 128 != 0)); + self.polyline(&wide, p.is_closed(), xf, rgba, attr, pattern); } EntityKind::Polyline(p) if p.kind() == PolylineKind::Polyline3D => { @@ -1425,11 +2046,7 @@ impl<'a> Tessellator<'a> { self.push_transformed(&pts, false, xf, rgba, attr, pattern); } - EntityKind::Point(p) => { - // Zero-length stroke; the renderer draws it as a round dot. - let a = xf.apply(p.location.x, p.location.y); - self.scene.push_stroke(a[0], a[1], a[0], a[1], rgba, attr); - } + EntityKind::Point(p) => self.point(p, xf, rgba, attr), EntityKind::Solid(s) | EntityKind::Trace(s) => { // SOLID and TRACE store their corners in Z order, so corners 3 @@ -1453,8 +2070,50 @@ impl<'a> Tessellator<'a> { xf.apply(c[2].x, c[2].y), xf.apply(c[3].x, c[3].y), ]; - // 3DFACE is an outline in a 2D view, not a filled region. - self.push_path(&q, true, rgba, attr, pattern); + // 3DFACE is an outline in a 2D view, not a filled region, + // without the edges its flags hide. + if f.invisible_edges & 0x0F == 0 { + self.push_path(&q, true, rgba, attr, pattern); + } else { + for i in 0..4 { + if f.invisible_edges & (1 << i) == 0 { + self.push_path(&[q[i], q[(i + 1) % 4]], false, rgba, attr, pattern); + } + } + } + } + + // Meshes in plan: their edges. + EntityKind::Mesh(m) => { + let mut seen = HashSet::new(); + for f in &m.faces { + for (k, &a) in f.iter().enumerate() { + let b = f[(k + 1) % f.len()]; + if !seen.insert((a.min(b), a.max(b))) { + continue; + } + let (pa, pb) = (m.vertices[a as usize], m.vertices[b as usize]); + self.push_transformed( + &[[pa.x, pa.y], [pb.x, pb.y]], + false, + xf, + rgba, + attr, + pattern, + ); + } + } + } + EntityKind::Tolerance(t) => self.tolerance(t, ctx, rgba, attr), + EntityKind::Polyline(p) if p.kind() == PolylineKind::PolygonMesh => { + for edge in polygon_mesh_edges(p) { + self.push_transformed(&edge, false, xf, rgba, attr, pattern); + } + } + EntityKind::Polyline(p) if p.kind() == PolylineKind::PolyfaceMesh => { + for edge in polyface_edges(p) { + self.push_transformed(&edge, false, xf, rgba, attr, pattern); + } } EntityKind::MLine(m) => { @@ -1521,6 +2180,12 @@ impl<'a> Tessellator<'a> { EntityKind::Insert(ins) => self.insert(ins, ctx, lid, rgba, contrast, lw, lt), + EntityKind::Dimension(d) + if d.block_name.is_empty() || self.block(&d.block_name).is_none() => + { + self.dimension(d, ctx, rgba, attr); + } + EntityKind::Dimension(d) => { // AutoCAD bakes dimension graphics into an anonymous block. // Render that instead of reimplementing the dimension engine. @@ -1555,49 +2220,23 @@ impl<'a> Tessellator<'a> { EntityKind::Text(t) => self.text(t, ctx, rgba, attr), - EntityKind::Attribute(a) => self.attribute_text(&a.text, ctx, rgba, attr), - - EntityKind::MText(m) => { - let height = if m.height > 0.0 { m.height } else { 1.0 }; - let face = self.style_face(&m.style); - let lines = self.wrap_mtext(&m.text, m.reference_width, height, face); - let spacing = height - * text::MTEXT_LINE_SPACING - * if m.line_spacing_factor > 0.0 { - m.line_spacing_factor - } else { - 1.0 - }; - - // DWG stores the text direction as a vector when it has one. - let rotation = match m.x_direction { - Some(d) if d.x != 0.0 || d.y != 0.0 => d.y.atan2(d.x), - _ => m.rotation, - }; - let (h_align, dy0) = - text::mtext_layout(m.attachment as u8, lines.len(), height, spacing); + // A multiline attribute (2018 on) is an MTEXT. + EntityKind::Attribute(a) => match &a.mtext { + Some(m) => self.mtext(m, ctx, rgba, attr), + None => self.attribute_text(&a.text, ctx, rgba, attr), + }, - let (sin_r, cos_r) = rotation.sin_cos(); - for (i, line) in lines.iter().enumerate() { - if line.spans.is_empty() { - continue; - } - // Lines stack along the text's own down direction. - let dy = dy0 - i as f64 * spacing; - let x = m.insertion.x - dy * sin_r; - let y = m.insertion.y + dy * cos_r; - self.push_mtext_line( - line, height, x, y, rotation, h_align, ctx, rgba, attr, face, - ); - } - } + EntityKind::MText(m) => self.mtext(m, ctx, rgba, attr), // An ordinary ATTDEF is the template for an ATTRIB carried by the // insert, so drawing it too would double the text. A *constant* // attribute has no ATTRIB: the definition is the only copy, and - // AutoCAD draws it. - EntityKind::AttributeDefinition(a) if a.is_constant() => { - self.attribute_text(&a.text, ctx, rgba, attr) + // AutoCAD draws it, unless it is invisible too. + EntityKind::AttributeDefinition(a) if a.is_constant() && !a.is_invisible() => { + match &a.mtext { + Some(m) => self.mtext(m, ctx, rgba, attr), + None => self.attribute_text(&a.text, ctx, rgba, attr), + } } EntityKind::AttributeDefinition(_) => {} @@ -1695,26 +2334,17 @@ impl<'a> Tessellator<'a> { } if l.arrowhead { - // A filled triangle at the first vertex, pointing back - // along the first segment, as long as the dimension - // style's arrow size (DIMASZ times DIMSCALE) and a third - // as wide: AutoCAD's default closed filled arrowhead. + // At the first vertex, pointing back along the first + // segment, the dimension style's arrow size (DIMASZ + // times DIMSCALE) long, in its leader arrow (DIMLDRBLK). let size = self.arrow_size(&l.style); - let d = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]]; - let len = d[0].hypot(d[1]); - if size > 0.0 && len > 1e-9 { - let u = [d[0] / len, d[1] / len]; - let n = [-u[1], u[0]]; - let base = [pts[0][0] + u[0] * size, pts[0][1] + u[1] * size]; - let half = size / 6.0; - self.scene.push_triangle( - xf.apply(pts[0][0], pts[0][1]), - xf.apply(base[0] + n[0] * half, base[1] + n[1] * half), - xf.apply(base[0] - n[0] * half, base[1] - n[1] * half), - rgba, - attr, - ); - } + let block = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&l.style)) + .map_or(Handle::NULL, |s| s.dimldrblk); + self.arrowhead(block, pts[0], pts[1], size, ctx, rgba, attr); } } @@ -1722,8 +2352,13 @@ impl<'a> Tessellator<'a> { // that can be drawn is the frame, which is what AutoCAD shows for // an image it cannot load, and the count so the host can say so. EntityKind::Image(img) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + let file = self + .doc + .image_defs + .iter() + .find(|d| d.handle == img.image_def); + self.warnings + .external(file.map_or("", |d| d.file_name.as_str())); let u = [img.u_vector.x, img.u_vector.y]; let v = [img.v_vector.x, img.v_vector.y]; let o = [img.insertion.x, img.insertion.y]; @@ -1744,8 +2379,13 @@ impl<'a> Tessellator<'a> { } EntityKind::Underlay(u) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + let file = self + .doc + .underlay_defs + .iter() + .find(|d| d.handle == u.definition); + self.warnings + .external(file.map_or("", |d| d.file_name.as_str())); if u.clip_vertices.len() >= 2 { let pts: Vec<[f64; 2]> = u.clip_vertices.iter().map(|p| [p.x, p.y]).collect(); // Two points are the corners of a rectangle, more are a path. @@ -1771,8 +2411,7 @@ impl<'a> Tessellator<'a> { } EntityKind::Ole2Frame(o) => { - self.warnings.external_references = - self.warnings.external_references.saturating_add(1); + self.warnings.external(""); let (a, b) = (o.upper_left, o.lower_right); let frame = [[a.x, a.y], [b.x, a.y], [b.x, b.y], [a.x, b.y]]; self.push_transformed(&frame, true, xf, rgba, attr, pattern); @@ -1796,11 +2435,11 @@ impl<'a> Tessellator<'a> { self.warnings.proxy_without_graphics.saturating_add(1); } - // Meshes, SHAPE (a glyph of an SHX file, which cannot be shipped) - // and every other type the model does not hold. - EntityKind::Polyline(_) | EntityKind::Shape(_) | EntityKind::Unknown(_) => { - self.warnings.unknown_entities = self.warnings.unknown_entities.saturating_add(1); - } + // SHAPE (a glyph of an SHX file, which cannot be shipped) and + // every other type the model does not hold. + EntityKind::Polyline(_) => self.warnings.not_drawn("POLYLINE"), + EntityKind::Shape(_) => self.warnings.not_drawn("SHAPE"), + EntityKind::Unknown(u) => self.warnings.not_drawn(&u.type_name), } } @@ -1841,7 +2480,7 @@ impl<'a> Tessellator<'a> { } else { t.alignment_point.unwrap_or(t.insertion) }; - let value = text::decode_special(&t.value); + let value = self.style_text(&t.style, &t.value); // Aligned and Fit stretch the string between two points rather than // anchoring it at one, so they need its width. Aligned scales the @@ -1849,24 +2488,38 @@ impl<'a> Tessellator<'a> { // instead. let stretched = matches!(t.h_align, HAlign::Aligned | HAlign::Fit); let face = self.style_face(&t.style); + let width_factor = if t.width_factor > 0.0 { + t.width_factor + } else { + 1.0 + }; if stretched { if let Some(second) = t.alignment_point { let first = t.insertion; let span = (second.x - first.x).hypot(second.y - first.y); - let natural = font::Metrics::for_face(face).measure(&value) * t.height; + let natural = + font::Metrics::for_face(face).measure(&value) * t.height * width_factor; if span > 1e-9 && natural > 1e-9 { let factor = span / natural; let fit = t.h_align == HAlign::Fit; + let frame = text::generated_frame( + if fit { t.height } else { t.height * factor }, + (second.y - first.y).atan2(second.x - first.x), + if fit { + width_factor * factor + } else { + width_factor + }, + t.oblique, + t.generation, + ); self.push_text_run( value, first.x, first.y, - if fit { t.height } else { t.height * factor }, - (second.y - first.y).atan2(second.x - first.x), - if fit { factor } else { 1.0 }, - t.oblique, + frame, text::HAlign::Left, - v_align_of(t.v_align), + text::VAlign::Baseline, ctx, rgba, attr, @@ -1881,10 +2534,13 @@ impl<'a> Tessellator<'a> { value, anchor.x, anchor.y, - t.height, - t.rotation, - t.width_factor, - t.oblique, + text::generated_frame( + t.height, + t.rotation, + t.width_factor, + t.oblique, + t.generation, + ), h_align_of(t.h_align), v_align_of(t.v_align), ctx, @@ -1905,13 +2561,16 @@ impl<'a> Tessellator<'a> { }; let face = self.style_face(&t.style); self.push_text_run( - text::decode_special(&t.value), + self.style_text(&t.style, &t.value), anchor.x, anchor.y, - t.height, - t.rotation, - t.width_factor, - t.oblique, + text::generated_frame( + t.height, + t.rotation, + t.width_factor, + t.oblique, + t.generation, + ), h_align_of(t.h_align), v_align_of(t.v_align), ctx, @@ -1932,10 +2591,7 @@ impl<'a> Tessellator<'a> { value: String, x: f64, y: f64, - height: f64, - rotation: f64, - width_factor: f64, - oblique: f64, + (u, v): ([f64; 2], [f64; 2]), h_align: text::HAlign, v_align: text::VAlign, ctx: &Ctx, @@ -1946,22 +2602,30 @@ impl<'a> Tessellator<'a> { if value.trim().is_empty() { return; } + // The stroke font has no CJK and few symbols: a run with characters + // it lacks goes to an outline face, which the browser completes from + // the system's fonts where the bundle has none. + let stroke = super::stroke_font::newstroke(); + let face = if face == font::Face::Stroke + && value + .chars() + .any(|c| !c.is_whitespace() && stroke.glyph(c).is_none()) + { + font::Face::default() + } else { + face + }; let p = ctx.xf.apply(x, y); - // Blocks rotate and scale their contents, so the text follows. - let block_rotation = ctx.xf.b.atan2(ctx.xf.a); - let scale = ctx.xf.scale_magnitude(); + // Blocks rotate, scale and mirror their contents, so the text + // follows, its frame with them. + let xf = &ctx.xf; + let linear = |w: [f64; 2]| [xf.a * w[0] + xf.c * w[1], xf.b * w[0] + xf.d * w[1]]; self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); let run = text::TextRun { x: p[0], y: p[1], - height: if height > 0.0 { height * scale } else { scale }, - rotation: rotation + block_rotation, - width_factor: if width_factor > 0.0 { - width_factor - } else { - 1.0 - }, - oblique, + u: linear(u), + v: linear(v), rgba, attr, h_align, @@ -1969,6 +2633,7 @@ impl<'a> Tessellator<'a> { text: value, face, order: self.scene.order, + clip: 0, }; match face { font::Face::Stroke => self.push_stroke_text(&run), @@ -1985,19 +2650,14 @@ impl<'a> Tessellator<'a> { /// order, layer visibility and viewport clipping all come from `attr`. fn push_stroke_text(&mut self, run: &text::TextRun) { let stroke = super::stroke_font::newstroke(); - let height = run.height; - if !(height > 0.0) { + if !(run.height() > 0.0) { return; } - let advance_scale = height * run.width_factor; - let width: f64 = run - .text - .chars() - .map(|c| stroke.advance(c) as f64 * advance_scale) - .sum(); + // Everything is in cap heights until the frame places it. + let width: f64 = run.text.chars().map(|c| stroke.advance(c) as f64).sum(); - // Aligned and Fit stretch between two points the record does not carry, - // so they centre, as the host's path does. + // Aligned and Fit arrive stretched and left-aligned (`text`); here + // they centre, as the host's path does. let dx = match run.h_align { text::HAlign::Center | text::HAlign::Middle @@ -2006,17 +2666,12 @@ impl<'a> Tessellator<'a> { text::HAlign::Right => -width, text::HAlign::Left => 0.0, }; - let dy = match run.v_align { - text::VAlign::Middle => -height / 2.0, - text::VAlign::Top => -height, - text::VAlign::Baseline | text::VAlign::Bottom => 0.0, - }; - - let (sin_r, cos_r) = run.rotation.sin_cos(); - let tan_o = if run.oblique.abs() < std::f64::consts::FRAC_PI_2 { - run.oblique.tan() - } else { - 0.0 + let dy = text::baseline_offset(run.h_align, run.v_align, font::Face::Stroke); + let at = |a: f64, b: f64| { + [ + run.x + a * run.u[0] + b * run.v[0], + run.y + a * run.u[1] + b * run.v[1], + ] }; let mut pen = dx; @@ -2031,22 +2686,142 @@ impl<'a> Tessellator<'a> { for polyline in glyph.polylines() { points.clear(); for p in polyline.points() { - // Glyph units are cap heights, so the text height is - // the only scale there is. - let gx = pen + p[0] as f64 * advance_scale; - let gy = dy + p[1] as f64 * height; - // Oblique leans the vertical, shifting x by height. - let sx = gx + gy * tan_o; - points.push([ - run.x + sx * cos_r - gy * sin_r, - run.y + sx * sin_r + gy * cos_r, - ]); + points.push(at(pen + p[0] as f64, dy + p[1] as f64)); } self.scene.push_polyline(&points, false, run.rgba, run.attr); } } - pen += stroke.advance(c) as f64 * advance_scale; + pen += stroke.advance(c) as f64; + } + } + + /// An LWPOLYLINE or 2D POLYLINE: a line through its bulges, or, where its + /// segments have width, the band they fill (outlined under `$FILLMODE` + /// 0). The band is in object coordinates and transformed corner by + /// corner, so a scaled block widens it too. + /// + /// A dashed one carries whether its linetype runs on across its vertices + /// (group 70 bit 128, PLINEGEN); without it each segment starts its + /// pattern afresh, as AutoCAD draws it. + fn polyline( + &mut self, + verts: &[WideVertex], + closed: bool, + xf: &Affine, + rgba: u32, + attr: u32, + dashes: Option<(&Pattern, bool)>, + ) { + let pattern = dashes.map(|(p, _)| p); + if !verts + .iter() + .any(|v| v.start_width > 0.0 || v.end_width > 0.0) + { + let pts: Vec<([f64; 2], f64)> = verts.iter().map(|v| (v.p, v.bulge)).collect(); + if pattern.is_none() + && pts.iter().any(|p| p.1 != 0.0) + && self.push_bulged(&pts, closed, xf, rgba, attr) + { + return; + } + if let Some((p, false)) = dashes { + let segments = if closed { + pts.len() + } else { + pts.len().saturating_sub(1) + }; + for i in 0..segments { + let piece = expand_bulges(&[pts[i], pts[(i + 1) % pts.len()]], false); + self.push_transformed(&piece, false, xf, rgba, attr, Some(p)); + } + return; + } + let flat = expand_bulges(&pts, closed); + self.push_transformed(&flat, closed, xf, rgba, attr, pattern); + return; + } + let band = wide::band(verts, closed); + for run in &band.thin { + self.push_transformed(run, false, xf, rgba, attr, pattern); + } + if self.doc.header.fillmode { + for [a, b, c] in &band.triangles { + self.scene.push_triangle( + xf.apply(a[0], a[1]), + xf.apply(b[0], b[1]), + xf.apply(c[0], c[1]), + rgba, + attr, + ); + } + } else { + for outline in &band.outlines { + self.push_transformed(outline, true, xf, rgba, attr, None); + } + } + } + + /// A circular arc in object coordinates (centre, radius, start angle, + /// signed sweep): arc strokes, exact at any zoom, when nothing dashes it + /// and the transform keeps circles round; else its flattened points. + #[allow(clippy::too_many_arguments)] + fn push_curve( + &mut self, + cx: f64, + cy: f64, + r: f64, + start: f64, + sweep: f64, + xf: &Affine, + rgba: u32, + attr: u32, + pattern: Option<&Pattern>, + ) { + if pattern.is_none() { + if let Some((scale, turn, mirrored)) = similarity(xf) { + let c = xf.apply(cx, cy); + let (start, sweep) = if mirrored { + (turn - start, -sweep) + } else { + (start + turn, sweep) + }; + self.scene + .push_arc(c[0], c[1], r * scale, start, sweep, rgba, attr); + return; + } + } + let pts = curves::arc_points(cx, cy, r, start, sweep); + self.push_transformed(&pts, false, xf, rgba, attr, pattern); + } + + /// A polyline's straight and bulged segments, the bulged ones as arc + /// strokes, under a transform that keeps circles round; false, drawing + /// nothing, under any other. + fn push_bulged( + &mut self, + pts: &[([f64; 2], f64)], + closed: bool, + xf: &Affine, + rgba: u32, + attr: u32, + ) -> bool { + let Some((_, _, mirrored)) = similarity(xf) else { + return false; + }; + let n = pts.len(); + let segments = if closed { n } else { n.saturating_sub(1) }; + for i in 0..segments { + let ((a, bulge), (b, _)) = (pts[i], pts[(i + 1) % n]); + let (wa, wb) = (xf.apply(a[0], a[1]), xf.apply(b[0], b[1])); + let bulge = if mirrored { -bulge } else { bulge }; + if bulge == 0.0 || !bulge.is_finite() { + self.scene + .push_stroke(wa[0], wa[1], wb[0], wb[1], rgba, attr); + } else if let Some((cx, cy, r, start, sweep)) = curves::bulge_arc(wa, wb, bulge) { + self.scene.push_arc(cx, cy, r, start, sweep, rgba, attr); + } } + true } fn push_transformed( @@ -2089,7 +2864,8 @@ impl<'a> Tessellator<'a> { self.scene.push_polyline(pts, closed, rgba, attr); } - /// Stamp a complex linetype's text along a path it has just dashed. + /// Stamp a complex linetype's text and shapes along a path it has just + /// dashed. fn push_line_labels( &mut self, pts: &[[f64; 2]], @@ -2103,29 +2879,306 @@ impl<'a> Tessellator<'a> { } for place in linetype::dash_labels(pts, closed, pattern) { let label = &pattern.labels[place.label]; - let run = text::TextRun { - x: place.x, - y: place.y, - height: label.height, - rotation: place.angle, - width_factor: 1.0, - oblique: 0.0, + match &label.mark { + linetype::Mark::Text { text, face } => { + let (u, v) = text::frame(label.height, place.angle, 1.0, 0.0); + let run = text::TextRun { + x: place.x, + y: place.y, + u, + v, + rgba, + attr, + h_align: text::HAlign::Left, + v_align: text::VAlign::Baseline, + text: text.clone(), + face: *face, + order: self.scene.order, + clip: 0, + }; + self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); + match face { + font::Face::Stroke => self.push_stroke_text(&run), + font::Face::TrueType(_) => self.scene.push_text(run), + } + } + linetype::Mark::Shape(parts) => { + let (sin, cos) = place.angle.sin_cos(); + let k = label.height; + let at = |p: [f64; 2]| { + [ + place.x + k * (p[0] * cos - p[1] * sin), + place.y + k * (p[0] * sin + p[1] * cos), + ] + }; + for part in *parts { + match part { + linetype::ShapePart::Line(ps) => { + for w in ps.windows(2) { + let (a, b) = (at(w[0]), at(w[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + } + linetype::ShapePart::Arc(c, r, start, sweep) => { + let c = at(*c); + self.scene.push_arc( + c[0], + c[1], + r * k, + start + place.angle, + *sweep, + rgba, + attr, + ); + } + } + } + } + } + } + } + + /// An arrowhead `size` long at `tip`, pointing away from `from`: the + /// arrow block `block` names, drawn from the drawing's block of that + /// name when it has one (AutoCAD saves `_Open` and the like there), else + /// the built-in shape of that name, else the closed filled default. + #[allow(clippy::too_many_arguments)] + fn arrowhead( + &mut self, + block: Handle, + tip: [f64; 2], + from: [f64; 2], + size: f64, + ctx: &Ctx, + rgba: u32, + attr: u32, + ) { + let d = [tip[0] - from[0], tip[1] - from[1]]; + if !(size > 0.0) || !(d[0].hypot(d[1]) > 1e-9) { + return; + } + let angle = d[1].atan2(d[0]); + let place = ctx.xf.mul(&Affine::new(tip[0], tip[1], size, size, angle)); + let name = (!block.is_null()) + .then(|| self.doc.blocks.iter().find(|b| b.record == block)) + .flatten(); + if let Some(b) = name.filter(|b| !b.entities.is_empty()) { + let (lid, lw) = (attr as u16, (attr >> 16) as u8); + let contrast = (attr >> 24) as u8 & FLAG_CONTRAST != 0; + let name = b.name.clone(); + self.expand_block( + &name, + &place, + lid, rgba, - attr, - h_align: text::HAlign::Left, - v_align: text::VAlign::Baseline, - text: label.text.clone(), - face: label.face, - order: self.scene.order, + contrast, + lw, + 0, + ctx.lt_scale, + ctx.depth, + ); + return; + } + let kind = name.map_or(String::new(), |b| b.name.to_ascii_uppercase()); + let shape = arrow_shape(&kind); + let world = |p: &[f64; 2]| place.apply(p[0], p[1]); + for t in shape.filled.chunks(3) { + if let [a, b, c] = t { + self.scene + .push_triangle(world(a), world(b), world(c), rgba, attr); + } + } + for line in shape.lines { + let pts: Vec<[f64; 2]> = line.iter().map(world).collect(); + self.scene.push_polyline(&pts, false, rgba, attr); + } + } + + /// A dimension saved without its block, drawn from its definition points + /// and its style (`dimension.rs`). A style the drawing does not have + /// falls back to the header's variables. + fn dimension(&mut self, d: &crate::cad::Dimension, ctx: &Ctx, rgba: u32, attr: u32) { + let h = &self.doc.header; + let style = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&d.style)) + .cloned() + .unwrap_or_else(|| crate::cad::DimStyle { + dimscale: h.dimscale, + dimasz: h.dimasz, + dimtxt: h.dimtxt, + dimgap: h.dimgap, + ..crate::cad::DimStyle::default() + }); + let text_style = self + .doc + .text_styles + .iter() + .find(|ts| ts.handle == style.dimtxsty && !style.dimtxsty.is_null()) + .map(|ts| ts.name.clone()) + .unwrap_or_default(); + let face = self.style_face(&text_style); + let metrics = font::Metrics::for_face(face); + let s = dimension::Style::new(&style); + let measure = |t: &str| metrics.measure(&text::decode_special(t)); + let Some(g) = dimension::graphics(d, &s, &measure) else { + return; + }; + for [a, b] in &g.lines { + let (a, b) = (ctx.xf.apply(a[0], a[1]), ctx.xf.apply(b[0], b[1])); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + for (c, r, start, sweep) in &g.arcs { + self.push_curve(c[0], c[1], *r, *start, *sweep, &ctx.xf, rgba, attr, None); + } + for (tip, from) in &g.arrows { + self.arrowhead(style.dimblk, *tip, *from, s.asz, ctx, rgba, attr); + } + if let Some(label) = g.label { + let content = crate::cad::MText { + insertion: crate::cad::Vec3::new(label.at[0], label.at[1], 0.0), + height: s.txt, + attachment: 5, + style: text_style, + rotation: label.rotation, + text: label.text, + ..crate::cad::MText::default() }; - self.warnings.text_runs = self.warnings.text_runs.saturating_add(1); - match label.face { - font::Face::Stroke => self.push_stroke_text(&run), - font::Face::TrueType(_) => self.scene.push_text(run), + self.mtext(&content, ctx, rgba, attr); + } + } + + /// A TOLERANCE's feature control frame: a row per `^J`, a box per + /// `%%v`, each its text and the style's gap around it, the GD&T codes as + /// the symbols they stand for; hung from the middle of the first row's + /// left side, along its direction. + fn tolerance(&mut self, t: &crate::cad::Tolerance, ctx: &Ctx, rgba: u32, attr: u32) { + let style = self + .doc + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&t.dimstyle)); + let header = &self.doc.header; + let (txt, gap, scale) = match style { + Some(s) => (s.dimtxt, s.dimgap, s.dimscale), + None => (header.dimtxt, header.dimgap, header.dimscale), + }; + let scale = if scale > 0.0 && scale.is_finite() { + scale + } else { + 1.0 + }; + let height = if txt > 0.0 { txt * scale } else { 0.18 }; + let gap = gap.abs() * scale; + let face = style + .and_then(|s| { + self.doc + .text_styles + .iter() + .find(|ts| ts.handle == s.dimtxsty && !s.dimtxsty.is_null()) + }) + .map_or(font::Face::Stroke, |ts| self.style_face(&ts.name)); + let metrics = font::Metrics::for_face(face); + let rotation = t.x_direction.y.atan2(t.x_direction.x); + let (sin_r, cos_r) = rotation.sin_cos(); + let at = |a: f64, b: f64| { + ctx.xf.apply( + t.insertion.x + a * cos_r - b * sin_r, + t.insertion.y + a * sin_r + b * cos_r, + ) + }; + let row_height = height + 2.0 * gap; + for (r, row) in t.text.split("^J").enumerate() { + // The first row's middle is on the insertion point. + let top = row_height / 2.0 - r as f64 * row_height; + let mut left = 0.0; + for cell in row.split("%%v") { + let shown = text::decode_special(&gdt_codes(cell)); + if shown.is_empty() { + continue; + } + let width = metrics.measure(&shown) * height + 2.0 * gap; + let corners = [ + (left, top), + (left + width, top), + (left + width, top - row_height), + (left, top - row_height), + ] + .map(|(a, b)| at(a, b)); + self.scene.push_polyline(&corners, true, rgba, attr); + let (x, y) = ( + t.insertion.x + (left + gap) * cos_r - (top - gap - height) * sin_r, + t.insertion.y + (left + gap) * sin_r + (top - gap - height) * cos_r, + ); + let frame = text::frame(height, rotation, 1.0, 0.0); + let (l, b) = (text::HAlign::Left, text::VAlign::Baseline); + self.push_text_run(shown, x, y, frame, l, b, ctx, rgba, attr, face); + left += width; } } } + /// A POINT in the drawing's `$PDMODE`: a dot (0), nothing (1), a plus + /// (2), a cross (3), a tick up (4), and around it a circle (+32) or a + /// square (+64), `$PDSIZE` across: units when above 0, else a share of + /// the drawing's height (5% at 0, -n for n%), as AutoCAD takes the + /// window's. + fn point(&mut self, p: &crate::cad::Point, xf: &Affine, rgba: u32, attr: u32) { + let h = &self.doc.header; + let mode = h.pdmode; + let at = xf.apply(p.location.x, p.location.y); + if mode & 7 == 0 { + // A zero-length stroke, which the renderer draws as a round dot. + self.scene + .push_stroke(at[0], at[1], at[0], at[1], rgba, attr); + } + if mode & !(7 | 32 | 64) != 0 || matches!(mode & 7, 0 | 1) && mode & 96 == 0 { + return; + } + let tall = h.extmax.y - h.extmin.y; + let tall = if tall.is_finite() && tall > 0.0 { + tall + } else { + 20.0 + }; + let size = match h.pdsize { + s if s > 0.0 => s, + s if s < 0.0 => tall * -s / 100.0, + _ => tall * 0.05, + }; + let r = size / 2.0; + let (sin_a, cos_a) = p.x_axis_angle.sin_cos(); + let local = |x: f64, y: f64| { + xf.apply( + p.location.x + x * cos_a - y * sin_a, + p.location.y + x * sin_a + y * cos_a, + ) + }; + let segments: &[[(f64, f64); 2]] = match mode & 7 { + 2 => &[[(-r, 0.0), (r, 0.0)], [(0.0, -r), (0.0, r)]], + 3 => &[[(-r, -r), (r, r)], [(-r, r), (r, -r)]], + 4 => &[[(0.0, 0.0), (0.0, r)]], + _ => &[], + }; + for [(x0, y0), (x1, y1)] in segments { + let (a, b) = (local(*x0, *y0), local(*x1, *y1)); + self.scene.push_stroke(a[0], a[1], b[0], b[1], rgba, attr); + } + if mode & 32 != 0 { + let ring: Vec<[f64; 2]> = curves::flatten_circle(0.0, 0.0, r) + .into_iter() + .map(|q| local(q[0], q[1])) + .collect(); + self.scene.push_polyline(&ring, true, rgba, attr); + } + if mode & 64 != 0 { + let square = [(-r, -r), (r, -r), (r, r), (-r, r)].map(|(x, y)| local(x, y)); + self.scene.push_polyline(&square, true, rgba, attr); + } + } + #[allow(clippy::too_many_arguments)] fn insert( &mut self, @@ -2168,9 +3221,12 @@ impl<'a> Tessellator<'a> { ins.insertion.y + dx * sin_r + dy * cos_r, ]; let child = Affine::new(p[0], p[1], sx, sy, ins.rotation); + let xf = ctx.xf.mul(&child); + let mark = self.scene.mark(); + let infinite = self.pending_infinite.len(); self.expand_block( &ins.block_name, - &ctx.xf.mul(&child), + &xf, lid, rgba, contrast, @@ -2179,6 +3235,22 @@ impl<'a> Tessellator<'a> { ctx.lt_scale, ctx.depth, ); + if let Some(outline) = self.xclip_outline(ins, &xf) { + if let Some(shape) = ClipShape::polygon(outline.clone()) { + // The block's rays and xlines end at the boundary rather + // than at the drawing's extents. + let (min, max) = shape.bounds(); + for line in self.pending_infinite.split_off(infinite) { + self.push_infinite(&line, min, max); + } + self.scene.clip_since(mark, &shape); + } + if self.doc.header.xclipframe != 0 { + let flags = if contrast { FLAG_CONTRAST } else { 0 }; + let attr = pack_attr(lid, lw, flags); + self.push_path(&outline, true, rgba, attr, None); + } + } } // The insert's attributes (title-block fields, room tags) are not in @@ -2203,6 +3275,29 @@ impl<'a> Tessellator<'a> { } } + /// An insert's XCLIP boundary in world coordinates, when clipping is on: + /// it is in block coordinates, so the block's base point comes off as + /// for its entities. Two points are the corners of a rectangle. + fn xclip_outline(&self, ins: &crate::cad::Insert, xf: &Affine) -> Option> { + let clip = ins.clip.as_ref().filter(|c| c.enabled)?; + let base = self.block(&ins.block_name)?.base_point; + let local: Vec<[f64; 2]> = match clip.boundary.as_slice() { + [a, b] => { + let (x0, x1) = (a.x.min(b.x), a.x.max(b.x)); + let (y0, y1) = (a.y.min(b.y), a.y.max(b.y)); + vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]] + } + ps if ps.len() >= 3 => ps.iter().map(|p| [p.x, p.y]).collect(), + _ => return None, + }; + Some( + local + .iter() + .map(|p| xf.apply(p[0] - base.x, p[1] - base.y)) + .collect(), + ) + } + #[allow(clippy::too_many_arguments)] fn expand_block( &mut self, @@ -2235,6 +3330,43 @@ impl<'a> Tessellator<'a> { return; }; + // An xref's content is in another file, which this viewer was not + // given: its path is drawn at the insertion point, as AutoCAD shows + // an xref it cannot find. + if block.is_xref() && block.entities.is_empty() { + let file = if block.xref_path.is_empty() { + &block.name + } else { + &block.xref_path + }; + self.warnings.external(file); + let at = Ctx { + xf: *xf, + layer: lid, + rgba, + contrast, + lw, + lt, + lt_scale, + depth, + }; + let flags = if contrast { FLAG_CONTRAST } else { 0 }; + let face = self.style_face(&self.doc.header.textstyle); + self.push_text_run( + file.clone(), + 0.0, + 0.0, + text::frame(self.doc.header.textsize, 0.0, 1.0, 0.0), + text::HAlign::Left, + text::VAlign::Baseline, + &at, + rgba, + pack_attr(lid, lw, flags), + face, + ); + return; + } + // Block geometry is defined relative to the block's base point. let base = block.base_point; let local = xf.mul(&Affine { @@ -2463,6 +3595,17 @@ impl<'a> Tessellator<'a> { if truncated { self.warnings.hatch_patterns_truncated = self.warnings.hatch_patterns_truncated.saturating_add(1); + // Too dense to draw line by line: the region in the tone so many + // lines average out to, rather than some of them and a gap. + let alpha = ((rgba >> 24) as f64 * TRUNCATED_PATTERN_TONE).round() as u32; + let tone = (rgba & 0x00FF_FFFF) | (alpha << 24); + for t in fill::fill_region(&loops, island_style(h)).chunks(3) { + if t.len() == 3 { + let [a, b, c] = [t[0], t[1], t[2]].map(|p| ctx.xf.apply(p[0], p[1])); + self.scene.push_triangle(a, b, c, tone, attr); + } + } + return; } // Record the pattern's spacing so the renderer can fade these lines @@ -2537,12 +3680,16 @@ fn spline_points(s: &crate::cad::Spline) -> Vec<[f64; 2]> { ) } +/// What "Default" stands for: AutoCAD's `LWDEFAULT`, 0.25 mm unless a user +/// changed it, which is a setting of their AutoCAD, not of the drawing. +const DEFAULT_LINEWEIGHT: u8 = 25; + /// Resolve a lineweight to hundredths of a millimetre, 0 meaning hairline. fn lineweight_value(lw: LineWeight, inherited: u8) -> u8 { match lw { LineWeight::Value(v) if v > 0 => v.min(211) as u8, LineWeight::Value(_) => 0, - LineWeight::Default => 0, + LineWeight::Default => DEFAULT_LINEWEIGHT, LineWeight::ByLayer | LineWeight::ByBlock => inherited, } } @@ -2650,8 +3797,11 @@ mod tests { #[test] fn a_minsert_asking_past_the_budget_is_drawn_up_to_it() { - // 160,000 rings of about seventy strokes: eleven million asked for. - let doc = minsert(400, 400); + // 810,000 cells of three rings of four arc strokes: 9.7 million + // asked for, under the cell cap. + let mut doc = minsert(900, 900); + let ring = doc.blocks[1].entities[0].clone(); + doc.blocks[1].entities.extend([ring.clone(), ring]); let mut t = Tessellator::new(&doc); t.run(); // Stopped between entities: over by one circle at most, and not short. @@ -2715,6 +3865,80 @@ mod tests { t.scene } + fn fill_area(s: &Scene) -> f64 { + s.fills + .chunks(3) + .map(|t| { + ((t[1].x - t[0].x) * (t[2].y - t[0].y) - (t[2].x - t[0].x) * (t[1].y - t[0].y)) + .abs() + / 2.0 + }) + .sum() + } + + /// A polyline with width fills its band, constant width standing for + /// every vertex's; one without stays strokes. + #[test] + fn a_wide_polyline_is_a_filled_band() { + use crate::cad::{LwPolyline, LwVertex, Vec2}; + let line = |w: f64| LwPolyline { + constant_width: w, + vertices: vec![ + LwVertex { + point: Vec2::new(0.0, 0.0), + ..LwVertex::default() + }, + LwVertex { + point: Vec2::new(10.0, 0.0), + ..LwVertex::default() + }, + ], + ..LwPolyline::default() + }; + let wide = tessellated(EntityKind::LwPolyline(line(2.0))); + // Only the band's four edges, which anti-alias it. + assert_eq!(wide.strokes.len(), 4); + assert!(wide + .strokes + .iter() + .all(|s| (s.attr >> 16) as u8 == super::super::scene::FILL_EDGE)); + assert!( + (fill_area(&wide) - 20.0).abs() < 1e-9, + "{}", + fill_area(&wide) + ); + let thin = tessellated(EntityKind::LwPolyline(line(0.0))); + assert_eq!((thin.strokes.len(), thin.fills.len()), (1, 0)); + } + + /// `$FILLMODE` 0 draws the band's edges instead. + #[test] + fn without_fillmode_a_wide_polyline_is_its_outline() { + use crate::cad::{LwPolyline, LwVertex, Vec2}; + let mut doc = CadDrawing::default(); + doc.header.fillmode = false; + let mut t = Tessellator::new(&doc); + t.tessellate_entity(&Entity { + kind: EntityKind::LwPolyline(LwPolyline { + constant_width: 2.0, + vertices: vec![ + LwVertex { + point: Vec2::new(0.0, 0.0), + ..LwVertex::default() + }, + LwVertex { + point: Vec2::new(10.0, 0.0), + ..LwVertex::default() + }, + ], + ..LwPolyline::default() + }), + ..Entity::default() + }); + assert!(t.scene.fills.is_empty()); + assert_eq!(t.scene.strokes.len(), 4); + } + /// TRACE is filled like SOLID, its corners in the same Z order. #[test] fn a_trace_is_filled_like_a_solid() { @@ -2800,7 +4024,7 @@ mod tests { #[test] fn lineweight_resolution() { assert_eq!(lineweight_value(LineWeight::Value(50), 9), 50); - assert_eq!(lineweight_value(LineWeight::Default, 9), 0); + assert_eq!(lineweight_value(LineWeight::Default, 9), DEFAULT_LINEWEIGHT); assert_eq!(lineweight_value(LineWeight::ByLayer, 9), 9); assert_eq!(lineweight_value(LineWeight::ByBlock, 13), 13); // Out-of-range values clamp rather than wrapping through the u8 cast. diff --git a/crates/exav-render/src/formats/dwg/text.rs b/crates/exav-render/src/formats/dwg/text.rs index 26efbbe..d64ae48 100644 --- a/crates/exav-render/src/formats/dwg/text.rs +++ b/crates/exav-render/src/formats/dwg/text.rs @@ -39,10 +39,11 @@ pub enum VAlign { pub struct TextRun { pub x: f64, pub y: f64, - pub height: f64, - pub rotation: f64, - pub width_factor: f64, - pub oblique: f64, + /// One cap height of advance along the baseline, width factor applied. + pub u: [f64; 2], + /// One cap height up the glyphs, oblique applied. With `u`, it carries + /// rotation, mirroring and a block's non-uniform scale. + pub v: [f64; 2], pub rgba: u32, pub attr: u32, pub h_align: HAlign, @@ -52,6 +53,101 @@ pub struct TextRun { pub face: super::font::Face, /// Position in the drawing's draw order. pub order: u32, + /// The clip region the run is drawn through, from 1 (`Scene::clips`), + /// or 0: a run that crosses a viewport's edge or an XCLIP boundary is + /// cut there by the renderer, which knows how wide it is. + pub clip: u16, +} + +impl TextRun { + /// Cap height on the sheet. + pub fn height(&self) -> f64 { + self.v[0].hypot(self.v[1]) + } + + /// The longer of the frame's two units, for bounding what the run covers. + pub fn unit(&self) -> f64 { + self.u[0].hypot(self.u[1]).max(self.height()) + } +} + +/// A run's frame (`TextRun::u`, `TextRun::v`) from a height, a rotation, a +/// width factor and an oblique angle (radians, leaning right). +pub fn frame(height: f64, rotation: f64, width_factor: f64, oblique: f64) -> ([f64; 2], [f64; 2]) { + let (s, c) = rotation.sin_cos(); + let height = if height > 0.0 { height } else { 1.0 }; + let w = if width_factor > 0.0 { + width_factor + } else { + 1.0 + }; + let t = if oblique.abs() < std::f64::consts::FRAC_PI_2 { + oblique.tan() + } else { + 0.0 + }; + ( + [c * height * w, s * height * w], + [(c * t - s) * height, (s * t + c) * height], + ) +} + +/// A TEXT's or ATTRIB's frame, mirrored as its generation flags say: 2 +/// backward (mirrored along the baseline), 4 upside down. A mirror turns the +/// slant the other way too. +pub fn generated_frame( + height: f64, + rotation: f64, + width_factor: f64, + oblique: f64, + generation: i16, +) -> ([f64; 2], [f64; 2]) { + let backward = generation & 2 != 0; + let upside_down = generation & 4 != 0; + let lean = if backward != upside_down { + -oblique + } else { + oblique + }; + let (mut u, mut v) = frame(height, rotation, width_factor, lean); + if backward { + u = [-u[0], -u[1]]; + } + if upside_down { + v = [-v[0], -v[1]]; + } + (u, v) +} + +/// How far below the baseline a face's descenders reach, in cap heights: +/// where Bottom alignment puts the baseline. The outline faces' are their +/// OS/2 descenders over their cap heights; the stroke font stands in for +/// SHX fonts, whose usual one (txt.shx: 2 below 6 above) is a third. +pub fn descent(face: Face) -> f64 { + match face { + Face::Stroke => 1.0 / 3.0, + Face::TrueType(f) => match f.family { + super::font::Family::Sans => 434.0 / 1409.0, + super::font::Family::Serif => 442.0 / 1341.0, + super::font::Family::Mono => 615.0 / 1349.0, + }, + } +} + +/// Where the baseline sits from the anchor, in cap heights up: Bottom puts +/// the descenders on it, Middle centres the capitals, Top their tops, and +/// the Middle horizontal alignment (TEXT's MIDDLE, unlike Middle Center) +/// centres capitals and descenders together. +pub fn baseline_offset(h: HAlign, v: VAlign, face: Face) -> f64 { + if h == HAlign::Middle { + return -(1.0 - descent(face)) / 2.0; + } + match v { + VAlign::Baseline => 0.0, + VAlign::Bottom => descent(face), + VAlign::Middle => -0.5, + VAlign::Top => -1.0, + } } pub const TEXT_RECORD_BYTES: usize = 48; @@ -224,6 +320,24 @@ pub fn decode_special(s: &str) -> String { // %%u, %%o and %%k toggle under/over/strike; we drop the styling. Some('u') | Some('U') | Some('o') | Some('O') | Some('k') | Some('K') => {} Some('%') => out.push('%'), + // %%nnn: the character of that number, three digits. + Some(d) if d.is_ascii_digit() => { + let mut code = d.to_digit(10).unwrap_or(0); + let mut digits = 1; + while digits < 3 { + match chars.peek().and_then(|c| c.to_digit(10)) { + Some(n) => { + code = code * 10 + n; + chars.next(); + digits += 1; + } + None => break, + } + } + if let Some(c) = u8::try_from(code).ok().and_then(windows_1252) { + out.push(c); + } + } Some(other) => { out.push('%'); out.push('%'); @@ -235,11 +349,24 @@ pub fn decode_special(s: &str) -> String { out } -/// Strip MTEXT inline formatting and split into lines. -/// -/// Formatting is dropped rather than honoured: mixed fonts, per-run colour and -/// stacked fractions are a documented gap. Stacked fractions degrade to `a/b` -/// so the content survives even though the layout does not. +/// A Windows-1252 byte as a character; None for a control character or +/// one of the five the code page leaves out. +fn windows_1252(b: u8) -> Option { + const HIGH: [u16; 32] = [ + 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, + 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, + 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178, + ]; + match b { + 0..=0x1F | 0x7F => None, + 0x80..=0x9F => { + char::from_u32(u32::from(HIGH[usize::from(b - 0x80)])).filter(|&c| c != '\0') + } + _ => Some(char::from(b)), + } +} + +/// MTEXT as plain lines: the formatting gone, a stack as `a/b`. pub fn mtext_lines(raw: &str) -> Vec { mtext_spans(raw) .into_iter() @@ -265,13 +392,35 @@ pub enum SpanHeight { /// /// The code is not documented by Autodesk; this follows the arguments the /// format is widely understood to use: `l` a left indent applying to every -/// line, `i` an extra indent on the first line only, and `t` a list of tab -/// stops. `q` justification and `x` line spacing are read and ignored. +/// line, `i` an extra indent on the first line only, `t` a list of tab stops +/// and `q` the justification. `x` line spacing is ignored. #[derive(Clone, Debug, PartialEq, Default)] pub struct Paragraph { pub indent: f64, pub first_indent: f64, pub tabs: Vec, + /// `q`: the lines aligned in the box this way rather than by the + /// attachment point (justified and distributed taken as left). + pub align: Option, +} + +/// A stacked fraction from `\S`. +#[derive(Clone, Debug, PartialEq)] +pub struct Stack { + pub upper: String, + pub lower: String, + pub kind: StackKind, +} + +/// The character between a stack's parts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StackKind { + /// `/`: one above the other across a bar. + Fraction, + /// `#`: side by side across a slash. + Diagonal, + /// `^`: one above the other, no bar, left-aligned (tolerances). + Tolerance, } /// One laid-out line: its runs and the paragraph settings in force. @@ -295,6 +444,14 @@ pub struct Span { pub underline: bool, pub overline: bool, pub strike: bool, + /// `\W`: a width factor in place of the style's. + pub width: Option, + /// `\Q`: an oblique angle (radians) in place of the style's. + pub oblique: Option