fix(tui): cap portrait image render height

This commit is contained in:
Mario Zechner
2026-05-13 16:11:40 +02:00
parent c00f1cf130
commit e3faf41801
4 changed files with 82 additions and 12 deletions

View File

@@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Fixed
- Fixed inline image rendering to cap portrait images by height instead of always scaling them to the configured maximum width.
## [0.74.0] - 2026-05-07 ## [0.74.0] - 2026-05-07
## [0.73.1] - 2026-05-07 ## [0.73.1] - 2026-05-07

View File

@@ -1,6 +1,7 @@
import { import {
allocateImageId, allocateImageId,
getCapabilities, getCapabilities,
getCellDimensions,
getImageDimensions, getImageDimensions,
type ImageDimensions, type ImageDimensions,
imageFallback, imageFallback,
@@ -61,7 +62,10 @@ export class Image implements Component {
return this.cachedLines; return this.cachedLines;
} }
const maxWidth = Math.min(width - 2, this.options.maxWidthCells ?? 60); const maxWidth = Math.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60));
const cellDimensions = getCellDimensions();
const defaultMaxHeight = Math.max(1, Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx));
const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight;
const caps = getCapabilities(); const caps = getCapabilities();
let lines: string[]; let lines: string[];
@@ -72,6 +76,7 @@ export class Image implements Component {
} }
const result = renderImage(this.base64Data, this.dimensions, { const result = renderImage(this.base64Data, this.dimensions, {
maxWidthCells: maxWidth, maxWidthCells: maxWidth,
maxHeightCells: maxHeight,
imageId: this.imageId, imageId: this.imageId,
moveCursor: false, moveCursor: false,
}); });

View File

@@ -211,16 +211,43 @@ export function encodeITerm2(
return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`; return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
} }
export interface ImageCellSize {
columns: number;
rows: number;
}
export function calculateImageCellSize(
imageDimensions: ImageDimensions,
maxWidthCells: number,
maxHeightCells?: number,
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
): ImageCellSize {
const maxWidth = Math.max(1, Math.floor(maxWidthCells));
const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells));
const imageWidth = Math.max(1, imageDimensions.widthPx);
const imageHeight = Math.max(1, imageDimensions.heightPx);
const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth;
const heightScale = maxHeight === undefined ? widthScale : (maxHeight * cellDimensions.heightPx) / imageHeight;
const scale = Math.min(widthScale, heightScale);
const scaledWidthPx = imageWidth * scale;
const scaledHeightPx = imageHeight * scale;
const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx);
const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
return {
columns: Math.max(1, Math.min(maxWidth, columns)),
rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)),
};
}
export function calculateImageRows( export function calculateImageRows(
imageDimensions: ImageDimensions, imageDimensions: ImageDimensions,
targetWidthCells: number, targetWidthCells: number,
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
): number { ): number {
const targetWidthPx = targetWidthCells * cellDimensions.widthPx; return calculateImageCellSize(imageDimensions, targetWidthCells, undefined, cellDimensions).rows;
const scale = targetWidthPx / imageDimensions.widthPx;
const scaledHeightPx = imageDimensions.heightPx * scale;
const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
return Math.max(1, rows);
} }
export function getPngDimensions(base64Data: string): ImageDimensions | null { export function getPngDimensions(base64Data: string): ImageDimensions | null {
@@ -376,25 +403,25 @@ export function renderImage(
} }
const maxWidth = options.maxWidthCells ?? 80; const maxWidth = options.maxWidthCells ?? 80;
const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions()); const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions());
if (caps.images === "kitty") { if (caps.images === "kitty") {
const sequence = encodeKitty(base64Data, { const sequence = encodeKitty(base64Data, {
columns: maxWidth, columns: size.columns,
rows, rows: size.rows,
imageId: options.imageId, imageId: options.imageId,
moveCursor: options.moveCursor, moveCursor: options.moveCursor,
}); });
return { sequence, rows, imageId: options.imageId }; return { sequence, rows: size.rows, imageId: options.imageId };
} }
if (caps.images === "iterm2") { if (caps.images === "iterm2") {
const sequence = encodeITerm2(base64Data, { const sequence = encodeITerm2(base64Data, {
width: maxWidth, width: size.columns,
height: "auto", height: "auto",
preserveAspectRatio: options.preserveAspectRatio ?? true, preserveAspectRatio: options.preserveAspectRatio ?? true,
}); });
return { sequence, rows }; return { sequence, rows: size.rows };
} }
return null; return null;

View File

@@ -312,6 +312,40 @@ describe("Kitty image cursor movement", () => {
} }
}); });
it("honors maxHeightCells by reducing rendered width", () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
setCellDimensions({ widthPx: 10, heightPx: 10 });
try {
const result = renderImage("AAAA", { widthPx: 10, heightPx: 100 }, { maxWidthCells: 10, maxHeightCells: 5 });
assert.ok(result);
assert.strictEqual(result.rows, 5);
assert.ok(result.sequence.includes(",c=1,r=5"));
} finally {
resetCapabilitiesCache();
setCellDimensions({ widthPx: 9, heightPx: 18 });
}
});
it("caps Image component height to a square pixel box by default", () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
setCellDimensions({ widthPx: 10, heightPx: 20 });
try {
const image = new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ maxWidthCells: 10 },
{ widthPx: 10, heightPx: 100 },
);
const lines = image.render(12);
assert.strictEqual(lines.length, 5);
assert.ok(lines[0].includes(",c=1,r=5"));
} finally {
resetCapabilitiesCache();
setCellDimensions({ widthPx: 9, heightPx: 18 });
}
});
it("places image sequence on first line with empty padding rows", () => { it("places image sequence on first line with empty padding rows", () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
setCellDimensions({ widthPx: 10, heightPx: 10 }); setCellDimensions({ widthPx: 10, heightPx: 10 });