import json import subprocess import textwrap from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MODULE = (ROOT / "static/js/editor/document-geometry.js").as_uri() def run_node(body: str): source = textwrap.dedent( f""" import {{ cropDocument, flipDocument, resizeCanvasDocument, resizeImageDocument, rotateDocument, }} from {json.dumps(MODULE)}; let tempId = 0; function canvas(tag, width, height) {{ const value = {{ tag, width, height, draws:[], clears:[] }}; const ctx = {{ imageSmoothingEnabled:false, imageSmoothingQuality:'low', font:'', textBaseline:'', textAlign:'left', fillStyle:'', strokeStyle:'', lineWidth:0, measureText(text) {{ return {{width:String(text).length * 8}}; }}, fillText() {{}}, strokeText() {{}}, drawImage(...args) {{ value.draws.push(args); }}, clearRect(...args) {{ value.clears.push(args); }}, save() {{}}, restore() {{}}, translate() {{}}, rotate() {{}}, scale() {{}}, setTransform() {{}}, }}; value.getContext = () => ctx; return value; }} globalThis.document = {{ createElement: () => canvas(`temp-${{++tempId}}`, 0, 0) }}; function holder(id, tag, width, height, masks=[]) {{ const c = canvas(tag, width, height); return {{ id, name:id, canvas:c, ctx:c.getContext('2d'), masks }}; }} function mask(id, tag, width, height, mode, space) {{ const c = canvas(tag, width, height); return {{ id, name:id, canvas:c, ctx:c.getContext('2d'), mode, space, visible:true }}; }} function sourceDraw(holderValue) {{ const sourceCanvas = holderValue?.canvas || holderValue; const rendered = sourceCanvas.draws.at(-1)?.[0]; const args = rendered?.draws?.[0] || []; return {{ source:args[0]?.tag, args:args.slice(1) }}; }} {body} """ ) result = subprocess.run( ["node", "--input-type=module", "-e", source], cwd=ROOT, text=True, capture_output=True, check=True, ) return json.loads(result.stdout) def test_crop_updates_layer_and_document_mask_coordinate_spaces(): result = run_node( """ const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer'); const selectionMask = mask('mask-selection', 'selection-mask', 100, 80, 'selection', 'document'); const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, selectionMask]); layer.activeMaskId = layerMask.id; const main = canvas('main', 100, 80); const state = { imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'), layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]), lassoPoints:[{x:25,y:15},{x:45,y:15},{x:45,y:30},{x:25,y:30}], guides:{vertical:[10,25,75],horizontal:[5,20,60]}, wandMask:null, wandLastSeed:null, }; const crop = cropDocument(state, {x:20,y:10,w:50,h:40}); console.log(JSON.stringify({ crop, doc:[state.imgWidth,state.imgHeight], main:[main.width,main.height], layer:[layer.canvas.width,layer.canvas.height], offset:state.layerOffsets.get(layer.id), layerMask:[layerMask.canvas.width,layerMask.canvas.height], selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height], layerDraw:sourceDraw(layer), layerMaskDraw:sourceDraw(layerMask), selectionMaskDraw:sourceDraw(selectionMask), lasso:state.lassoPoints, guides:state.guides, activeMask:state.maskCanvas.tag, })); """ ) assert result["crop"] == {"x": 20, "y": 10, "width": 50, "height": 40} assert result["doc"] == [50, 40] assert result["main"] == [50, 40] assert result["layer"] == [50, 40] assert result["offset"] == {"x": 0, "y": 0} assert result["layerMask"] == [50, 40] assert result["selectionMask"] == [50, 40] assert result["layerDraw"] == {"source": "pixels", "args": [-10, -5]} assert result["layerMaskDraw"] == {"source": "layer-mask", "args": [-10, -5]} assert result["selectionMaskDraw"] == {"source": "selection-mask", "args": [-20, -10]} assert result["lasso"] == [ {"x": 5, "y": 5}, {"x": 25, "y": 5}, {"x": 25, "y": 20}, {"x": 5, "y": 20}, ] assert result["guides"] == {"vertical": [5], "horizontal": [10]} assert result["activeMask"] == "layer-mask" def test_image_resize_resamples_layers_masks_offsets_and_selection(): result = run_node( """ const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer'); const selectionMask = mask('mask-selection', 'selection-mask', 100, 80, 'selection', 'document'); const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, selectionMask]); layer.activeMaskId = selectionMask.id; const main = canvas('main', 100, 80); const wand = canvas('wand', 40, 30); const state = { imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'), layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]), lassoPoints:[{x:10,y:20},{x:30,y:20},{x:30,y:40}], guides:{vertical:[10,50],horizontal:[20,70]}, wandMask:wand, wandLayerId:layer.id, wandLastSeed:{x:15,y:20,mode:'replace'}, }; const resized = resizeImageDocument(state, 200, 120); console.log(JSON.stringify({ resized, doc:[state.imgWidth,state.imgHeight], layer:[layer.canvas.width,layer.canvas.height], layerMask:[layerMask.canvas.width,layerMask.canvas.height], selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height], wand:[wand.width,wand.height], offset:state.layerOffsets.get(layer.id), lasso:state.lassoPoints, guides:state.guides, seed:state.wandLastSeed, activeMask:state.maskCanvas.tag, })); """ ) assert result["resized"] == { "width": 200, "height": 120, "scaleX": 2, "scaleY": 1.5 } assert result["doc"] == [200, 120] assert result["layer"] == [80, 45] assert result["layerMask"] == [80, 45] assert result["selectionMask"] == [200, 120] assert result["wand"] == [80, 45] assert result["offset"] == {"x": 20, "y": 8} assert result["lasso"] == [ {"x": 20, "y": 30}, {"x": 60, "y": 30}, {"x": 60, "y": 60} ] assert result["guides"] == {"vertical": [20, 100], "horizontal": [30, 105]} assert result["seed"] == {"x": 30, "y": 30, "mode": "replace"} assert result["activeMask"] == "selection-mask" def test_canvas_resize_only_changes_document_space_masks(): result = run_node( """ const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer'); const selectionMask = mask('mask-selection', 'selection-mask', 100, 80, 'selection', 'document'); const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, selectionMask]); layer.activeMaskId = layerMask.id; const main = canvas('main', 100, 80); const state = { imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'), layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]), lassoPoints:[{x:10,y:10},{x:90,y:10},{x:90,y:70},{x:10,y:70}], guides:{vertical:[10,90],horizontal:[20,70]}, wandMask:null, wandLastSeed:null, }; resizeCanvasDocument(state, 60, 50); console.log(JSON.stringify({ doc:[state.imgWidth,state.imgHeight], layer:[layer.canvas.width,layer.canvas.height], layerMask:[layerMask.canvas.width,layerMask.canvas.height], selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height], offset:state.layerOffsets.get(layer.id), lasso:state.lassoPoints, guides:state.guides, })); """ ) assert result["doc"] == [60, 50] assert result["layer"] == [40, 30] assert result["layerMask"] == [40, 30] assert result["selectionMask"] == [60, 50] assert result["offset"] == {"x": 10, "y": 5} assert result["guides"] == {"vertical": [10], "horizontal": [20]} assert all(0 <= point["x"] <= 60 and 0 <= point["y"] <= 50 for point in result["lasso"]) def test_canvas_resize_anchor_shifts_layers_and_document_space_state(): result = run_node( """ const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer'); const documentMask = mask('mask-document', 'document-mask', 100, 80, 'selection', 'document'); const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, documentMask]); const main = canvas('main', 100, 80); const selection = canvas('selection', 100, 80); const state = { imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'), layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]), lassoPoints:[{x:20,y:10},{x:40,y:10},{x:40,y:30}], guides:{vertical:[10,90],horizontal:[20,70]}, savedSelections:[{canvas:selection}], wandMask:null, wandLastSeed:{x:30,y:25,mode:'replace'}, }; const resized = resizeCanvasDocument(state, 140, 120, {anchorX:0.5, anchorY:0.5}); console.log(JSON.stringify({ resized, offset:state.layerOffsets.get(layer.id), layerMask:[layerMask.canvas.width,layerMask.canvas.height], documentMask:[documentMask.canvas.width,documentMask.canvas.height], documentMaskDraw:sourceDraw(documentMask.canvas), selectionDraw:sourceDraw(selection), lasso:state.lassoPoints, guides:state.guides, seed:state.wandLastSeed, })); """ ) assert result["resized"] == {"width": 140, "height": 120} assert result["offset"] == {"x": 30, "y": 25} assert result["layerMask"] == [40, 30] assert result["documentMask"] == [140, 120] assert result["documentMaskDraw"]["args"] == [20, 20] assert result["selectionDraw"]["args"] == [20, 20] assert result["lasso"] == [{"x": 40, "y": 30}, {"x": 60, "y": 30}, {"x": 60, "y": 50}] assert result["guides"] == {"vertical": [30, 110], "horizontal": [40, 90]} assert result["seed"] == {"x": 50, "y": 45, "mode": "replace"} def test_rotate_and_flip_keep_masks_offsets_and_selections_aligned(): result = run_node( """ const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer'); const selectionMask = mask('mask-selection', 'selection-mask', 100, 80, 'selection', 'document'); const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, selectionMask]); layer.activeMaskId = layerMask.id; const main = canvas('main', 100, 80); const state = { imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'), layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]), lassoPoints:[{x:20,y:10},{x:30,y:10},{x:30,y:20}], guides:{vertical:[20,70],horizontal:[10,60]}, wandMask:canvas('wand', 40, 30), wandLayerId:layer.id, wandLastSeed:{x:20,y:10,mode:'replace'}, }; const rotated = rotateDocument(state, 90); const afterRotate = { result:rotated, layer:[layer.canvas.width,layer.canvas.height], layerMask:[layerMask.canvas.width,layerMask.canvas.height], selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height], wand:[state.wandMask.width,state.wandMask.height], offset:state.layerOffsets.get(layer.id), lasso:state.lassoPoints, guides:state.guides, seed:state.wandLastSeed, }; const flipped = flipDocument(state, 'h'); console.log(JSON.stringify({ afterRotate, flipped, offset:state.layerOffsets.get(layer.id), lasso:state.lassoPoints, guides:state.guides, seed:state.wandLastSeed, layerMask:[layerMask.canvas.width,layerMask.canvas.height], selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height], })); """ ) rotated = result["afterRotate"] assert rotated["result"] == {"width": 80, "height": 100, "degrees": 90} assert rotated["layer"] == [30, 40] assert rotated["layerMask"] == [30, 40] assert rotated["selectionMask"] == [80, 100] assert rotated["wand"] == [30, 40] assert rotated["offset"] == {"x": 45, "y": 10} assert rotated["lasso"] == [ {"x": 70, "y": 20}, {"x": 70, "y": 30}, {"x": 60, "y": 30} ] assert rotated["guides"] == {"vertical": [20, 70], "horizontal": [20, 70]} assert rotated["seed"] == {"x": 70, "y": 20, "mode": "replace"} assert result["flipped"] == {"width": 80, "height": 100, "axis": "h"} assert result["offset"] == {"x": 5, "y": 10} assert result["lasso"] == [ {"x": 10, "y": 20}, {"x": 10, "y": 30}, {"x": 20, "y": 30} ] assert result["guides"] == {"vertical": [10, 60], "horizontal": [20, 70]} assert result["seed"] == {"x": 10, "y": 20, "mode": "replace"} assert result["layerMask"] == [30, 40] assert result["selectionMask"] == [80, 100] def test_group_masks_follow_every_document_geometry_operation(): result = run_node( """ const groupMask = mask('group-mask', 'group-mask-source', 100, 80, 'group', 'document'); const layer = holder('layer-1', 'pixels', 100, 80, []); const group = {id:'group-1',layerIds:[layer.id],masks:[groupMask],activeMaskId:groupMask.id}; const main = canvas('main', 100, 80); const state = { imgWidth:100,imgHeight:80,mainCanvas:main,mainCtx:main.getContext('2d'), layers:[layer],layerGroups:[group],activeGroupId:group.id,activeLayerId:layer.id, layerOffsets:new Map([[layer.id,{x:0,y:0}]]),lassoPoints:[],guides:{},wandMask:null, }; cropDocument(state,{x:10,y:5,w:80,h:60}); const crop={size:[groupMask.canvas.width,groupMask.canvas.height],draw:sourceDraw(groupMask),active:state.maskCanvas.tag}; resizeImageDocument(state,160,120); const imageSize=[groupMask.canvas.width,groupMask.canvas.height]; resizeCanvasDocument(state,150,110); const canvasSize=[groupMask.canvas.width,groupMask.canvas.height]; rotateDocument(state,90); const rotateSize=[groupMask.canvas.width,groupMask.canvas.height]; flipDocument(state,'h'); console.log(JSON.stringify({crop,imageSize,canvasSize,rotateSize,flipSize:[groupMask.canvas.width,groupMask.canvas.height]})); """ ) assert result["crop"] == { "size": [80, 60], "draw": {"source": "group-mask-source", "args": [-10, -5]}, "active": "group-mask-source", } assert result["imageSize"] == [160, 120] assert result["canvasSize"] == [150, 110] assert result["rotateSize"] == [110, 150] assert result["flipSize"] == [110, 150] def test_placed_source_survives_document_resize_rotate_flip_and_crop(): result = run_node( """ const source = canvas('immutable-source', 40, 20); const layer = holder('placed', 'preview', 40, 20, []); layer.kind = 'placed'; layer.placed = {sourceCanvas:source,sourceWidth:40,sourceHeight:20,sourceName:'source.png',matrix:[1,0,0,1,10,5]}; const main = canvas('main', 100, 80); const state = { imgWidth:100,imgHeight:80,mainCanvas:main,mainCtx:main.getContext('2d'), layers:[layer],layerGroups:[],activeLayerId:layer.id, layerOffsets:new Map([[layer.id,{x:10,y:5}]]),lassoPoints:[],guides:{},wandMask:null, }; resizeImageDocument(state,200,160); const resized={matrix:[...layer.placed.matrix],size:[layer.canvas.width,layer.canvas.height],offset:state.layerOffsets.get(layer.id)}; rotateDocument(state,90); const rotated={matrix:[...layer.placed.matrix],size:[layer.canvas.width,layer.canvas.height],offset:state.layerOffsets.get(layer.id)}; flipDocument(state,'h'); const flipped={matrix:[...layer.placed.matrix],size:[layer.canvas.width,layer.canvas.height],offset:state.layerOffsets.get(layer.id)}; cropDocument(state,{x:10,y:20,w:100,h:100}); console.log(JSON.stringify({resized,rotated,flipped,cropped:{matrix:layer.placed.matrix,size:[layer.canvas.width,layer.canvas.height],offset:state.layerOffsets.get(layer.id)},source:[layer.placed.sourceCanvas.tag,layer.placed.sourceCanvas.width,layer.placed.sourceCanvas.height]})); """ ) assert result["resized"] == {"matrix": [2, 0, 0, 2, 20, 10], "size": [80, 40], "offset": {"x": 20, "y": 10}} assert result["rotated"] == {"matrix": [0, 2, -2, 0, 150, 20], "size": [40, 80], "offset": {"x": 110, "y": 20}} assert result["flipped"] == {"matrix": [0, 2, 2, 0, 10, 20], "size": [40, 80], "offset": {"x": 10, "y": 20}} assert result["cropped"] == {"matrix": [0, 2, 2, 0, 0, 0], "size": [40, 80], "offset": {"x": 0, "y": 0}} assert result["source"] == ["immutable-source", 40, 20] def test_saved_and_reselect_masks_follow_document_geometry(): result = run_node( """ const layer = holder('layer-1', 'pixels', 100, 80, []); const saved = canvas('saved-selection', 100, 80); const recent = canvas('last-selection', 100, 80); const main = canvas('main', 100, 80); const state = { imgWidth:100,imgHeight:80,mainCanvas:main,mainCtx:main.getContext('2d'), layers:[layer],layerGroups:[],activeLayerId:layer.id, layerOffsets:new Map([[layer.id,{x:0,y:0}]]),lassoPoints:[],guides:{},wandMask:null, savedSelections:[{id:'selection-1',name:'Subject',canvas:saved}], lastSelection:{canvas:recent}, }; cropDocument(state,{x:10,y:5,w:80,h:60}); const crop={saved:[saved.width,saved.height],recent:[recent.width,recent.height],savedDraw:sourceDraw({canvas:saved})}; resizeImageDocument(state,160,120); resizeCanvasDocument(state,150,110); rotateDocument(state,90); flipDocument(state,'h'); console.log(JSON.stringify({crop,saved:[saved.width,saved.height],recent:[recent.width,recent.height]})); """ ) assert result["crop"]["saved"] == [80, 60] assert result["crop"]["recent"] == [80, 60] assert result["crop"]["savedDraw"] == {"source": "saved-selection", "args": [-10, -5]} assert result["saved"] == [110, 150] assert result["recent"] == [110, 150] def test_text_layer_stays_retained_through_crop_and_image_resize(): result = run_node( """ const layer = holder('layer-text', 'text-pixels', 80, 30, []); layer.kind = 'text'; layer.text = { content:'Caption', fontFamily:'Arial', fontSize:20, color:'#fff', transform:{scaleX:1,scaleY:1,rotation:0,flipH:false,flipV:false}, }; const main = canvas('main', 100, 80); const state = { imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'), layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:20,y:10}]]), lassoPoints:[], wandMask:null, wandLastSeed:null, }; cropDocument(state, {x:10,y:5,w:70,h:50}); const afterCrop = { kind:layer.kind, content:layer.text.content, offset:state.layerOffsets.get(layer.id), size:[layer.canvas.width,layer.canvas.height], }; resizeImageDocument(state, 140, 100); console.log(JSON.stringify({ afterCrop, kind:layer.kind, content:layer.text.content, offset:state.layerOffsets.get(layer.id), transform:layer.text.transform, size:[layer.canvas.width,layer.canvas.height], })); """ ) assert result["afterCrop"]["kind"] == "text" assert result["afterCrop"]["content"] == "Caption" assert result["afterCrop"]["offset"] == {"x": 10, "y": 5} assert result["kind"] == "text" assert result["content"] == "Caption" assert result["offset"] == {"x": 20, "y": 10} assert result["transform"]["scaleX"] == 2 assert result["transform"]["scaleY"] == 2 assert result["size"][0] > result["afterCrop"]["size"][0] def test_image_menu_exposes_separate_canvas_and_image_size_commands(): topbar = (ROOT / "static/js/editor/build/topbar.js").read_text(encoding="utf-8") wiring = (ROOT / "static/js/editor/wire-topbar-menus.js").read_text(encoding="utf-8") editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8") assert 'data-image-action="canvas-size"' in topbar assert 'data-image-action="image-size"' in topbar assert "resizeCanvasDocument(state, newW, newH, options)" in wiring assert "resizeImageDocument(state, newW, newH, {" in wiring assert "_cropDocument(state, state.cropRect)" in editor