Imported from archer884/artano (
AGENTS.md). Install upstream withnpx skills add archer884/artano. Copyright stays with the author.
Artano
A small Rust library for adding styled text to images — specifically, the "Impact" font top/bottom caption meme style. Named after Sauron (Quenya for "High Smith"), per the author's sense of humor.
At a glance
- Crate:
artanov0.3.14, edition 2024, dual-licensed MIT/Apache-2.0 - Purpose: Render an image with caption text overlaid in the classic white-on-black-outline meme style.
- Backbone:
image0.25,imageproc0.27,rusttype0.9,font-kit0.14,rayon1.12 (parallel annotation + resize) - Test surface: Tiny — one
load_font("Impact")smoke test, onesplit_texttest, no integration tests, no fixtures. - Dev tools:
examples/benchmark.rs(read/annotate/render/total timings across image sizes and annotation counts) andexamples/diff.rs(pixel-wise diff between two render methods with amplified visualizations intarget/diff/).
Public API surface (src/lib.rs)
Re-exports everything from the submodules plus rusttype::Font:
Canvas(src/canvas.rs) — the main entry point. Holds a base image and aVec<RenderedLayer>of small text-bbox buffers.add_annotation/add_annotationsproduce the layers,render()resizes each in parallel via rayon and composites onto the base.Annotation,Position::{Top,Middle,Bottom},PlacedLine(src/annotation.rs) — text + vertical placement, plus a pureAnnotation::layout()that returnsVec<PlacedLine>(no rendering).Error(src/error.rs) — wrapsio::Error,image::ImageError,font_kit::SelectionError, and a stringly-typed font-not-found variant.outline(src/outline.rs,pub) — the outline renderer (text + black halo via a single morphology dilate).load_font(name: &str) -> Result<Font>— loads a system font by PostScript name viafont-kit. Expects Impact to be installed (the only test asserts this on the host).Result<T>=std::result::Result<T, Error>.- Constants
AA_FACTOR: u32 = 3andAA_FACTOR_FLOAT: f32 = 3.0(the supersampling factor used for antialiasing — referenced in multiple places, so don't rename without grepping).
How rendering works (the mental model)
Canvas::read_from_buffer(&[u8])decodes an image (any formatimagesupports) intobase, withlayers: Vec<RenderedLayer>empty.Canvas::add_annotations(&[Annotation], &Font, scale_multiplier)(or the single-shotadd_annotationfor one annotation) lays out the text atheight/10 * scale_multiplierglyph height. The layout (splitting, line offsets perPositionvariant) lives inAnnotation::layoutand returnsVec<PlacedLine>— pure, no drawing. The batch form parallelizes layout + small-buffer rendering with rayon.- For each
PlacedLine,render_line_to_small_buffer(insrc/canvas.rs) allocates a small RGBA buffer sized to(text_width * AA_FACTOR + 2*pad, text_height * AA_FACTOR + 2*pad)wherepad = (outline_radius + lanczos_kernel_radius).div_ceil(AA_FACTOR) * AA_FACTORcovers both the outline radius and the Lanczos3 kernel's 3-pixel output radius (9 input pixels at 3×).outline::render_linethen produces text + outline into that buffer; the layer is pushed toself.layers. outline::render_line:- Draw the text mask (white text on black) at 3× into a
GrayImage. - Build a 1-pixel-thick ring mask at the outline radius
(0.09 * scale_factor) as i32. imageproc::morphology::grayscale_dilate(text_mask, ring_mask)→ a 1-pixel ring at that radius. Same math as the originaldraw_hollow_circle_mut-per-edge-pixel approach, but one dilate pass over the whole buffer.- Stamp the ring as opaque black into the small buffer.
- Draw the white text on top via
draw::text(weighted blend, so the AA edges transition smoothly out of the black outline).
- Draw the text mask (white text on black) at 3× into a
Canvas::render()resizes each layer in parallel via rayon (the resize is independent per layer), then composites each layer in order onto the base. The composite is serial because every layer's overlay writes to the sameself.base. The text lands at(line.x - pad/AA_FACTOR, line.y - pad/AA_FACTOR)so the text appears at the original(x, y)with the outline extending ~3 px outside the text bbox in each direction.Canvas::save_jpg/save_pngwrite the result.
Note Canvas is mutated through &mut self for both add_annotation(s)
and render.
Common tasks
- Add a new text position (e.g.
TopLeft): extendPositioninsrc/annotation.rs, add a constructor and aposition::*helper, then add a match arm inAnnotation::layout(the three existing arms already callPlacedLine { ... y: pos.1 + line_offset, ... }with differentline_offsets when text is split).Annotation::positionispubso it can be reused outside this module. - Tweak the outline:
outline::render_lineinsrc/outline.rsis the only place. The ring-kernel radius formula is(0.09 * scale_factor) as i32. The padding inCanvas::render_line_to_small_bufferadds a9 px(Lanczos3 output radius at 3×) margin on top of that. If you change the outline radius, also revisit the pad. - Add a new output format:
Canvas::save_*are one-liners that delegate toimage::DynamicImage::write_to. Add another method following the same pattern. - Use a font not on the system:
load_fontwon't work. Therusttype::Font::try_from_vec_and_indexpath it uses internally is what you want — bypassload_fontand feed bytes directly.
Build / verify
cargo build
cargo test # Impact smoke test + split_text
cargo clippy # should be warning-free
cargo run --release --example benchmark
# per-case read/annotate/render/total timings
cargo run --release --example diff
# renders each case via both methods and reports
# max/mean/pixel-count diffs; writes amplified
# |a-b|*16 visualizations to target/diff/<case>.png
The Impact font test will fail on systems without Impact installed (most Linux). That's expected — it's a smoke test, not a CI gate worth chasing. On macOS it's preinstalled.
The diff binary's render_b is currently a copy of render_a (a
sanity check that the pipeline is self-consistent, diff should be 0).
Swap in a different rendering path there to compare against it in the
future.
Repo layout
Cargo.toml package + deps (incl. rayon)
src/lib.rs module wiring, load_font, Result alias, AA constants
src/canvas.rs Canvas: base + Vec<RenderedLayer>, per-layer parallel render
src/annotation.rs Annotation, Position, pure layout() -> Vec<PlacedLine>
+ private text-metric helpers (font_height, calculate_text_width, split_text)
src/outline.rs outline: text mask + 1-px ring dilate
src/draw.rs thin wrapper over rusttype's glyph draw loop with weighted blending
src/error.rs Error enum + From impls
examples/benchmark.rs read/annotate/render/total timings
examples/diff.rs method_a vs method_b pixel diff + amplified visualization
AGENTS.md this file
Gotchas / non-obvious things
AA_FACTORlives in two forms (u32andf32) because it's used in both integer pixel math and floatScalemath. If you change one, change both.split_textpanics with"Wtf, bro?..."if the input has no space. The caller inAnnotation::layoutalready guards onself.text.contains(' '), so the panic is unreachable in practice — but if you refactor, preserve that invariant or replace the panic with a real error.outline::render_line'sroot_positionis in base coordinates, not buffer coordinates. Internally it multiplies byAA_FACTORto get the buffer position. The call site inCanvas::render_line_to_small_bufferpasses(pad / AA_FACTOR, pad / AA_FACTOR)so the text lands at(pad, pad)in the small buffer. Passing(pad, pad)directly draws at(pad * AA_FACTOR, pad * AA_FACTOR)and silently misaligns the whole composition — bug I hit during the refactor, easy to re-hit.render_line_to_small_buffer's pad =outline_radius + 9px (the Lanczos3 kernel's 3-px output radius × 3 = 9 input px), rounded up to a multiple ofAA_FACTORso the 3× resize and the composite offset come out exact. Both the outline and the resize kernel need to fit inside this pad or you get clipping at the AA edges.render()parallelizes the resize but not the composite. The composite has to be serial (every layer writes to the samebase). For a single annotation this is essentially free; for many annotations, the resize work fans out across cores and the composite is the remaining serial step.- The 1-annotation
add_annotationis intentionally serial. It avoids rayon's work-distribution overhead for the common case. Useadd_annotations(the batch form) when you have more than one annotation and want the parallel path. Cargo.lockis gitignored (unusual for a binary-adjacent lib, but the author prefers it that way for libraries).- Edition is 2024 — requires a recent rustc (1.85+). The toolchain on this machine (1.97) is fine.
conv = "0.3.3"is a declared dependency but is not used insrc/(nouse conv;anywhere). Looks vestigial; safe to drop if you ever tidy deps.- No
#[non_exhaustive]onErrororPosition— adding variants is a breaking change for downstream users. - The
imageproc::morphology::Maskconstructor isfrom_image(with privatenew); the max image dimension is 511 pixels. That's fine for outline radii up to ~250 (i.e. images up to ~25000 px tall at the defaultheight/10scale), but worth knowing if you ever crankscale_multiplier.