Update
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
## termimg — Terminal Image Rendering for Nim
|
||||
##
|
||||
## Picture-perfect image rendering in the native terminal. Renders RGBA
|
||||
## pixel data using the highest-fidelity protocol the terminal supports.
|
||||
##
|
||||
## ## Supported Protocols
|
||||
##
|
||||
## | Protocol | Fidelity | Mechanism |
|
||||
## |------------- |-----------------------|-------------------------------------------------|
|
||||
## | kitty | Exact pixels | RGBA transmitted in base64 chunks |
|
||||
## | iterm2 | Exact | Raw file bytes via OSC 1337 inline image |
|
||||
## | sixel | 256-color palette | DCS sixel bands with RLE compression |
|
||||
## | quarterblock | 4 sub-cells per cell | Unicode quadrant blocks with 24-bit truecolor |
|
||||
## | halfblock | 2 vertical per cell | Unicode ▀ with 24-bit foreground/background |
|
||||
##
|
||||
## ## Usage
|
||||
##
|
||||
## ```nim
|
||||
## import termimg
|
||||
##
|
||||
## let caps = detectCapabilities()
|
||||
## let opts = defaultOptions()
|
||||
## let output = renderImageRgba(rgbaPixels, 640, 480, caps, opts)
|
||||
## stdout.write(output)
|
||||
## ```
|
||||
|
||||
import termimg/types, termimg/capabilities, termimg/renderer, termimg/scaling
|
||||
import termimg/kitty, termimg/sixel, termimg/iterm2, termimg/halfblock, termimg/quarterblock
|
||||
|
||||
export termimg.types, termimg.capabilities, termimg.renderer, termimg.scaling
|
||||
export termimg.kitty, termimg.sixel, termimg.iterm2, termimg.halfblock, termimg.quarterblock
|
||||
@@ -0,0 +1,62 @@
|
||||
## Terminal capability detection: size, cell dimensions, and protocol probing.
|
||||
##
|
||||
## Detection uses environment heuristics combined with terminal escape query
|
||||
## where available. Falls back gracefully to halfblock in all terminals.
|
||||
|
||||
import std/[os, terminal, strutils]
|
||||
import types
|
||||
|
||||
proc terminalIsInteractive*(): bool =
|
||||
isatty(stdout) and isatty(stdin)
|
||||
|
||||
proc cellSize*(columns, rows: int): CellSize =
|
||||
## Estimate cell pixel dimensions.
|
||||
## For true-color terminals, these defaults work well.
|
||||
## Actual pixel dimensions are derived during rendering.
|
||||
CellSize(width: DefaultCellWidth, height: DefaultCellHeight)
|
||||
|
||||
proc protocolsFromEnvironment(): set[Protocol] =
|
||||
## Detect supported image protocols from environment variables.
|
||||
let term = getEnv("TERM", "")
|
||||
let termProgram = getEnv("TERM_PROGRAM", "")
|
||||
|
||||
# Kitty protocol
|
||||
if getEnv("KITTY_WINDOW_ID", "").len > 0:
|
||||
result.incl ptKitty
|
||||
if termProgram in ["kitty", "ghostty", "WezTerm"]:
|
||||
result.incl ptKitty
|
||||
if getEnv("WEZTERM_EXECUTABLE", "").len > 0:
|
||||
result.incl ptKitty
|
||||
result.incl ptSixel
|
||||
if term.find("kitty") >= 0:
|
||||
result.incl ptKitty
|
||||
|
||||
# iTerm2 protocol
|
||||
if termProgram in ["iTerm.app", "vscode"]:
|
||||
result.incl ptIterm2
|
||||
|
||||
# Sixel protocol
|
||||
let colorterm = getEnv("COLORTERM", "")
|
||||
if colorterm.find("sixel") >= 0 or term.find("sixel") >= 0:
|
||||
result.incl ptSixel
|
||||
if term in ["foot", "xterm-foot", "mlterm"]:
|
||||
result.incl ptSixel
|
||||
|
||||
proc detectCapabilities*(): TerminalCapabilities =
|
||||
## Resolve terminal capabilities: size, cell size, and supported protocols.
|
||||
## Always returns valid values — never crashes even outside a terminal.
|
||||
let rawCols = terminalWidth()
|
||||
let rawRows = terminalHeight()
|
||||
let columns = if rawCols > 0: rawCols else: 80
|
||||
let rows = if rawRows > 0: rawRows else: 24
|
||||
let cell = cellSize(columns, rows)
|
||||
|
||||
var protocols: set[Protocol] = {ptHalfBlock}
|
||||
protocols = protocols + protocolsFromEnvironment()
|
||||
|
||||
TerminalCapabilities(
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
cell: cell,
|
||||
protocols: protocols,
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
## Half-block Unicode renderer — universal fallback.
|
||||
##
|
||||
## Uses ▀ (UPPER HALF BLOCK) with 24-bit foreground/background
|
||||
## colors. Two vertical samples per character cell. Works in any
|
||||
## truecolor terminal. Supports bilinear scaling and optional
|
||||
## Floyd-Steinberg dithering for downscaled images.
|
||||
|
||||
import std/[strutils, math]
|
||||
import types, scaling
|
||||
|
||||
const
|
||||
Csi = "\e["
|
||||
ResetColor = Csi & "0m"
|
||||
UpperHalfBlock = "▀"
|
||||
|
||||
type
|
||||
Color = tuple[r, g, b: uint8]
|
||||
|
||||
proc foreground(color: Color): string =
|
||||
Csi & "38;2;" & $color.r & ";" & $color.g & ";" & $color.b & "m"
|
||||
|
||||
proc background(color: Color): string =
|
||||
Csi & "48;2;" & $color.r & ";" & $color.g & ";" & $color.b & "m"
|
||||
|
||||
proc compositeOver(img: ImageData, bg: tuple[r, g, b: uint8]): seq[uint8] =
|
||||
let total = img.width * img.height
|
||||
result = newSeq[uint8](total * 3)
|
||||
for i in 0 ..< total:
|
||||
let off = i * 4
|
||||
let a = float(img.data[off + 3]) / 255.0
|
||||
let inva = 1.0 - a
|
||||
result[i * 3 + 0] = uint8(float(img.data[off + 0]) * a + float(bg.r) * inva)
|
||||
result[i * 3 + 1] = uint8(float(img.data[off + 1]) * a + float(bg.g) * inva)
|
||||
result[i * 3 + 2] = uint8(float(img.data[off + 2]) * a + float(bg.b) * inva)
|
||||
|
||||
proc floydSteinbergHalfBlock(
|
||||
buf: var seq[uint8], w, h: int,
|
||||
) =
|
||||
## In-place Floyd-Steinberg dithering of the resized RGB buffer.
|
||||
## Distributes quantization error to smooth banding in downscaled images.
|
||||
for y in 0 ..< h:
|
||||
for x in 0 ..< w:
|
||||
let off = (y * w + x) * 3
|
||||
for c in 0 ..< 3:
|
||||
var old = float(buf[off + c])
|
||||
var newVal = round(old)
|
||||
if newVal < 0.0: newVal = 0.0
|
||||
if newVal > 255.0: newVal = 255.0
|
||||
let err = old - newVal
|
||||
buf[off + c] = uint8(newVal)
|
||||
|
||||
template d(xx, yy, wgt: untyped) =
|
||||
if xx >= 0 and xx < w and yy >= 0 and yy < h:
|
||||
let toff = (yy * w + xx) * 3
|
||||
buf[toff + c] = uint8(clamp(
|
||||
int(round(float(buf[toff + c]) + err * wgt)), 0, 255))
|
||||
|
||||
d(x + 1, y, 7.0 / 16.0)
|
||||
d(x - 1, y + 1, 3.0 / 16.0)
|
||||
d(x, y + 1, 5.0 / 16.0)
|
||||
d(x + 1, y + 1, 1.0 / 16.0)
|
||||
|
||||
proc renderHalfBlock*(
|
||||
img: ImageData,
|
||||
background: tuple[r, g, b: uint8],
|
||||
columns, sampleHeight: int,
|
||||
dither: bool = false,
|
||||
): string =
|
||||
## Produce half-block Unicode output.
|
||||
## Returns empty string if source or target dimensions are invalid.
|
||||
if img.width <= 0 or img.height <= 0 or columns <= 0 or sampleHeight <= 0:
|
||||
return ""
|
||||
if img.data.len < img.width * img.height * 4:
|
||||
return ""
|
||||
|
||||
let rgb = compositeOver(img, background)
|
||||
let rgbScaled = bilinearResizeRgb(rgb, img.width, img.height, columns, sampleHeight)
|
||||
|
||||
var buf = rgbScaled
|
||||
if dither:
|
||||
floydSteinbergHalfBlock(buf, columns, sampleHeight)
|
||||
|
||||
var lines: seq[string]
|
||||
for top in countup(0, sampleHeight - 2, 2):
|
||||
var parts: seq[string]
|
||||
var prevTop: Color = (0'u8, 0, 0)
|
||||
var prevBot: Color = (0'u8, 0, 0)
|
||||
var first = true
|
||||
|
||||
for col in 0 ..< columns:
|
||||
let topOff = (top * columns + col) * 3
|
||||
let botOff = ((top + 1) * columns + col) * 3
|
||||
let upper: Color = (
|
||||
buf[topOff + 0],
|
||||
buf[topOff + 1],
|
||||
buf[topOff + 2],
|
||||
)
|
||||
let lower: Color = (
|
||||
buf[botOff + 0],
|
||||
buf[botOff + 1],
|
||||
buf[botOff + 2],
|
||||
)
|
||||
|
||||
if first or upper != prevTop:
|
||||
parts.add(foreground(upper))
|
||||
prevTop = upper
|
||||
if first or lower != prevBot:
|
||||
parts.add(background(lower))
|
||||
prevBot = lower
|
||||
parts.add(UpperHalfBlock)
|
||||
first = false
|
||||
|
||||
parts.add(ResetColor)
|
||||
lines.add(join(parts))
|
||||
|
||||
result = join(lines, "\n")
|
||||
@@ -0,0 +1,18 @@
|
||||
## iTerm2 inline image protocol renderer.
|
||||
##
|
||||
## Encodes raw file bytes as base64 and emits via OSC 1337.
|
||||
|
||||
import std/base64
|
||||
|
||||
const
|
||||
Osc = "\e]"
|
||||
Bel = "\a"
|
||||
|
||||
proc renderIterm2*(rawBytes: seq[uint8], width, height: int): string =
|
||||
## Produce iTerm2 inline image escape sequence.
|
||||
## `rawBytes` is the original image file bytes (PNG, JPEG, etc.).
|
||||
let b64 = encode(rawBytes)
|
||||
result = Osc & "1337;File=inline=1;width=" & $width &
|
||||
"px;height=" & $height &
|
||||
"px;size=" & $rawBytes.len &
|
||||
";name=image;base64," & b64 & Bel & "\n"
|
||||
@@ -0,0 +1,52 @@
|
||||
## Kitty graphics protocol renderer.
|
||||
##
|
||||
## Transmits RGBA pixel data in base64 chunks. See:
|
||||
## https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
||||
|
||||
import std/[base64, math, strutils]
|
||||
import types, scaling
|
||||
|
||||
const
|
||||
Apc = "\e_"
|
||||
St = "\e\\"
|
||||
KittyChunkBytes = 4096
|
||||
RgbaFormat = 32
|
||||
|
||||
proc renderKitty*(img: ImageData, targetW, targetH: int): string =
|
||||
## Produce kitty graphics escape sequence for the given image.
|
||||
## Returns empty string if source or target dimensions are invalid.
|
||||
if img.width <= 0 or img.height <= 0 or targetW <= 0 or targetH <= 0:
|
||||
return ""
|
||||
if img.data.len < img.width * img.height * 4:
|
||||
return ""
|
||||
let pixels = bilinearResizeRgba(img, targetW, targetH)
|
||||
if pixels.len == 0:
|
||||
return ""
|
||||
|
||||
let payload = encode(pixels)
|
||||
let numChunks = max(1, int(ceil(float(payload.len) / float(KittyChunkBytes))))
|
||||
|
||||
var seqs: seq[string]
|
||||
for i in 0 ..< numChunks:
|
||||
let chunkStart = i * KittyChunkBytes
|
||||
let chunkEnd = min(chunkStart + KittyChunkBytes, payload.len)
|
||||
let chunk = payload[chunkStart ..< chunkEnd]
|
||||
let last = i == numChunks - 1
|
||||
let m = if last: 0 else: 1
|
||||
|
||||
if i == 0:
|
||||
seqs.add(Apc & "Ga=T,f=" & $RgbaFormat & ",s=" & $targetW &
|
||||
",v=" & $targetH & ",m=" & $m & ";" & chunk & St)
|
||||
else:
|
||||
seqs.add(Apc & "Gm=" & $m & ";" & chunk & St)
|
||||
|
||||
# End-of-data marker + present command
|
||||
seqs.add(Apc & "Gm=0;" & St)
|
||||
seqs.add(Apc & "Ga=P" & St)
|
||||
result = seqs.join()
|
||||
|
||||
proc renderKittyFile*(filePath: string): string =
|
||||
## Tell the terminal to read a PNG file directly (t=f, f=100).
|
||||
## Only works for local terminals that can access the file path.
|
||||
let encoded = encode(filePath)
|
||||
result = Apc & "Gf=100,t=f;" & encoded & St
|
||||
@@ -0,0 +1,197 @@
|
||||
## Quarter-block Unicode renderer — double-density fallback.
|
||||
##
|
||||
## Uses the Unicode quadrant block characters (▖▗▘▙▚▛▜▝▞▟█ and space)
|
||||
## to render 2×2 pixel samples per terminal cell. Each character
|
||||
## encodes which of the four quadrants take the foreground colour
|
||||
## and which take the background colour.
|
||||
##
|
||||
## With Floyd-Steinberg dithering, this produces remarkably detailed
|
||||
## thumbnails even in terminals without graphics protocol support.
|
||||
|
||||
import std/[strutils, math]
|
||||
import types, scaling
|
||||
|
||||
const
|
||||
Csi = "\e["
|
||||
ResetColor = Csi & "0m"
|
||||
|
||||
type
|
||||
Color = tuple[r, g, b: uint8]
|
||||
|
||||
# Quadrant block character lookup table.
|
||||
# Index bits: bit0 = top-left, bit1 = top-right, bit2 = bottom-left, bit3 = bottom-right
|
||||
# 1 = foreground colour, 0 = background colour
|
||||
const
|
||||
QuadChars: array[16, string] = [
|
||||
" ", # 0000 — all background
|
||||
"▘", # 0001 — bottom-right only
|
||||
"▝", # 0010 — bottom-left only
|
||||
"▀", # 0011 — bottom row (both bottom quadrants)
|
||||
"▖", # 0100 — top-right only
|
||||
"▞", # 0101 — top-right + bottom-right (right column)
|
||||
"▚", # 0110 — top-right + bottom-left (diagonal)
|
||||
"▜", # 0111 — all except top-left
|
||||
"▗", # 1000 — top-left only
|
||||
"▙", # 1001 — all except top-right
|
||||
"▛", # 1010 — top-left + bottom-left (left column)
|
||||
"▟", # 1011 — all except bottom-right
|
||||
"▄", # 1100 — top row (both top quadrants)
|
||||
"▌", # 1101 — left column (top-left + bottom-left)
|
||||
"▐", # 1110 — right column (top-right + bottom-right)
|
||||
"█", # 1111 — all foreground
|
||||
]
|
||||
|
||||
proc foreground(color: Color): string =
|
||||
Csi & "38;2;" & $color.r & ";" & $color.g & ";" & $color.b & "m"
|
||||
|
||||
proc background(color: Color): string =
|
||||
Csi & "48;2;" & $color.r & ";" & $color.g & ";" & $color.b & "m"
|
||||
|
||||
proc compositeOver(img: ImageData, bg: tuple[r, g, b: uint8]): seq[uint8] =
|
||||
let total = img.width * img.height
|
||||
result = newSeq[uint8](total * 3)
|
||||
for i in 0 ..< total:
|
||||
let off = i * 4
|
||||
let a = float(img.data[off + 3]) / 255.0
|
||||
let inva = 1.0 - a
|
||||
result[i * 3 + 0] = uint8(float(img.data[off + 0]) * a + float(bg.r) * inva)
|
||||
result[i * 3 + 1] = uint8(float(img.data[off + 1]) * a + float(bg.g) * inva)
|
||||
result[i * 3 + 2] = uint8(float(img.data[off + 2]) * a + float(bg.b) * inva)
|
||||
|
||||
proc floydSteinbergQuarter(
|
||||
buf: var seq[uint8], w, h: int,
|
||||
) =
|
||||
## In-place Floyd-Steinberg dithering.
|
||||
for y in 0 ..< h:
|
||||
for x in 0 ..< w:
|
||||
let off = (y * w + x) * 3
|
||||
for c in 0 ..< 3:
|
||||
var old = float(buf[off + c])
|
||||
var newVal = round(old)
|
||||
if newVal < 0.0: newVal = 0.0
|
||||
if newVal > 255.0: newVal = 255.0
|
||||
let err = old - newVal
|
||||
buf[off + c] = uint8(newVal)
|
||||
|
||||
template d(xx, yy, wgt: untyped) =
|
||||
if xx >= 0 and xx < w and yy >= 0 and yy < h:
|
||||
let toff = (yy * w + xx) * 3
|
||||
buf[toff + c] = uint8(clamp(
|
||||
int(round(float(buf[toff + c]) + err * wgt)), 0, 255))
|
||||
|
||||
d(x + 1, y, 7.0 / 16.0)
|
||||
d(x - 1, y + 1, 3.0 / 16.0)
|
||||
d(x, y + 1, 5.0 / 16.0)
|
||||
d(x + 1, y + 1, 1.0 / 16.0)
|
||||
|
||||
proc renderQuarterBlock*(
|
||||
img: ImageData,
|
||||
background: tuple[r, g, b: uint8],
|
||||
columns, sampleHeight: int,
|
||||
dither: bool = true,
|
||||
): string =
|
||||
## Produce quarter-block Unicode output.
|
||||
## Returns empty string if source or target dimensions are invalid.
|
||||
if img.width <= 0 or img.height <= 0 or columns <= 0 or sampleHeight <= 0:
|
||||
return ""
|
||||
if img.data.len < img.width * img.height * 4:
|
||||
return ""
|
||||
|
||||
let rgb = compositeOver(img, background)
|
||||
let rgbScaled = bilinearResizeRgb(rgb, img.width, img.height, columns, sampleHeight)
|
||||
|
||||
var buf = rgbScaled
|
||||
if dither:
|
||||
floydSteinbergQuarter(buf, columns, sampleHeight)
|
||||
|
||||
# Each cell covers 2×2 source pixels.
|
||||
# Rows: cells = sampleHeight / 2
|
||||
# Columns: cells = columns / 2
|
||||
let cellRows = sampleHeight div 2
|
||||
let cellCols = columns div 2
|
||||
|
||||
var lines: seq[string]
|
||||
for cy in 0 ..< cellRows:
|
||||
var parts: seq[string]
|
||||
var prevFg: Color = (0'u8, 0, 0)
|
||||
var prevBg: Color = (0'u8, 0, 0)
|
||||
var first = true
|
||||
|
||||
for cx in 0 ..< cellCols:
|
||||
# Read the 2×2 source region for this cell
|
||||
let px = cx * 2
|
||||
let py = cy * 2
|
||||
|
||||
template get(xx, yy: int): Color =
|
||||
let o = ((yy) * columns + (xx)) * 3
|
||||
(buf[o + 0], buf[o + 1], buf[o + 2])
|
||||
|
||||
let
|
||||
tl = get(px, py) # top-left
|
||||
tr = get(px + 1, py) # top-right
|
||||
bl = get(px, py + 1) # bottom-left
|
||||
br = get(px + 1, py + 1) # bottom-right
|
||||
|
||||
# Cluster the 4 colours into 2 groups (fg and bg) via simple threshold.
|
||||
# Compute luminance for each quadrant.
|
||||
proc lum(c: Color): float =
|
||||
0.299 * float(c.r) + 0.587 * float(c.g) + 0.114 * float(c.b)
|
||||
|
||||
let lumVals = [lum(tl), lum(tr), lum(bl), lum(br)]
|
||||
let lumMin = min(lumVals)
|
||||
let lumMax = max(lumVals)
|
||||
let lumMid = (lumMin + lumMax) / 2.0
|
||||
|
||||
# Quadrants above threshold → fg, below → bg
|
||||
var mask = 0
|
||||
var fgSumR, fgSumG, fgSumB: float
|
||||
var bgSumR, bgSumG, bgSumB: float
|
||||
var fgCount, bgCount: int
|
||||
|
||||
template accum(idx, colr: untyped) =
|
||||
if lumVals[idx] >= lumMid:
|
||||
mask = mask or (1 shl idx)
|
||||
fgSumR += float(colr.r); fgSumG += float(colr.g); fgSumB += float(colr.b)
|
||||
inc fgCount
|
||||
else:
|
||||
bgSumR += float(colr.r); bgSumG += float(colr.g); bgSumB += float(colr.b)
|
||||
inc bgCount
|
||||
|
||||
accum(3, br) # bit0 = bottom-right (matches QuadChars ordering)
|
||||
accum(2, bl) # bit1 = bottom-left
|
||||
accum(1, tr) # bit2 = top-right
|
||||
accum(0, tl) # bit3 = top-left
|
||||
|
||||
var fg: Color
|
||||
var bg: Color
|
||||
if fgCount > 0:
|
||||
fg = (
|
||||
uint8(clamp(int(round(fgSumR / float(fgCount))), 0, 255)),
|
||||
uint8(clamp(int(round(fgSumG / float(fgCount))), 0, 255)),
|
||||
uint8(clamp(int(round(fgSumB / float(fgCount))), 0, 255)),
|
||||
)
|
||||
else:
|
||||
fg = (0'u8, 0, 0)
|
||||
|
||||
if bgCount > 0:
|
||||
bg = (
|
||||
uint8(clamp(int(round(bgSumR / float(bgCount))), 0, 255)),
|
||||
uint8(clamp(int(round(bgSumG / float(bgCount))), 0, 255)),
|
||||
uint8(clamp(int(round(bgSumB / float(bgCount))), 0, 255)),
|
||||
)
|
||||
else:
|
||||
bg = background
|
||||
|
||||
if first or fg != prevFg:
|
||||
parts.add(foreground(fg))
|
||||
prevFg = fg
|
||||
if first or bg != prevBg:
|
||||
parts.add(background(bg))
|
||||
prevBg = bg
|
||||
parts.add(QuadChars[mask])
|
||||
first = false
|
||||
|
||||
parts.add(ResetColor)
|
||||
lines.add(join(parts))
|
||||
|
||||
result = join(lines, "\n")
|
||||
@@ -0,0 +1,109 @@
|
||||
## Terminal image renderer — main API and protocol selector.
|
||||
##
|
||||
## Auto-detects terminal capabilities and renders images using the
|
||||
## best available protocol. The priority order is:
|
||||
## kitty → iterm2 → sixel → quarterblock → halfblock.
|
||||
##
|
||||
## ## Quick Start
|
||||
##
|
||||
## ```nim
|
||||
## import termimg
|
||||
##
|
||||
## let caps = detectCapabilities()
|
||||
## let opts = defaultOptions()
|
||||
## let output = renderImageRgba(pixels, 640, 480, caps, opts)
|
||||
## stdout.write(output)
|
||||
## ```
|
||||
|
||||
import std/[logging]
|
||||
import types
|
||||
import kitty, iterm2, sixel, halfblock, quarterblock
|
||||
|
||||
const
|
||||
PriorityOrder = [ptKitty, ptIterm2, ptSixel, ptQuarterBlock, ptHalfBlock]
|
||||
|
||||
proc selectProtocol*(caps: TerminalCapabilities, preferred: Protocol = ptAuto): Protocol =
|
||||
## Select the best available protocol.
|
||||
if preferred != ptAuto:
|
||||
if caps.supports(preferred):
|
||||
return preferred
|
||||
warn("preferred protocol " & $preferred & " not detected; forcing anyway")
|
||||
return preferred
|
||||
for proto in PriorityOrder:
|
||||
if caps.supports(proto):
|
||||
return proto
|
||||
return ptHalfBlock
|
||||
|
||||
proc renderImageRgba*(data: seq[uint8], width, height: int,
|
||||
caps: TerminalCapabilities,
|
||||
opts: RenderOptions): string =
|
||||
## Render RGBA pixel data to a terminal escape string.
|
||||
## Returns empty string for invalid input (zero dimensions, short buffer).
|
||||
if width <= 0 or height <= 0 or data.len < width * height * 4:
|
||||
return ""
|
||||
|
||||
let img = ImageData(width: width, height: height, data: data)
|
||||
let geo = computeGeometry(width, height, caps, opts)
|
||||
let proto = selectProtocol(caps, opts.protocol)
|
||||
|
||||
case proto
|
||||
of ptKitty:
|
||||
result = renderKitty(img, geo.pixelWidth, geo.pixelHeight)
|
||||
of ptIterm2:
|
||||
result = renderSixel(img, opts.backgroundRgb, geo.pixelWidth, geo.pixelHeight,
|
||||
dither = opts.dither)
|
||||
of ptSixel:
|
||||
result = renderSixel(img, opts.backgroundRgb, geo.pixelWidth, geo.pixelHeight,
|
||||
dither = opts.dither)
|
||||
of ptQuarterBlock:
|
||||
result = renderQuarterBlock(img, opts.backgroundRgb, geo.columns, geo.sampleHeight,
|
||||
dither = opts.dither)
|
||||
of ptHalfBlock:
|
||||
result = renderHalfBlock(img, opts.backgroundRgb, geo.columns, geo.sampleHeight,
|
||||
dither = opts.dither)
|
||||
of ptAuto:
|
||||
result = renderHalfBlock(img, opts.backgroundRgb, geo.columns, geo.sampleHeight,
|
||||
dither = opts.dither)
|
||||
|
||||
proc renderImageRaw*(rawBytes: seq[uint8], width, height: int,
|
||||
caps: TerminalCapabilities,
|
||||
opts: RenderOptions): string =
|
||||
## Render from raw image file bytes.
|
||||
## Returns empty string for invalid input or when iterm2 is unavailable.
|
||||
if width <= 0 or height <= 0:
|
||||
return ""
|
||||
let proto = selectProtocol(caps, opts.protocol)
|
||||
if proto == ptIterm2:
|
||||
let geo = computeGeometry(width, height, caps, opts)
|
||||
result = renderIterm2(rawBytes, geo.pixelWidth, geo.pixelHeight)
|
||||
else:
|
||||
warn("raw bytes rendering requested but iterm2 not available")
|
||||
result = ""
|
||||
|
||||
proc defaultOptions*: RenderOptions =
|
||||
## Sensible defaults: auto protocol, contain fit, half density.
|
||||
RenderOptions(
|
||||
protocol: ptAuto,
|
||||
fit: fmContain,
|
||||
maxWidth: 0,
|
||||
maxHeight: 0,
|
||||
maxWidthRatio: 0.9,
|
||||
maxHeightRatio: 0.8,
|
||||
backgroundRgb: (0'u8, 0'u8, 0'u8),
|
||||
dither: false,
|
||||
density: bdHalf,
|
||||
)
|
||||
|
||||
proc thumbnailOptions*: RenderOptions =
|
||||
## Options tuned for thumbnail rendering: quarter density with dithering.
|
||||
RenderOptions(
|
||||
protocol: ptAuto,
|
||||
fit: fmContain,
|
||||
maxWidth: 40,
|
||||
maxHeight: 20,
|
||||
maxWidthRatio: 0.5,
|
||||
maxHeightRatio: 0.5,
|
||||
backgroundRgb: (0'u8, 0'u8, 0'u8),
|
||||
dither: true,
|
||||
density: bdQuarter,
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
## Shared image scaling: bilinear interpolation for RGBA and RGB buffers.
|
||||
|
||||
import std/math
|
||||
import types
|
||||
|
||||
proc bilinearResizeRgba*(img: ImageData, dstW, dstH: int): seq[uint8] =
|
||||
## Bilinear resize of RGBA image. Edge-clamped sampling.
|
||||
if img.width <= 0 or img.height <= 0 or dstW <= 0 or dstH <= 0:
|
||||
return newSeq[uint8](max(0, dstW * dstH * 4))
|
||||
if img.width == dstW and img.height == dstH:
|
||||
return img.data
|
||||
|
||||
let dataLen = img.data.len
|
||||
let needed = img.width * img.height * 4
|
||||
if dataLen < needed:
|
||||
return newSeq[uint8](max(0, dstW * dstH * 4))
|
||||
|
||||
result = newSeq[uint8](dstW * dstH * 4)
|
||||
let
|
||||
xRatio = float(img.width - 1) / float(max(dstW, 2) - 1)
|
||||
yRatio = float(img.height - 1) / float(max(dstH, 2) - 1)
|
||||
|
||||
for y in 0 ..< dstH:
|
||||
let
|
||||
sy = float(y) * yRatio
|
||||
sy0 = clamp(int(sy), 0, img.height - 1)
|
||||
sy1 = clamp(sy0 + 1, 0, img.height - 1)
|
||||
fy = sy - float(sy0)
|
||||
|
||||
for x in 0 ..< dstW:
|
||||
let
|
||||
sx = float(x) * xRatio
|
||||
sx0 = clamp(int(sx), 0, img.width - 1)
|
||||
sx1 = clamp(sx0 + 1, 0, img.width - 1)
|
||||
fx = sx - float(sx0)
|
||||
|
||||
let
|
||||
off00 = (sy0 * img.width + sx0) * 4
|
||||
off01 = (sy0 * img.width + sx1) * 4
|
||||
off10 = (sy1 * img.width + sx0) * 4
|
||||
off11 = (sy1 * img.width + sx1) * 4
|
||||
|
||||
let dstOff = (y * dstW + x) * 4
|
||||
for c in 0 ..< 4:
|
||||
let
|
||||
v00 = float(img.data[off00 + c])
|
||||
v01 = float(img.data[off01 + c])
|
||||
v10 = float(img.data[off10 + c])
|
||||
v11 = float(img.data[off11 + c])
|
||||
v0 = v00 + (v01 - v00) * fx
|
||||
v1 = v10 + (v11 - v10) * fx
|
||||
v = v0 + (v1 - v0) * fy
|
||||
result[dstOff + c] = uint8(clamp(int(round(v)), 0, 255))
|
||||
|
||||
proc bilinearResizeRgb*(rgb: seq[uint8], srcW, srcH, dstW, dstH: int): seq[uint8] =
|
||||
## Bilinear resize of packed RGB (3 bytes/pixel). Edge-clamped sampling.
|
||||
if srcW <= 0 or srcH <= 0 or dstW <= 0 or dstH <= 0:
|
||||
return newSeq[uint8](max(0, dstW * dstH * 3))
|
||||
let dataLen = rgb.len
|
||||
let needed = srcW * srcH * 3
|
||||
if dataLen < needed:
|
||||
return newSeq[uint8](max(0, dstW * dstH * 3))
|
||||
if srcW == dstW and srcH == dstH:
|
||||
return rgb
|
||||
|
||||
result = newSeq[uint8](dstW * dstH * 3)
|
||||
let
|
||||
xRatio = float(srcW - 1) / float(max(dstW, 2) - 1)
|
||||
yRatio = float(srcH - 1) / float(max(dstH, 2) - 1)
|
||||
|
||||
for y in 0 ..< dstH:
|
||||
let
|
||||
sy = float(y) * yRatio
|
||||
sy0 = clamp(int(sy), 0, srcH - 1)
|
||||
sy1 = clamp(sy0 + 1, 0, srcH - 1)
|
||||
fy = sy - float(sy0)
|
||||
|
||||
for x in 0 ..< dstW:
|
||||
let
|
||||
sx = float(x) * xRatio
|
||||
sx0 = clamp(int(sx), 0, srcW - 1)
|
||||
sx1 = clamp(sx0 + 1, 0, srcW - 1)
|
||||
fx = sx - float(sx0)
|
||||
|
||||
let
|
||||
off00 = (sy0 * srcW + sx0) * 3
|
||||
off01 = (sy0 * srcW + sx1) * 3
|
||||
off10 = (sy1 * srcW + sx0) * 3
|
||||
off11 = (sy1 * srcW + sx1) * 3
|
||||
|
||||
let dstOff = (y * dstW + x) * 3
|
||||
for c in 0 ..< 3:
|
||||
let
|
||||
v00 = float(rgb[off00 + c])
|
||||
v01 = float(rgb[off01 + c])
|
||||
v10 = float(rgb[off10 + c])
|
||||
v11 = float(rgb[off11 + c])
|
||||
v0 = v00 + (v01 - v00) * fx
|
||||
v1 = v10 + (v11 - v10) * fx
|
||||
v = v0 + (v1 - v0) * fy
|
||||
result[dstOff + c] = uint8(clamp(int(round(v)), 0, 255))
|
||||
|
||||
type
|
||||
RgbBuf* = object
|
||||
data*: seq[uint8]
|
||||
w*, h*: int
|
||||
|
||||
proc floydSteinberg*(buf: var RgbBuf) =
|
||||
## Floyd-Steinberg error diffusion dithering.
|
||||
## Modifies buf in place. Use before quantizing to a reduced palette.
|
||||
## Distributes quantization error to neighboring pixels.
|
||||
let stride = buf.w
|
||||
|
||||
for y in 0 ..< buf.h:
|
||||
for x in 0 ..< buf.w:
|
||||
let off = (y * stride + x) * 3
|
||||
# Quantize to nearest valid value; for truecolor this is a no-op,
|
||||
# but callers can override `quantize` per channel.
|
||||
for c in 0 ..< 3:
|
||||
var old = float(buf.data[off + c])
|
||||
var newVal = round(old)
|
||||
if newVal < 0.0: newVal = 0.0
|
||||
if newVal > 255.0: newVal = 255.0
|
||||
let err = old - newVal
|
||||
buf.data[off + c] = uint8(newVal)
|
||||
|
||||
# Distribute error
|
||||
template d(xx, yy, wgt: untyped) =
|
||||
if xx >= 0 and xx < buf.w and yy >= 0 and yy < buf.h:
|
||||
let toff = (yy * stride + xx) * 3
|
||||
buf.data[toff + c] = uint8(clamp(
|
||||
int(round(float(buf.data[toff + c]) + err * wgt)), 0, 255))
|
||||
|
||||
d(x + 1, y, 7.0 / 16.0)
|
||||
d(x - 1, y + 1, 3.0 / 16.0)
|
||||
d(x, y + 1, 5.0 / 16.0)
|
||||
d(x + 1, y + 1, 1.0 / 16.0)
|
||||
@@ -0,0 +1,185 @@
|
||||
## Sixel graphics protocol renderer.
|
||||
##
|
||||
## Encodes images as sixel bands (6 pixels vertical per band)
|
||||
## with a 256-color palette and RLE compression.
|
||||
## Supports bilinear scaling and optional Floyd-Steinberg dithering.
|
||||
|
||||
import std/[math, tables, algorithm, strutils]
|
||||
import types, scaling
|
||||
|
||||
const
|
||||
Dcs = "\eP"
|
||||
St = "\e\\"
|
||||
SixelBandHeight = 6
|
||||
SixelMaxColors = 256
|
||||
SixelColorScale = 100
|
||||
SixelPrintableOffset = 63
|
||||
RleIntro = '!'
|
||||
RepeatThreshold = 3
|
||||
RgbColorSpace = 2
|
||||
|
||||
type
|
||||
Rgb = tuple[r, g, b: int]
|
||||
|
||||
proc quantizeColor(r, g, b: uint8): Rgb =
|
||||
let scale = float(SixelColorScale) / 255.0
|
||||
(
|
||||
int(round(float(r) * scale)),
|
||||
int(round(float(g) * scale)),
|
||||
int(round(float(b) * scale)),
|
||||
)
|
||||
|
||||
proc encodeRun(code: int, count: int): string =
|
||||
let glyph = char(SixelPrintableOffset + code)
|
||||
if count > RepeatThreshold:
|
||||
result = RleIntro & $count & glyph
|
||||
else:
|
||||
result = repeat(glyph, count)
|
||||
|
||||
proc encodeRow(codes: seq[int]): string =
|
||||
var parts: seq[string]
|
||||
if codes.len == 0:
|
||||
return ""
|
||||
var runCode = codes[0]
|
||||
var runLen = 0
|
||||
for c in codes:
|
||||
if c == runCode:
|
||||
inc runLen
|
||||
else:
|
||||
parts.add(encodeRun(runCode, runLen))
|
||||
runCode = c
|
||||
runLen = 1
|
||||
parts.add(encodeRun(runCode, runLen))
|
||||
result = join(parts)
|
||||
|
||||
proc compositeOver(img: ImageData, bg: tuple[r, g, b: uint8]): seq[uint8] =
|
||||
let total = img.width * img.height
|
||||
result = newSeq[uint8](total * 3)
|
||||
for i in 0 ..< total:
|
||||
let off = i * 4
|
||||
let a = float(img.data[off + 3]) / 255.0
|
||||
let inva = 1.0 - a
|
||||
result[i * 3 + 0] = uint8(float(img.data[off + 0]) * a + float(bg.r) * inva)
|
||||
result[i * 3 + 1] = uint8(float(img.data[off + 1]) * a + float(bg.g) * inva)
|
||||
result[i * 3 + 2] = uint8(float(img.data[off + 2]) * a + float(bg.b) * inva)
|
||||
|
||||
proc floydSteinbergSixel(buf: var seq[uint8], w, h: int) =
|
||||
for y in 0 ..< h:
|
||||
for x in 0 ..< w:
|
||||
let off = (y * w + x) * 3
|
||||
for c in 0 ..< 3:
|
||||
var old = float(buf[off + c])
|
||||
var newVal = round(old)
|
||||
if newVal < 0.0: newVal = 0.0
|
||||
if newVal > 255.0: newVal = 255.0
|
||||
let err = old - newVal
|
||||
buf[off + c] = uint8(newVal)
|
||||
template d(xx, yy, wgt: untyped) =
|
||||
if xx >= 0 and xx < w and yy >= 0 and yy < h:
|
||||
let toff = (yy * w + xx) * 3
|
||||
buf[toff + c] = uint8(clamp(
|
||||
int(round(float(buf[toff + c]) + err * wgt)), 0, 255))
|
||||
d(x + 1, y, 7.0 / 16.0)
|
||||
d(x - 1, y + 1, 3.0 / 16.0)
|
||||
d(x, y + 1, 5.0 / 16.0)
|
||||
d(x + 1, y + 1, 1.0 / 16.0)
|
||||
|
||||
proc quantize(rgb: seq[uint8], pixelCount: int): (seq[int], seq[Rgb]) =
|
||||
var palette: seq[Rgb]
|
||||
var indexMap = initTable[tuple[r, g, b: int], int]()
|
||||
result[0] = newSeq[int](pixelCount)
|
||||
|
||||
for i in 0 ..< pixelCount:
|
||||
let q = quantizeColor(rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2])
|
||||
if q notin indexMap:
|
||||
if indexMap.len >= SixelMaxColors:
|
||||
var bestIdx = -1
|
||||
var bestDist = high(int)
|
||||
for j, p in palette:
|
||||
let dr = q.r - p.r
|
||||
let dg = q.g - p.g
|
||||
let db = q.b - p.b
|
||||
let dist = dr * dr + dg * dg + db * db
|
||||
if dist < bestDist:
|
||||
bestDist = dist
|
||||
bestIdx = j
|
||||
indexMap[q] = bestIdx
|
||||
else:
|
||||
let idx = palette.len
|
||||
palette.add(q)
|
||||
indexMap[q] = idx
|
||||
result[0][i] = indexMap[q]
|
||||
|
||||
proc renderSixel*(
|
||||
img: ImageData,
|
||||
background: tuple[r, g, b: uint8],
|
||||
targetW, targetH: int,
|
||||
dither: bool = false,
|
||||
): string =
|
||||
## Produce sixel escape sequence for the given image.
|
||||
## Returns empty string if source or target dimensions are invalid.
|
||||
if img.width <= 0 or img.height <= 0 or targetW <= 0 or targetH <= 0:
|
||||
return ""
|
||||
if img.data.len < img.width * img.height * 4:
|
||||
return ""
|
||||
|
||||
let rgb = compositeOver(img, background)
|
||||
var rgbScaled = bilinearResizeRgb(rgb, img.width, img.height, targetW, targetH)
|
||||
|
||||
if dither:
|
||||
floydSteinbergSixel(rgbScaled, targetW, targetH)
|
||||
|
||||
let pixelCount = targetW * targetH
|
||||
let (indices, palette) = quantize(rgbScaled, pixelCount)
|
||||
|
||||
var parts: seq[string]
|
||||
parts.add(Dcs)
|
||||
parts.add("q")
|
||||
parts.add("\"1;1;" & $targetW & ";" & $targetH)
|
||||
|
||||
var colorOrder: seq[int]
|
||||
for c in indices:
|
||||
if c notin colorOrder:
|
||||
colorOrder.add(c)
|
||||
|
||||
var colorSeen: set[0 .. 255]
|
||||
for c in colorOrder:
|
||||
if c in colorSeen: continue
|
||||
colorSeen.incl c
|
||||
let p = palette[c]
|
||||
parts.add("#" & $c & ";" & $RgbColorSpace & ";" & $p.r & ";" & $p.g & ";" & $p.b)
|
||||
|
||||
var bandLines: seq[string]
|
||||
for top in countup(0, targetH - 1, SixelBandHeight):
|
||||
let depth = min(SixelBandHeight, targetH - top)
|
||||
var bandColors: seq[int]
|
||||
var seen: set[0 .. 255]
|
||||
for row in 0 ..< depth:
|
||||
let base = (top + row) * targetW
|
||||
for col in 0 ..< targetW:
|
||||
let c = indices[base + col]
|
||||
if c notin seen:
|
||||
seen.incl c
|
||||
bandColors.add(c)
|
||||
sort(bandColors)
|
||||
|
||||
var passes: seq[string]
|
||||
for color in bandColors:
|
||||
var codes: seq[int]
|
||||
for col in 0 ..< targetW:
|
||||
var sixelVal = 0
|
||||
for row in 0 ..< depth:
|
||||
let idx = (top + row) * targetW + col
|
||||
if indices[idx] == color:
|
||||
sixelVal = sixelVal or (1 shl row)
|
||||
codes.add(sixelVal)
|
||||
passes.add("#" & $color & encodeRow(codes))
|
||||
|
||||
if passes.len > 0:
|
||||
bandLines.add(join(passes, "$"))
|
||||
else:
|
||||
bandLines.add("")
|
||||
|
||||
parts.add(join(bandLines, "-"))
|
||||
parts.add(St)
|
||||
result = join(parts)
|
||||
@@ -0,0 +1,161 @@
|
||||
## Terminal image rendering — shared types and geometry calculations.
|
||||
|
||||
import std/math
|
||||
|
||||
type
|
||||
Protocol* = enum
|
||||
ptKitty = "kitty"
|
||||
ptSixel = "sixel"
|
||||
ptIterm2 = "iterm2"
|
||||
ptHalfBlock = "halfblock"
|
||||
ptQuarterBlock = "quarterblock"
|
||||
ptAuto = "auto"
|
||||
|
||||
FitMode* = enum
|
||||
fmContain = "contain"
|
||||
fmStretch = "stretch"
|
||||
fmWidth = "width"
|
||||
fmOriginal = "original"
|
||||
fmCellExact = "cellexact"
|
||||
|
||||
BlockDensity* = enum
|
||||
bdFull = 1 ## 1 pixel sample per cell (█ solid block)
|
||||
bdHalf = 2 ## 2 vertical samples per cell (▀ half block)
|
||||
bdQuarter = 4 ## 4 samples per cell (2×2 quadrant blocks)
|
||||
|
||||
CellSize* = object
|
||||
width*: int
|
||||
height*: int
|
||||
|
||||
TerminalCapabilities* = object
|
||||
columns*: int
|
||||
rows*: int
|
||||
cell*: CellSize
|
||||
protocols*: set[Protocol]
|
||||
|
||||
Geometry* = object
|
||||
pixelWidth*: int
|
||||
pixelHeight*: int
|
||||
columns*: int
|
||||
sampleHeight*: int
|
||||
|
||||
ImageData* = object
|
||||
width*: int
|
||||
height*: int
|
||||
data*: seq[uint8] ## RGBA packed, row-major (width*height*4 bytes)
|
||||
|
||||
RenderOptions* = object
|
||||
protocol*: Protocol
|
||||
fit*: FitMode
|
||||
maxWidth*: int
|
||||
maxHeight*: int
|
||||
maxWidthRatio*: float
|
||||
maxHeightRatio*: float
|
||||
backgroundRgb*: tuple[r, g, b: uint8]
|
||||
dither*: bool
|
||||
density*: BlockDensity
|
||||
|
||||
Frame* = object
|
||||
image*: ImageData
|
||||
duration*: float
|
||||
|
||||
ImageSource* = object
|
||||
frames*: seq[Frame]
|
||||
loop*: int
|
||||
|
||||
const
|
||||
ReservedRows* = 1
|
||||
DefaultCellWidth* = 10
|
||||
DefaultCellHeight* = 20
|
||||
|
||||
proc pixelWidth*(caps: TerminalCapabilities): int =
|
||||
caps.columns * caps.cell.width
|
||||
|
||||
proc pixelHeight*(caps: TerminalCapabilities): int =
|
||||
caps.rows * caps.cell.height
|
||||
|
||||
proc supports*(caps: TerminalCapabilities, proto: Protocol): bool =
|
||||
proto in caps.protocols
|
||||
|
||||
proc animated*(src: ImageSource): bool =
|
||||
src.frames.len > 1
|
||||
|
||||
func scaleToFit(w, h, maxW, maxH: int): (int, int) =
|
||||
if w <= 0 or h <= 0:
|
||||
return (1, 1)
|
||||
let ratio = min(float(maxW) / float(w), float(maxH) / float(h))
|
||||
(max(1, int(float(w) * ratio)), max(1, int(float(h) * ratio)))
|
||||
|
||||
func scaleToWidth(w, h, maxW: int): (int, int) =
|
||||
if w <= 0: return (1, 1)
|
||||
let ratio = float(maxW) / float(w)
|
||||
(maxW, max(1, int(float(h) * ratio)))
|
||||
|
||||
proc computeGeometry*(
|
||||
imageWidth, imageHeight: int,
|
||||
caps: TerminalCapabilities,
|
||||
opts: RenderOptions,
|
||||
): Geometry =
|
||||
## Compute render dimensions preserving aspect ratio.
|
||||
## Uses maxWidthRatio and maxHeightRatio to bound the image within
|
||||
## the terminal's pixel area. The density setting determines
|
||||
## how many pixel samples map to each terminal cell.
|
||||
|
||||
let cell = caps.cell
|
||||
|
||||
# Effective terminal pixel budget
|
||||
let termPxW = int(float(caps.columns * cell.width) * opts.maxWidthRatio)
|
||||
let termPxH = int(float(caps.rows * cell.height) * opts.maxHeightRatio)
|
||||
|
||||
# User overrides take precedence
|
||||
let userCols = if opts.maxWidth > 0: opts.maxWidth else: caps.columns
|
||||
let userRows = if opts.maxHeight > 0: opts.maxHeight else: caps.rows
|
||||
|
||||
var maxPxW = min(userCols * cell.width, termPxW)
|
||||
var maxPxH = min(userRows * cell.height, termPxH)
|
||||
maxPxW = max(maxPxW, cell.width) # at least one cell
|
||||
maxPxH = max(maxPxH, cell.height)
|
||||
|
||||
if maxPxH > (caps.rows - ReservedRows) * cell.height:
|
||||
maxPxH = max(1, (caps.rows - ReservedRows) * cell.height)
|
||||
|
||||
var (pxW, pxH) =
|
||||
case opts.fit
|
||||
of fmStretch:
|
||||
(maxPxW, maxPxH)
|
||||
of fmWidth:
|
||||
if imageWidth <= 0 or imageHeight <= 0:
|
||||
(cell.width, cell.height)
|
||||
else:
|
||||
scaleToWidth(imageWidth, imageHeight, maxPxW)
|
||||
of fmOriginal:
|
||||
if imageWidth <= 0 or imageHeight <= 0:
|
||||
(cell.width, cell.height)
|
||||
elif imageWidth <= maxPxW and imageHeight <= maxPxH:
|
||||
(imageWidth, imageHeight)
|
||||
else:
|
||||
scaleToFit(imageWidth, imageHeight, maxPxW, maxPxH)
|
||||
of fmContain:
|
||||
if imageWidth <= 0 or imageHeight <= 0:
|
||||
(cell.width, cell.height)
|
||||
else:
|
||||
scaleToFit(imageWidth, imageHeight, maxPxW, maxPxH)
|
||||
of fmCellExact:
|
||||
if imageWidth <= 0 or imageHeight <= 0:
|
||||
(cell.width, cell.height)
|
||||
else:
|
||||
# User specifies exact cell count; derive pixels from cell size.
|
||||
let cols = if opts.maxWidth > 0: opts.maxWidth else: caps.columns
|
||||
let scaleRatio = float(cols * cell.width) / float(imageWidth)
|
||||
(cols * cell.width, max(1, int(float(imageHeight) * scaleRatio)))
|
||||
|
||||
let densityPx = int(opts.density)
|
||||
let columns = max(1, int(round(float(pxW) / float(cell.width))))
|
||||
let sampleRows = max(densityPx, int(round(float(pxH) / float(cell.height) * float(densityPx))))
|
||||
|
||||
Geometry(
|
||||
pixelWidth: pxW,
|
||||
pixelHeight: pxH,
|
||||
columns: columns,
|
||||
sampleHeight: sampleRows,
|
||||
)
|
||||
Reference in New Issue
Block a user