From 669aace3cfe8fc80fc69334da3de8975b50e0534 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 18:12:32 +0900 Subject: [PATCH 1/3] fix: crop right whitespace from Ruby tab screenshot Scan pixel data after capture to find the rightmost non-white column, then trim the image to that boundary plus a small padding. This removes the large right-side whitespace that appeared when editor width exceeded code length. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scratch-gui/src/lib/ruby-screenshot.js | 71 ++++++- .../test/unit/lib/ruby-screenshot.test.js | 192 ++++++++++++++++++ 2 files changed, 262 insertions(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/lib/ruby-screenshot.js b/packages/scratch-gui/src/lib/ruby-screenshot.js index 5834d7c089..ba2f2a3bd5 100644 --- a/packages/scratch-gui/src/lib/ruby-screenshot.js +++ b/packages/scratch-gui/src/lib/ruby-screenshot.js @@ -3,6 +3,9 @@ import {toBlob} from 'html-to-image'; import downloadBlob from './download-blob'; +/** Threshold below which a colour channel is considered "content" (handles anti-aliasing). */ +const WHITE_THRESHOLD = 250; + /** * Builds the export filename for Ruby tab screenshots. * @param {string} projectTitle - Project name @@ -13,6 +16,70 @@ const buildFilename = function (projectTitle, spriteName) { return `${projectTitle}_${spriteName}_ruby.png`; }; +/** + * Trims right-side whitespace from a PNG blob by scanning pixel data. + * Finds the rightmost non-white column, then crops the image to that + * column plus padding. Returns the original blob if cropping would not + * save meaningful space. + * @param {Blob} blob - source PNG blob + * @param {number} padding - extra pixels to keep to the right of content + * @returns {Promise} cropped (or original) PNG blob + */ +const cropRightWhitespace = async function (blob, padding = 32) { + const bitmap = await createImageBitmap(blob); + const {width, height} = bitmap; + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + + // Scan columns from right to find the rightmost column with content + const imageData = ctx.getImageData(0, 0, width, height); + const {data} = imageData; + + let rightmostContentX = 0; + for (let x = width - 1; x >= 0; x--) { + let hasContent = false; + // Sample every 4th row for performance + for (let y = 0; y < height; y += 4) { + const idx = ((y * width) + x) * 4; + if (data[idx] < WHITE_THRESHOLD || + data[idx + 1] < WHITE_THRESHOLD || + data[idx + 2] < WHITE_THRESHOLD) { + hasContent = true; + break; + } + } + if (hasContent) { + rightmostContentX = x; + break; + } + } + + const cropWidth = Math.min(width, rightmostContentX + 1 + padding); + + // If cropping would not save meaningful space, return the original + if (cropWidth >= width - padding) { + return blob; + } + + // Create a cropped canvas and export as PNG blob + const croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = height; + const croppedCtx = croppedCanvas.getContext('2d'); + croppedCtx.drawImage(canvas, 0, 0, cropWidth, height, 0, 0, cropWidth, height); + + return new Promise(resolve => { + croppedCanvas.toBlob(croppedBlob => { + resolve(croppedBlob || blob); + }, 'image/png'); + }); +}; + /** * Captures the Monaco editor content as a PNG image and triggers a download. * Temporarily expands the editor to its full content height so that all lines @@ -69,7 +136,8 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) { }); if (blob) { - downloadBlob(buildFilename(projectTitle, spriteName), blob); + const croppedBlob = await cropRightWhitespace(blob); + downloadBlob(buildFilename(projectTitle, spriteName), croppedBlob); } } finally { // Restore original state @@ -84,5 +152,6 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) { export { buildFilename, + cropRightWhitespace, downloadRubyAsImage }; diff --git a/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js b/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js index 1afc7d7afa..95843a0f09 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js @@ -1,5 +1,6 @@ import { buildFilename, + cropRightWhitespace, downloadRubyAsImage } from '../../../src/lib/ruby-screenshot'; @@ -52,11 +53,202 @@ describe('buildFilename', () => { }); }); +// ---- cropRightWhitespace ---- + +/** + * Helper: build a fake ImageData-like pixel buffer. + * Creates a width x height image where `contentCols` leftmost columns + * contain non-white pixels and the rest are white (#ffffff). + * @param {number} width - image width in pixels + * @param {number} height - image height in pixels + * @param {number} contentCols - number of leftmost columns with content + * @returns {Uint8ClampedArray} RGBA pixel data + */ +const buildPixelData = (width, height, contentCols) => { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + if (x < contentCols) { + // Dark pixel (content) + data[idx] = 0; + data[idx + 1] = 0; + data[idx + 2] = 0; + data[idx + 3] = 255; + } else { + // White pixel (background) + data[idx] = 255; + data[idx + 1] = 255; + data[idx + 2] = 255; + data[idx + 3] = 255; + } + } + } + return data; +}; + +/** + * Set up Canvas/ImageBitmap mocks for cropRightWhitespace tests. + * @param {number} width - image width + * @param {number} height - image height + * @param {Uint8ClampedArray} pixelData - RGBA pixel buffer + * @returns {object} mock references for assertions + */ +const setupCanvasMocks = (width, height, pixelData) => { + const mockCtx = { + drawImage: jest.fn(), + getImageData: jest.fn(() => ({data: pixelData})) + }; + const croppedCtx = { + drawImage: jest.fn() + }; + let canvasCount = 0; + const canvases = []; + const realCreateElement = document.createElement.bind(document); + jest.spyOn(document, 'createElement').mockImplementation(tag => { + if (tag === 'canvas') { + const c = realCreateElement('canvas'); + canvases.push(c); + canvasCount++; + c.getContext = jest.fn(() => (canvasCount === 1 ? mockCtx : croppedCtx)); + c.toBlob = jest.fn(cb => cb(new Blob(['cropped'], {type: 'image/png'}))); + return c; + } + return realCreateElement(tag); + }); + global.createImageBitmap = jest.fn(() => + Promise.resolve({width, height, close: jest.fn()}) + ); + return {mockCtx, croppedCtx, canvases}; +}; + +describe('cropRightWhitespace', () => { + afterEach(() => { + document.createElement.mockRestore?.(); + delete global.createImageBitmap; + }); + + test('crops right whitespace to content width + padding', async () => { + const width = 200; + const height = 100; + const contentCols = 120; + const padding = 32; + const pixelData = buildPixelData(width, height, contentCols); + const {croppedCtx, canvases} = setupCanvasMocks(width, height, pixelData); + + const inputBlob = new Blob(['original'], {type: 'image/png'}); + const result = await cropRightWhitespace(inputBlob, padding); + + // Should return a new (cropped) blob, not the original + expect(result).not.toBe(inputBlob); + + // Cropped canvas width = contentCols + padding + const croppedCanvas = canvases[1]; + expect(croppedCanvas.width).toBe(contentCols + padding); + expect(croppedCanvas.height).toBe(height); + + // drawImage should copy the cropped region + expect(croppedCtx.drawImage).toHaveBeenCalledTimes(1); + }); + + test('returns original blob when image has little whitespace', async () => { + const width = 200; + const height = 100; + const contentCols = 190; // almost full width + const padding = 32; + const pixelData = buildPixelData(width, height, contentCols); + setupCanvasMocks(width, height, pixelData); + + const inputBlob = new Blob(['original'], {type: 'image/png'}); + const result = await cropRightWhitespace(inputBlob, padding); + + // Content + padding >= width, so no cropping needed + expect(result).toBe(inputBlob); + }); + + test('handles anti-aliased pixels (near-white but not pure white)', async () => { + const width = 100; + const height = 10; + const padding = 16; + // Build all-white image, then add a near-white pixel at column 60 + const data = buildPixelData(width, height, 50); + // Add anti-aliased pixel at (60, 4): RGB = (240, 245, 248) + // Values must be below WHITE_THRESHOLD (250) to be detected as content. + // Row must be a multiple of 4 to be hit by the sampling stride. + const aaIdx = (4 * width + 60) * 4; + data[aaIdx] = 240; + data[aaIdx + 1] = 245; + data[aaIdx + 2] = 248; + data[aaIdx + 3] = 255; + + const {canvases} = setupCanvasMocks(width, height, data); + + const inputBlob = new Blob(['img'], {type: 'image/png'}); + const result = await cropRightWhitespace(inputBlob, padding); + + expect(result).not.toBe(inputBlob); + // rightmost content is at column 60, so crop width = 61 + padding + expect(canvases[1].width).toBe(61 + padding); + }); + + test('returns original blob when entire image is content', async () => { + const width = 100; + const height = 50; + const pixelData = buildPixelData(width, height, width); // all content + setupCanvasMocks(width, height, pixelData); + + const inputBlob = new Blob(['img'], {type: 'image/png'}); + const result = await cropRightWhitespace(inputBlob, 16); + + expect(result).toBe(inputBlob); + }); + + test('uses default padding of 32 when not specified', async () => { + const width = 200; + const height = 50; + const contentCols = 100; + const pixelData = buildPixelData(width, height, contentCols); + const {canvases} = setupCanvasMocks(width, height, pixelData); + + const inputBlob = new Blob(['img'], {type: 'image/png'}); + await cropRightWhitespace(inputBlob); + + expect(canvases[1].width).toBe(contentCols + 32); + }); +}); + // ---- downloadRubyAsImage ---- describe('downloadRubyAsImage', () => { beforeEach(() => { jest.clearAllMocks(); + // Mock createImageBitmap for cropRightWhitespace called inside downloadRubyAsImage. + // Return a tiny all-white image so cropping is skipped (returns original blob). + global.createImageBitmap = jest.fn(() => + Promise.resolve({width: 10, height: 10, close: jest.fn()}) + ); + const mockCtx = { + drawImage: jest.fn(), + getImageData: jest.fn(() => { + // All-white pixels → no crop + const data = new Uint8ClampedArray(10 * 10 * 4); + data.fill(255); + return {data}; + }) + }; + const realCreateElement = document.createElement.bind(document); + jest.spyOn(document, 'createElement').mockImplementation(tag => { + const el = realCreateElement(tag); + if (tag === 'canvas') { + el.getContext = jest.fn(() => mockCtx); + } + return el; + }); + }); + + afterEach(() => { + document.createElement.mockRestore?.(); + delete global.createImageBitmap; }); test('does nothing when editor is null', async () => { From ba2749797f5d57e19e43c515315e70d74c245c1d Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 18:19:53 +0900 Subject: [PATCH 2/3] fix: disable line highlight and scrollbar before screenshot capture The cursor line highlight and scrollbar span the full editor width, which prevents cropRightWhitespace from detecting the true content boundary. Temporarily set renderLineHighlight to 'none' and hide scrollbars during capture. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scratch-gui/src/lib/ruby-screenshot.js | 18 +++++++++++++++--- .../test/unit/lib/ruby-screenshot.test.js | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-screenshot.js b/packages/scratch-gui/src/lib/ruby-screenshot.js index ba2f2a3bd5..6a324fb7c2 100644 --- a/packages/scratch-gui/src/lib/ruby-screenshot.js +++ b/packages/scratch-gui/src/lib/ruby-screenshot.js @@ -107,8 +107,15 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) { const originalOverflow = containerEl.style.overflow; try { - // Disable scrollBeyondLastLine to get tight content height - editor.updateOptions({scrollBeyondLastLine: false}); + // Disable visual elements that span the full editor width, which + // would otherwise prevent cropRightWhitespace from detecting the + // true content boundary. + editor.updateOptions({ + scrollBeyondLastLine: false, + renderLineHighlight: 'none', + scrollbar: {vertical: 'hidden', horizontal: 'hidden'}, + hideCursorInOverviewRuler: true + }); editor.layout(); // Expand editor to full content height so all lines are in the DOM @@ -141,7 +148,12 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) { } } finally { // Restore original state - editor.updateOptions({scrollBeyondLastLine: true}); + editor.updateOptions({ + scrollBeyondLastLine: true, + renderLineHighlight: 'line', + scrollbar: {vertical: 'auto', horizontal: 'auto'}, + hideCursorInOverviewRuler: false + }); containerEl.style.height = originalHeight; containerEl.style.overflow = originalOverflow; editor.layout(); diff --git a/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js b/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js index 95843a0f09..a3c543427e 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js @@ -360,7 +360,7 @@ describe('downloadRubyAsImage', () => { expect(editor.layout).toHaveBeenCalled(); }); - test('disables scrollBeyondLastLine before capture and restores it after', async () => { + test('disables full-width visual elements before capture and restores them after', async () => { const mockBlob = new Blob(['test'], {type: 'image/png'}); toBlob.mockResolvedValue(mockBlob); @@ -368,9 +368,20 @@ describe('downloadRubyAsImage', () => { await downloadRubyAsImage(editor, 'project', 'sprite'); - // First call disables, second call (in finally) re-enables - expect(editor.updateOptions).toHaveBeenCalledWith({scrollBeyondLastLine: false}); - expect(editor.updateOptions).toHaveBeenCalledWith({scrollBeyondLastLine: true}); + // First call disables visual elements for clean capture + expect(editor.updateOptions).toHaveBeenCalledWith({ + scrollBeyondLastLine: false, + renderLineHighlight: 'none', + scrollbar: {vertical: 'hidden', horizontal: 'hidden'}, + hideCursorInOverviewRuler: true + }); + // Second call (in finally) re-enables them + expect(editor.updateOptions).toHaveBeenCalledWith({ + scrollBeyondLastLine: true, + renderLineHighlight: 'line', + scrollbar: {vertical: 'auto', horizontal: 'auto'}, + hideCursorInOverviewRuler: false + }); }); test('calls editor.layout() to trigger re-render', async () => { From e7d63b805681d3bc5f73a3ab2ab67baed023d4bf Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 16 Mar 2026 18:27:58 +0900 Subject: [PATCH 3/3] fix: replace pixel scanning with DOM-based text width measurement Pixel scanning could not reliably detect the content boundary due to Monaco's decorative elements (overview ruler, scrollbar, line highlight) spanning the full editor width. Instead, measure the actual rendered text and furigana span widths from the DOM before capture, then crop the image to that width using Canvas API. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scratch-gui/src/lib/ruby-screenshot.js | 156 +++++----- .../test/unit/lib/ruby-screenshot.test.js | 270 +++++++----------- 2 files changed, 193 insertions(+), 233 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-screenshot.js b/packages/scratch-gui/src/lib/ruby-screenshot.js index 6a324fb7c2..9203608d4e 100644 --- a/packages/scratch-gui/src/lib/ruby-screenshot.js +++ b/packages/scratch-gui/src/lib/ruby-screenshot.js @@ -3,9 +3,6 @@ import {toBlob} from 'html-to-image'; import downloadBlob from './download-blob'; -/** Threshold below which a colour channel is considered "content" (handles anti-aliasing). */ -const WHITE_THRESHOLD = 250; - /** * Builds the export filename for Ruby tab screenshots. * @param {string} projectTitle - Project name @@ -17,64 +14,80 @@ const buildFilename = function (projectTitle, spriteName) { }; /** - * Trims right-side whitespace from a PNG blob by scanning pixel data. - * Finds the rightmost non-white column, then crops the image to that - * column plus padding. Returns the original blob if cropping would not - * save meaningful space. + * Measures the maximum rendered text width across all visible lines + * in the Monaco editor, relative to the editor DOM node's left edge. + * This is more reliable than pixel scanning because Monaco renders + * decorative elements (overview ruler, scrollbar, line highlights) + * that span the full editor width. + * @param {HTMLElement} editorDomNode - the editor's root DOM node + * @returns {number} maximum text right edge in CSS pixels, relative to editorDomNode + */ +const measureTextWidth = function (editorDomNode) { + const viewLines = editorDomNode.querySelector('.view-lines'); + if (!viewLines) return 0; + + const editorLeft = editorDomNode.getBoundingClientRect().left; + let maxRight = 0; + + for (const line of viewLines.children) { + const spans = line.querySelectorAll('span span'); + for (const span of spans) { + const right = span.getBoundingClientRect().right; + if (right > maxRight) maxRight = right; + } + } + + return maxRight > 0 ? maxRight - editorLeft : 0; +}; + +/** + * Measures the maximum rendered furigana annotation width. + * ViewZones may extend beyond the code text width. + * @param {HTMLElement} editorDomNode - the editor's root DOM node + * @returns {number} maximum furigana right edge in CSS pixels, relative to editorDomNode + */ +const measureFuriganaWidth = function (editorDomNode) { + const viewZones = editorDomNode.querySelector('.view-zones'); + if (!viewZones) return 0; + + const editorLeft = editorDomNode.getBoundingClientRect().left; + let maxRight = 0; + + const spans = viewZones.querySelectorAll('span'); + for (const span of spans) { + const right = span.getBoundingClientRect().right; + if (right > maxRight) maxRight = right; + } + + return maxRight > 0 ? maxRight - editorLeft : 0; +}; + +/** + * Crops a PNG blob to the specified width. + * Returns the original blob if cropping would not reduce the width. * @param {Blob} blob - source PNG blob - * @param {number} padding - extra pixels to keep to the right of content + * @param {number} cropWidth - target width in image pixels * @returns {Promise} cropped (or original) PNG blob */ -const cropRightWhitespace = async function (blob, padding = 32) { +const cropToWidth = async function (blob, cropWidth) { const bitmap = await createImageBitmap(blob); const {width, height} = bitmap; + // No crop needed if target width >= image width + if (cropWidth >= width) { + bitmap.close(); + return blob; + } + const canvas = document.createElement('canvas'); - canvas.width = width; + canvas.width = cropWidth; canvas.height = height; const ctx = canvas.getContext('2d'); - ctx.drawImage(bitmap, 0, 0); + ctx.drawImage(bitmap, 0, 0, cropWidth, height, 0, 0, cropWidth, height); bitmap.close(); - // Scan columns from right to find the rightmost column with content - const imageData = ctx.getImageData(0, 0, width, height); - const {data} = imageData; - - let rightmostContentX = 0; - for (let x = width - 1; x >= 0; x--) { - let hasContent = false; - // Sample every 4th row for performance - for (let y = 0; y < height; y += 4) { - const idx = ((y * width) + x) * 4; - if (data[idx] < WHITE_THRESHOLD || - data[idx + 1] < WHITE_THRESHOLD || - data[idx + 2] < WHITE_THRESHOLD) { - hasContent = true; - break; - } - } - if (hasContent) { - rightmostContentX = x; - break; - } - } - - const cropWidth = Math.min(width, rightmostContentX + 1 + padding); - - // If cropping would not save meaningful space, return the original - if (cropWidth >= width - padding) { - return blob; - } - - // Create a cropped canvas and export as PNG blob - const croppedCanvas = document.createElement('canvas'); - croppedCanvas.width = cropWidth; - croppedCanvas.height = height; - const croppedCtx = croppedCanvas.getContext('2d'); - croppedCtx.drawImage(canvas, 0, 0, cropWidth, height, 0, 0, cropWidth, height); - return new Promise(resolve => { - croppedCanvas.toBlob(croppedBlob => { + canvas.toBlob(croppedBlob => { resolve(croppedBlob || blob); }, 'image/png'); }); @@ -85,6 +98,8 @@ const cropRightWhitespace = async function (blob, padding = 32) { * Temporarily expands the editor to its full content height so that all lines * (including those scrolled out of view) are rendered in the DOM before capture. * ViewZones (e.g. furigana annotations) are captured naturally as DOM elements. + * After capture, the image is cropped to the actual text content width to remove + * the large right-side whitespace caused by the editor being wider than the code. * @param {object} editor - Monaco editor instance * @param {string} projectTitle - Project name (used in filename) * @param {string} spriteName - Sprite / stage name (used in filename) @@ -106,16 +121,13 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) { const originalHeight = containerEl.style.height; const originalOverflow = containerEl.style.overflow; + /** Extra pixels (CSS) added to the right of the content boundary. */ + const CROP_PADDING = 16; + const PIXEL_RATIO = 2; + try { - // Disable visual elements that span the full editor width, which - // would otherwise prevent cropRightWhitespace from detecting the - // true content boundary. - editor.updateOptions({ - scrollBeyondLastLine: false, - renderLineHighlight: 'none', - scrollbar: {vertical: 'hidden', horizontal: 'hidden'}, - hideCursorInOverviewRuler: true - }); + // Disable scrollBeyondLastLine to get tight content height + editor.updateOptions({scrollBeyondLastLine: false}); editor.layout(); // Expand editor to full content height so all lines are in the DOM @@ -133,27 +145,33 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) { }); }); + // Measure content width from rendered DOM before capture + const textWidth = measureTextWidth(editorDomNode); + const furiganaWidth = measureFuriganaWidth(editorDomNode); + const contentWidth = Math.max(textWidth, furiganaWidth); + // Capture the editor DOM as a PNG blob. // skipFonts avoids SecurityError when html-to-image tries to read // cssRules from cross-origin stylesheets (Monaco's CDN CSS). const blob = await toBlob(editorDomNode, { backgroundColor: '#ffffff', - pixelRatio: 2, + pixelRatio: PIXEL_RATIO, skipFonts: true }); if (blob) { - const croppedBlob = await cropRightWhitespace(blob); - downloadBlob(buildFilename(projectTitle, spriteName), croppedBlob); + let downloadableBlob = blob; + if (contentWidth > 0) { + const cropWidthPx = Math.ceil( + (contentWidth + CROP_PADDING) * PIXEL_RATIO + ); + downloadableBlob = await cropToWidth(blob, cropWidthPx); + } + downloadBlob(buildFilename(projectTitle, spriteName), downloadableBlob); } } finally { // Restore original state - editor.updateOptions({ - scrollBeyondLastLine: true, - renderLineHighlight: 'line', - scrollbar: {vertical: 'auto', horizontal: 'auto'}, - hideCursorInOverviewRuler: false - }); + editor.updateOptions({scrollBeyondLastLine: true}); containerEl.style.height = originalHeight; containerEl.style.overflow = originalOverflow; editor.layout(); @@ -164,6 +182,8 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) { export { buildFilename, - cropRightWhitespace, + cropToWidth, + measureFuriganaWidth, + measureTextWidth, downloadRubyAsImage }; diff --git a/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js b/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js index a3c543427e..551231de24 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js @@ -1,6 +1,8 @@ import { buildFilename, - cropRightWhitespace, + cropToWidth, + measureTextWidth, + measureFuriganaWidth, downloadRubyAsImage } from '../../../src/lib/ruby-screenshot'; @@ -53,167 +55,122 @@ describe('buildFilename', () => { }); }); -// ---- cropRightWhitespace ---- - -/** - * Helper: build a fake ImageData-like pixel buffer. - * Creates a width x height image where `contentCols` leftmost columns - * contain non-white pixels and the rest are white (#ffffff). - * @param {number} width - image width in pixels - * @param {number} height - image height in pixels - * @param {number} contentCols - number of leftmost columns with content - * @returns {Uint8ClampedArray} RGBA pixel data - */ -const buildPixelData = (width, height, contentCols) => { - const data = new Uint8ClampedArray(width * height * 4); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const idx = (y * width + x) * 4; - if (x < contentCols) { - // Dark pixel (content) - data[idx] = 0; - data[idx + 1] = 0; - data[idx + 2] = 0; - data[idx + 3] = 255; - } else { - // White pixel (background) - data[idx] = 255; - data[idx + 1] = 255; - data[idx + 2] = 255; - data[idx + 3] = 255; - } - } - } - return data; -}; +// ---- measureTextWidth ---- -/** - * Set up Canvas/ImageBitmap mocks for cropRightWhitespace tests. - * @param {number} width - image width - * @param {number} height - image height - * @param {Uint8ClampedArray} pixelData - RGBA pixel buffer - * @returns {object} mock references for assertions - */ -const setupCanvasMocks = (width, height, pixelData) => { - const mockCtx = { - drawImage: jest.fn(), - getImageData: jest.fn(() => ({data: pixelData})) - }; - const croppedCtx = { - drawImage: jest.fn() - }; - let canvasCount = 0; - const canvases = []; - const realCreateElement = document.createElement.bind(document); - jest.spyOn(document, 'createElement').mockImplementation(tag => { - if (tag === 'canvas') { - const c = realCreateElement('canvas'); - canvases.push(c); - canvasCount++; - c.getContext = jest.fn(() => (canvasCount === 1 ? mockCtx : croppedCtx)); - c.toBlob = jest.fn(cb => cb(new Blob(['cropped'], {type: 'image/png'}))); - return c; - } - return realCreateElement(tag); +describe('measureTextWidth', () => { + test('returns 0 when .view-lines is not found', () => { + const div = document.createElement('div'); + expect(measureTextWidth(div)).toBe(0); }); - global.createImageBitmap = jest.fn(() => - Promise.resolve({width, height, close: jest.fn()}) - ); - return {mockCtx, croppedCtx, canvases}; -}; -describe('cropRightWhitespace', () => { - afterEach(() => { - document.createElement.mockRestore?.(); - delete global.createImageBitmap; + test('measures max right edge of text spans', () => { + const editor = document.createElement('div'); + const viewLines = document.createElement('div'); + viewLines.classList.add('view-lines'); + + // Create two lines with spans at different widths + const line1 = document.createElement('div'); + const span1 = document.createElement('span'); + const innerSpan1 = document.createElement('span'); + innerSpan1.textContent = 'short'; + span1.appendChild(innerSpan1); + line1.appendChild(span1); + + const line2 = document.createElement('div'); + const span2 = document.createElement('span'); + const innerSpan2 = document.createElement('span'); + innerSpan2.textContent = 'this is a longer line of code'; + span2.appendChild(innerSpan2); + line2.appendChild(span2); + + viewLines.appendChild(line1); + viewLines.appendChild(line2); + editor.appendChild(viewLines); + document.body.appendChild(editor); + + // jsdom returns 0 for getBoundingClientRect, so result is 0 + // but the function should not throw + const width = measureTextWidth(editor); + expect(typeof width).toBe('number'); + + document.body.removeChild(editor); }); +}); - test('crops right whitespace to content width + padding', async () => { - const width = 200; - const height = 100; - const contentCols = 120; - const padding = 32; - const pixelData = buildPixelData(width, height, contentCols); - const {croppedCtx, canvases} = setupCanvasMocks(width, height, pixelData); +// ---- measureFuriganaWidth ---- - const inputBlob = new Blob(['original'], {type: 'image/png'}); - const result = await cropRightWhitespace(inputBlob, padding); +describe('measureFuriganaWidth', () => { + test('returns 0 when .view-zones is not found', () => { + const div = document.createElement('div'); + expect(measureFuriganaWidth(div)).toBe(0); + }); - // Should return a new (cropped) blob, not the original - expect(result).not.toBe(inputBlob); + test('returns 0 when view-zones has no spans', () => { + const editor = document.createElement('div'); + const viewZones = document.createElement('div'); + viewZones.classList.add('view-zones'); + editor.appendChild(viewZones); + expect(measureFuriganaWidth(editor)).toBe(0); + }); +}); - // Cropped canvas width = contentCols + padding - const croppedCanvas = canvases[1]; - expect(croppedCanvas.width).toBe(contentCols + padding); - expect(croppedCanvas.height).toBe(height); +// ---- cropToWidth ---- - // drawImage should copy the cropped region - expect(croppedCtx.drawImage).toHaveBeenCalledTimes(1); +describe('cropToWidth', () => { + afterEach(() => { + document.createElement.mockRestore?.(); + delete global.createImageBitmap; }); - test('returns original blob when image has little whitespace', async () => { - const width = 200; - const height = 100; - const contentCols = 190; // almost full width - const padding = 32; - const pixelData = buildPixelData(width, height, contentCols); - setupCanvasMocks(width, height, pixelData); - - const inputBlob = new Blob(['original'], {type: 'image/png'}); - const result = await cropRightWhitespace(inputBlob, padding); + test('crops blob to specified width', async () => { + const imgWidth = 1000; + const imgHeight = 500; + global.createImageBitmap = jest.fn(() => + Promise.resolve({width: imgWidth, height: imgHeight, close: jest.fn()}) + ); - // Content + padding >= width, so no cropping needed - expect(result).toBe(inputBlob); - }); + const canvases = []; + const mockCtx = {drawImage: jest.fn()}; + const realCreateElement = document.createElement.bind(document); + jest.spyOn(document, 'createElement').mockImplementation(tag => { + const el = realCreateElement(tag); + if (tag === 'canvas') { + canvases.push(el); + el.getContext = jest.fn(() => mockCtx); + el.toBlob = jest.fn(cb => cb(new Blob(['cropped'], {type: 'image/png'}))); + } + return el; + }); - test('handles anti-aliased pixels (near-white but not pure white)', async () => { - const width = 100; - const height = 10; - const padding = 16; - // Build all-white image, then add a near-white pixel at column 60 - const data = buildPixelData(width, height, 50); - // Add anti-aliased pixel at (60, 4): RGB = (240, 245, 248) - // Values must be below WHITE_THRESHOLD (250) to be detected as content. - // Row must be a multiple of 4 to be hit by the sampling stride. - const aaIdx = (4 * width + 60) * 4; - data[aaIdx] = 240; - data[aaIdx + 1] = 245; - data[aaIdx + 2] = 248; - data[aaIdx + 3] = 255; - - const {canvases} = setupCanvasMocks(width, height, data); - - const inputBlob = new Blob(['img'], {type: 'image/png'}); - const result = await cropRightWhitespace(inputBlob, padding); + const inputBlob = new Blob(['original'], {type: 'image/png'}); + const result = await cropToWidth(inputBlob, 400); expect(result).not.toBe(inputBlob); - // rightmost content is at column 60, so crop width = 61 + padding - expect(canvases[1].width).toBe(61 + padding); + expect(canvases[0].width).toBe(400); + expect(canvases[0].height).toBe(imgHeight); + expect(mockCtx.drawImage).toHaveBeenCalledTimes(1); }); - test('returns original blob when entire image is content', async () => { - const width = 100; - const height = 50; - const pixelData = buildPixelData(width, height, width); // all content - setupCanvasMocks(width, height, pixelData); + test('returns original blob when cropWidth >= image width', async () => { + global.createImageBitmap = jest.fn(() => + Promise.resolve({width: 200, height: 100, close: jest.fn()}) + ); - const inputBlob = new Blob(['img'], {type: 'image/png'}); - const result = await cropRightWhitespace(inputBlob, 16); + const inputBlob = new Blob(['original'], {type: 'image/png'}); + const result = await cropToWidth(inputBlob, 300); expect(result).toBe(inputBlob); }); - test('uses default padding of 32 when not specified', async () => { - const width = 200; - const height = 50; - const contentCols = 100; - const pixelData = buildPixelData(width, height, contentCols); - const {canvases} = setupCanvasMocks(width, height, pixelData); + test('returns original blob when cropWidth equals image width', async () => { + global.createImageBitmap = jest.fn(() => + Promise.resolve({width: 200, height: 100, close: jest.fn()}) + ); - const inputBlob = new Blob(['img'], {type: 'image/png'}); - await cropRightWhitespace(inputBlob); + const inputBlob = new Blob(['original'], {type: 'image/png'}); + const result = await cropToWidth(inputBlob, 200); - expect(canvases[1].width).toBe(contentCols + 32); + expect(result).toBe(inputBlob); }); }); @@ -222,25 +179,18 @@ describe('cropRightWhitespace', () => { describe('downloadRubyAsImage', () => { beforeEach(() => { jest.clearAllMocks(); - // Mock createImageBitmap for cropRightWhitespace called inside downloadRubyAsImage. - // Return a tiny all-white image so cropping is skipped (returns original blob). + // Mock createImageBitmap for cropToWidth called inside downloadRubyAsImage. + // Return a small image so cropping can work when contentWidth > 0. global.createImageBitmap = jest.fn(() => - Promise.resolve({width: 10, height: 10, close: jest.fn()}) + Promise.resolve({width: 2000, height: 400, close: jest.fn()}) ); - const mockCtx = { - drawImage: jest.fn(), - getImageData: jest.fn(() => { - // All-white pixels → no crop - const data = new Uint8ClampedArray(10 * 10 * 4); - data.fill(255); - return {data}; - }) - }; + const mockCtx = {drawImage: jest.fn()}; const realCreateElement = document.createElement.bind(document); jest.spyOn(document, 'createElement').mockImplementation(tag => { const el = realCreateElement(tag); if (tag === 'canvas') { el.getContext = jest.fn(() => mockCtx); + el.toBlob = jest.fn(cb => cb(new Blob(['cropped'], {type: 'image/png'}))); } return el; }); @@ -300,6 +250,7 @@ describe('downloadRubyAsImage', () => { await downloadRubyAsImage(editor, 'MyProject', 'Cat'); + // In jsdom, measureTextWidth returns 0, so no crop → original blob expect(downloadBlob).toHaveBeenCalledWith('MyProject_Cat_ruby.png', mockBlob); }); @@ -360,7 +311,7 @@ describe('downloadRubyAsImage', () => { expect(editor.layout).toHaveBeenCalled(); }); - test('disables full-width visual elements before capture and restores them after', async () => { + test('disables scrollBeyondLastLine before capture and restores it after', async () => { const mockBlob = new Blob(['test'], {type: 'image/png'}); toBlob.mockResolvedValue(mockBlob); @@ -368,20 +319,9 @@ describe('downloadRubyAsImage', () => { await downloadRubyAsImage(editor, 'project', 'sprite'); - // First call disables visual elements for clean capture - expect(editor.updateOptions).toHaveBeenCalledWith({ - scrollBeyondLastLine: false, - renderLineHighlight: 'none', - scrollbar: {vertical: 'hidden', horizontal: 'hidden'}, - hideCursorInOverviewRuler: true - }); - // Second call (in finally) re-enables them - expect(editor.updateOptions).toHaveBeenCalledWith({ - scrollBeyondLastLine: true, - renderLineHighlight: 'line', - scrollbar: {vertical: 'auto', horizontal: 'auto'}, - hideCursorInOverviewRuler: false - }); + // First call disables, second call (in finally) re-enables + expect(editor.updateOptions).toHaveBeenCalledWith({scrollBeyondLastLine: false}); + expect(editor.updateOptions).toHaveBeenCalledWith({scrollBeyondLastLine: true}); }); test('calls editor.layout() to trigger re-render', async () => {