diff --git a/docs/screenshot/README.md b/docs/screenshot/README.md index 63dd9b184f6..a6f97ee03d3 100644 --- a/docs/screenshot/README.md +++ b/docs/screenshot/README.md @@ -57,7 +57,29 @@ ## 出力形式 - 形式: PNG -- 解像度: 元の表示サイズに準拠 +- 解像度: **書き出し倍率で高解像度化**(ブロックは SVG なので、倍率を上げるとベクタとして + 再描画されぼやけない) + +### ブロックスクリーンショットの書き出し倍率 + +レイアウトはブロックの自然サイズ(`LOGICAL_SCALE = 1`)で固定し、ピクセル密度だけを +倍率で上げる。**`workspace.scale`(ユーザーの現在ズーム。既定 0.675)には依存しない**ので、 +同じプログラムなら誰が撮っても同じサイズの画像になる。 + +| 経路 | 倍率 | 理由 | +|---|---|---| +| コードタブのカメラボタン(`downloadBlocksAsImage`) | `DOWNLOAD_EXPORT_SCALE = 4` | 教材・ワークシート・印刷での判読性 | +| クラス機能の提出 / 不具合報告(`renderBlocksToCanvas` 既定) | `DEFAULT_EXPORT_SCALE = 2` | アップロード / S3 保存サイズを抑える。Ruby タブの `PIXEL_RATIO = 2` と同等 | + +倍率は呼び出し側が `{exportScale}` で上書きできる。巨大なプログラムでは +`clampExportScale()` がブラウザのキャンバス上限(`MAX_EXPORT_DIMENSION` / +`MAX_EXPORT_PIXELS`)に収まるよう倍率を自動的に下げる(下限は等倍)。 + +上限はもっとも厳しい **iOS / iPadOS Safari のキャンバス面積上限 16,777,216 px** に +合わせてある(授業では iPad を使うため、デスクトップ基準で緩めるとその環境だけ +書き出しが失敗する)。クランプはスプライトヘッダーを含めた合成後のサイズに対して +行う。それでもエンコードに失敗して `toBlob()` が `null` を返した場合は、 +ダウンロードを行わず警告を出すだけにしてエディタを壊さない。 ## テスト diff --git a/packages/scratch-gui/src/lib/blocks-screenshot.js b/packages/scratch-gui/src/lib/blocks-screenshot.js index 6024def5e87..bafd67e9fdb 100644 --- a/packages/scratch-gui/src/lib/blocks-screenshot.js +++ b/packages/scratch-gui/src/lib/blocks-screenshot.js @@ -1,10 +1,68 @@ import downloadBlob from './download-blob'; /** - * Padding around blocks in the exported image (in pixels, ~1rem). + * Padding around blocks in the exported image (in logical pixels, ~1rem). + * The padding is multiplied by the export scale together with the blocks. */ const EXPORT_PADDING = 16; +/** + * Scale used to lay out the blocks in the export. Deliberately fixed at 1 + * (natural block size) instead of `workspace.scale` so the exported image is + * reproducible: the same program produces the same image no matter how far + * the user happened to be zoomed in or out (the workspace default is 0.675). + */ +const LOGICAL_SCALE = 1; + +/** + * Export scale used when a caller does not ask for one. Blocks are SVG, so + * raising the scale re-rasterises them as vectors — the result is sharper, + * not blurrier. 2 matches `ruby-screenshot.js` (`PIXEL_RATIO = 2`) and keeps + * the upload size of the classroom submit / bug report paths moderate. + */ +const DEFAULT_EXPORT_SCALE = 2; + +/** + * Export scale for the "download blocks as image" button. Higher than the + * default because these images end up in worksheets and printed handouts, + * where 2x is still too coarse to read. + */ +const DOWNLOAD_EXPORT_SCALE = 4; + +/** + * Safety limits for the exported canvas. Browsers reject canvases past a + * per-side limit (and past a total area limit), and `toBlob()` then returns + * null, so a very large program must fall back to a smaller export scale + * rather than produce nothing at all. + * + * The area limit is set by iOS / iPadOS Safari, which caps a canvas at + * 16,777,216 px (far below desktop Chrome / Firefox). iPad is a supported + * platform for classroom use, so the limit has to hold there too — a limit + * tuned for desktop would let the iPad export fail silently. + */ +const MAX_EXPORT_DIMENSION = 8192; +const MAX_EXPORT_PIXELS = 16 * 1024 * 1024; + +/** + * Reduces the requested export scale so the resulting canvas stays within the + * browser's canvas limits. Never returns less than 1 (natural block size): + * below that the export is unreadable, and such huge programs would fail to + * rasterise at any scale anyway. + * + * `dims` must describe the *whole* composed image at export scale 1 (blocks + * plus the sprite header), not just the blocks: clamping against the blocks + * alone lets the final canvas exceed the limit by the header's height. + * @param {{width: number, height: number}} dims - Logical canvas dimensions (export scale 1) + * @param {number} desiredScale - Requested export scale + * @returns {number} Scale to actually use + */ +const clampExportScale = function (dims, desiredScale) { + const byWidth = MAX_EXPORT_DIMENSION / dims.width; + const byHeight = MAX_EXPORT_DIMENSION / dims.height; + const byArea = Math.sqrt(MAX_EXPORT_PIXELS / (dims.width * dims.height)); + return Math.max(1, Math.min(desiredScale, byWidth, byHeight, byArea)); +}; + /** * Returns the blocks bounding box for the given workspace, or null if the * workspace is empty (no blocks placed). @@ -316,28 +374,45 @@ const loadImage = function (dataUri) { * image drawn above the blocks at the top-left. * @param {object} workspace - Scratch Blocks workspace * @param {string} [costumeDataUri] - Sprite costume data URI (omit to skip) + * @param {object} [options] - Export options + * @param {number} [options.exportScale] - Pixel scale of the export (default: DEFAULT_EXPORT_SCALE) * @returns {Promise} Canvas or null if workspace is empty */ -const renderBlocksToCanvas = async function (workspace, costumeDataUri) { +const renderBlocksToCanvas = async function (workspace, costumeDataUri, options = {}) { const blockBbox = getBlocksBoundingBox(workspace); if (!blockBbox) return null; const bbox = mergeWithBubbleBBox(workspace, blockBbox); - const scale = workspace.scale; - const { width: blocksWidth, height: blocksHeight } = calculateCanvasDimensions(bbox, scale); + // The export layout is intentionally independent of `workspace.scale` + // (the user's current zoom); only the pixel density changes with scale. + const logicalDims = calculateCanvasDimensions(bbox, LOGICAL_SCALE); + // Clamp against the composed image, i.e. including the sprite header that + // is drawn above the blocks — otherwise the final canvas overshoots the + // limit by the header height. + // The +1 on each axis absorbs the sub-pixel `Math.ceil()` rounding that + // the real canvas dimensions get, so the clamp result really does fit. + const logicalHeaderHeight = costumeDataUri ? SPRITE_IMAGE_SIZE + SPRITE_IMAGE_GAP : 0; + const exportScale = clampExportScale( + { width: logicalDims.width + 1, height: logicalDims.height + logicalHeaderHeight + 1 }, + options.exportScale ?? DEFAULT_EXPORT_SCALE, + ); + const scale = LOGICAL_SCALE * exportScale; + const padding = EXPORT_PADDING * exportScale; + const { width: blocksWidth, height: blocksHeight } = calculateCanvasDimensions(bbox, scale, padding); // Calculate sprite header height let spriteHeaderHeight = 0; let spriteImg = null; + const spriteImageSize = SPRITE_IMAGE_SIZE * exportScale; if (costumeDataUri) { spriteImg = await loadImage(costumeDataUri); - spriteHeaderHeight = SPRITE_IMAGE_SIZE + SPRITE_IMAGE_GAP; + spriteHeaderHeight = spriteImageSize + SPRITE_IMAGE_GAP * exportScale; } const totalWidth = blocksWidth; const totalHeight = blocksHeight + spriteHeaderHeight; - const svgStr = await buildExportSVG(workspace, bbox, scale, blocksWidth, blocksHeight); + const svgStr = await buildExportSVG(workspace, bbox, scale, blocksWidth, blocksHeight, padding); // Render blocks SVG to a temporary canvas const blocksCanvas = await renderSVGToCanvas(svgStr, blocksWidth, blocksHeight); @@ -353,15 +428,15 @@ const renderBlocksToCanvas = async function (workspace, costumeDataUri) { if (spriteImg) { // Draw sprite at top-left, preserving aspect ratio within SPRITE_IMAGE_SIZE box const aspectRatio = spriteImg.width / spriteImg.height; - let drawW = SPRITE_IMAGE_SIZE; - let drawH = SPRITE_IMAGE_SIZE; + let drawW = spriteImageSize; + let drawH = spriteImageSize; if (aspectRatio > 1) { - drawH = SPRITE_IMAGE_SIZE / aspectRatio; + drawH = spriteImageSize / aspectRatio; } else { - drawW = SPRITE_IMAGE_SIZE * aspectRatio; + drawW = spriteImageSize * aspectRatio; } - const drawX = EXPORT_PADDING; - const drawY = (SPRITE_IMAGE_SIZE - drawH) / 2; + const drawX = padding; + const drawY = (spriteImageSize - drawH) / 2; ctx.drawImage(spriteImg, drawX, drawY, drawW, drawH); } @@ -379,14 +454,29 @@ const renderBlocksToCanvas = async function (workspace, costumeDataUri) { * @param {string} projectTitle - Project name (used in filename) * @param {string} spriteName - Sprite / stage name (used in filename) * @param {string} [costumeDataUri] - Sprite costume data URI + * @param {object} [options] - Export options + * @param {number} [options.exportScale] - Pixel scale of the export (default: DOWNLOAD_EXPORT_SCALE) * @returns {Promise} */ -const downloadBlocksAsImage = async function (workspace, projectTitle, spriteName, costumeDataUri) { - const canvas = await renderBlocksToCanvas(workspace, costumeDataUri); +const downloadBlocksAsImage = async function (workspace, projectTitle, spriteName, costumeDataUri, options = {}) { + const canvas = await renderBlocksToCanvas(workspace, costumeDataUri, { + exportScale: options.exportScale ?? DOWNLOAD_EXPORT_SCALE, + }); if (!canvas) return; return new Promise((resolve) => { canvas.toBlob((blob) => { + // `toBlob()` hands back null when the browser could not rasterise + // the canvas (too large / out of memory). `downloadBlob()` would + // then throw while reading `blob.type`, so bail out with a warning + // instead of breaking the editor. The submit / bug-report paths + // already guard the same way. + if (!blob) { + // eslint-disable-next-line no-console + console.warn('Blocks screenshot could not be encoded (canvas too large).'); + resolve(); + return; + } downloadBlob(buildFilename(projectTitle, spriteName), blob); resolve(); }, 'image/png'); @@ -397,6 +487,7 @@ export { getBlocksBoundingBox, mergeWithBubbleBBox, calculateCanvasDimensions, + clampExportScale, buildFilename, buildExportSVG, renderSVGToCanvas, @@ -405,4 +496,9 @@ export { stripExternalCssUrls, downloadBlocksAsImage, EXPORT_PADDING, + LOGICAL_SCALE, + DEFAULT_EXPORT_SCALE, + DOWNLOAD_EXPORT_SCALE, + MAX_EXPORT_DIMENSION, + MAX_EXPORT_PIXELS, }; diff --git a/packages/scratch-gui/test/unit/lib/blocks-screenshot.test.js b/packages/scratch-gui/test/unit/lib/blocks-screenshot.test.js index ded33c4cac6..d4df9603fff 100644 --- a/packages/scratch-gui/test/unit/lib/blocks-screenshot.test.js +++ b/packages/scratch-gui/test/unit/lib/blocks-screenshot.test.js @@ -2,11 +2,17 @@ import { getBlocksBoundingBox, mergeWithBubbleBBox, calculateCanvasDimensions, + clampExportScale, buildFilename, buildExportSVG, + renderBlocksToCanvas, downloadBlocksAsImage, stripExternalCssUrls, EXPORT_PADDING, + DEFAULT_EXPORT_SCALE, + DOWNLOAD_EXPORT_SCALE, + MAX_EXPORT_DIMENSION, + MAX_EXPORT_PIXELS, } from '../../../src/lib/blocks-screenshot'; import downloadBlob from '../../../src/lib/download-blob'; @@ -59,6 +65,28 @@ const makeMockWorkspace = ({ }; }; +// Helper: capture every created while the export runs. The final +// composed canvas is the last one created (the blocks canvas comes first). +const nativeCreateElement = document.createElement.bind(document); +const captureCanvases = () => { + const canvases = []; + jest.spyOn(document, 'createElement').mockImplementation((tag) => { + const el = nativeCreateElement(tag); + if (tag === 'canvas') canvases.push(el); + return el; + }); + return { + last: () => canvases[canvases.length - 1], + restore: () => { + if (document.createElement.mockRestore) document.createElement.mockRestore(); + }, + }; +}; + +afterEach(() => { + if (document.createElement.mockRestore) document.createElement.mockRestore(); +}); + // ---- stripExternalCssUrls ---- describe('stripExternalCssUrls', () => { @@ -184,6 +212,14 @@ describe('calculateCanvasDimensions', () => { expect(height).toBe(100 + EXPORT_PADDING * 2); }); + test('scales the padding too when an explicit padding is given', () => { + const bbox = { x: 0, y: 0, width: 200, height: 100 }; + const exportScale = 4; + const { width, height } = calculateCanvasDimensions(bbox, exportScale, EXPORT_PADDING * exportScale); + expect(width).toBe((200 + EXPORT_PADDING * 2) * exportScale); + expect(height).toBe((100 + EXPORT_PADDING * 2) * exportScale); + }); + test('single block is small with minimal extra padding', () => { // A single block might be ~150x50 workspace units const bbox = { x: 100, y: 100, width: 150, height: 50 }; @@ -195,6 +231,37 @@ describe('calculateCanvasDimensions', () => { }); }); +// ---- clampExportScale ---- + +describe('clampExportScale', () => { + test('keeps the requested scale for a normal sized program', () => { + expect(clampExportScale({ width: 400, height: 300 }, DOWNLOAD_EXPORT_SCALE)).toBe(DOWNLOAD_EXPORT_SCALE); + }); + + test('clamps so the longest side stays within the canvas dimension limit', () => { + const dims = { width: MAX_EXPORT_DIMENSION / 2, height: 100 }; + expect(clampExportScale(dims, 8)).toBe(2); + }); + + test('never clamps below 1 (natural size) even for a huge program', () => { + const dims = { width: MAX_EXPORT_DIMENSION * 4, height: MAX_EXPORT_DIMENSION * 4 }; + expect(clampExportScale(dims, DOWNLOAD_EXPORT_SCALE)).toBe(1); + }); + + test('clamps by total pixel count for very wide programs', () => { + const dims = { width: 3000, height: 2000 }; + const clamped = clampExportScale(dims, DOWNLOAD_EXPORT_SCALE); + expect(clamped).toBeLessThan(DOWNLOAD_EXPORT_SCALE); + expect(clamped).toBeGreaterThan(1); + expect(dims.width * clamped * (dims.height * clamped)).toBeLessThanOrEqual(MAX_EXPORT_PIXELS); + }); + + test('keeps the clamped canvas within the iOS Safari canvas area limit', () => { + // iOS / iPadOS Safari rejects canvases larger than 16,777,216 px. + expect(MAX_EXPORT_PIXELS).toBeLessThanOrEqual(16 * 1024 * 1024); + }); +}); + // ---- buildFilename ---- describe('buildFilename', () => { @@ -310,44 +377,100 @@ describe('downloadBlocksAsImage', () => { expect(downloadBlob).toHaveBeenCalledWith('myProject_Sprite1.png', expect.any(Blob)); }); - test('canvas dimensions include padding', async () => { + test('canvas dimensions include padding and the download export scale', async () => { const bbox = { x: 0, y: 0, width: 200, height: 100 }; const workspace = makeMockWorkspace({ boundingBox: bbox, scale: 1 }); - let capturedCanvas; - const realCreateElement = document.createElement.bind(document); - jest.spyOn(document, 'createElement').mockImplementation((tag) => { - const el = realCreateElement(tag); - if (tag === 'canvas') capturedCanvas = el; - return el; - }); - + const capture = captureCanvases(); await downloadBlocksAsImage(workspace, 'p', 's'); - expect(capturedCanvas.width).toBe(200 + EXPORT_PADDING * 2); - expect(capturedCanvas.height).toBe(100 + EXPORT_PADDING * 2); + const canvas = capture.last(); + expect(canvas.width).toBe((200 + EXPORT_PADDING * 2) * DOWNLOAD_EXPORT_SCALE); + expect(canvas.height).toBe((100 + EXPORT_PADDING * 2) * DOWNLOAD_EXPORT_SCALE); - document.createElement.mockRestore(); + capture.restore(); }); - test('canvas dimensions respect scale', async () => { + test('canvas dimensions do not depend on the workspace zoom scale', async () => { const bbox = { x: 0, y: 0, width: 200, height: 100 }; - const scale = 2; - const workspace = makeMockWorkspace({ boundingBox: bbox, scale }); - - let capturedCanvas; - const realCreateElement = document.createElement.bind(document); - jest.spyOn(document, 'createElement').mockImplementation((tag) => { - const el = realCreateElement(tag); - if (tag === 'canvas') capturedCanvas = el; - return el; + const sizes = []; + for (const scale of [0.675, 1, 2]) { + const workspace = makeMockWorkspace({ boundingBox: bbox, scale }); + const capture = captureCanvases(); + await downloadBlocksAsImage(workspace, 'p', 's'); + const canvas = capture.last(); + sizes.push(`${canvas.width}x${canvas.height}`); + capture.restore(); + } + expect(sizes[0]).toBe(sizes[1]); + expect(sizes[1]).toBe(sizes[2]); + }); + + test('does not download when the canvas is too large to encode (toBlob returns null)', async () => { + // Browsers hand back null instead of a Blob when the canvas exceeds + // their limits; passing that on would throw inside downloadBlob(). + HTMLCanvasElement.prototype.toBlob = jest.fn(function (callback) { + callback(null); + }); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const workspace = makeMockWorkspace({ + boundingBox: { x: 0, y: 0, width: 200, height: 100 }, + scale: 1, }); - await downloadBlocksAsImage(workspace, 'p', 's'); + await expect(downloadBlocksAsImage(workspace, 'p', 's')).resolves.toBeUndefined(); + + expect(downloadBlob).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +// ---- renderBlocksToCanvas ---- + +describe('renderBlocksToCanvas', () => { + beforeEach(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); + global.URL.revokeObjectURL = jest.fn(); + HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ + fillStyle: '', + fillRect: jest.fn(), + drawImage: jest.fn(), + })); + global.Image = class { + set src(_url) { + // eslint-disable-line accessor-pairs + setTimeout(() => this.onload && this.onload(), 0); + } + }; + }); - expect(capturedCanvas.width).toBe(200 * scale + EXPORT_PADDING * 2); - expect(capturedCanvas.height).toBe(100 * scale + EXPORT_PADDING * 2); + test('returns null for an empty workspace', async () => { + const workspace = makeMockWorkspace({ boundingBox: { x: 0, y: 0, width: 0, height: 0 } }); + expect(await renderBlocksToCanvas(workspace)).toBeNull(); + }); + + test('uses the default export scale when none is given (submit / bug report path)', async () => { + const workspace = makeMockWorkspace({ boundingBox: { x: 0, y: 0, width: 200, height: 100 }, scale: 0.675 }); + const canvas = await renderBlocksToCanvas(workspace); + expect(canvas.width).toBe((200 + EXPORT_PADDING * 2) * DEFAULT_EXPORT_SCALE); + expect(canvas.height).toBe((100 + EXPORT_PADDING * 2) * DEFAULT_EXPORT_SCALE); + }); - document.createElement.mockRestore(); + test('honours an explicit export scale', async () => { + const workspace = makeMockWorkspace({ boundingBox: { x: 0, y: 0, width: 200, height: 100 }, scale: 0.675 }); + const canvas = await renderBlocksToCanvas(workspace, undefined, { exportScale: 3 }); + expect(canvas.width).toBe((200 + EXPORT_PADDING * 2) * 3); + expect(canvas.height).toBe((100 + EXPORT_PADDING * 2) * 3); + }); + + test('composed canvas stays within the pixel limit including the sprite header', async () => { + // The sprite header is drawn above the blocks, so it has to be part of + // what the export scale is clamped against. + const workspace = makeMockWorkspace({ boundingBox: { x: 0, y: 0, width: 3000, height: 2000 } }); + const canvas = await renderBlocksToCanvas(workspace, 'data:image/png;base64,AAAA', { + exportScale: DOWNLOAD_EXPORT_SCALE, + }); + expect(canvas.width * canvas.height).toBeLessThanOrEqual(MAX_EXPORT_PIXELS); }); });