From 63448ca6403bc0b576a0e021396b9fb2c85268e1 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 19 Aug 2026 06:41:10 -0400 Subject: [PATCH] shamona --- docs/new-features.md | 2 +- docs/skin-studio.md | 23 +++-- src/core/DeltaSkin.lua | 27 ++++-- src/core/PdfImage.lua | 115 ++++++++++++++++++++++ src/core/TouchSkin.lua | 116 ++++++++++++++++++++--- src/import/RomImporter.lua | 3 +- src/ui/SkinStudio.lua | 24 ++--- tests/engine/skin_format_import_test.lua | 76 +++++++++++++++ 8 files changed, 347 insertions(+), 39 deletions(-) create mode 100644 src/core/PdfImage.lua diff --git a/docs/new-features.md b/docs/new-features.md index 64615d10..7ab7c658 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -11,7 +11,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Persistent custom options** stored separately from game saves * **Optional widescreen battle layout** * **Mobile touch controls** with editable layouts, vibration, and orientation settings -* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders +* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders * **Pokédex diploma and printer image exports** ## Gen 2 Specifics diff --git a/docs/skin-studio.md b/docs/skin-studio.md index 24e345d2..ff487233 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -95,18 +95,22 @@ auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in `extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the corners fire two directions. `screens[1].outputFrame` (or the legacy -`gameScreenFrame`) becomes the screen cutout, and the skin stretches to the -window the way Delta does rather than letterboxing. Host functions map to +`gameScreenFrame`) becomes the screen cutout. A portrait page with neither +keeps `mappingSize` as the overlay aspect, sits at the bottom of the +window, and puts the Game Boy picture in the leftover space above -- the +usual GBA4iOS controller-deck layout. Pages that name a screen rect still +stretch to the window the way Delta does. Host functions map to engine hotkeys: `menu` to `menu_toggle`, `fastForward` to `hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`; `quickSave` and `quickLoad` have nothing to bind to and drop to decoration. Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*` identifiers are accepted, and a non Game Boy system warns instead of failing. -PDF artwork is the one thing that does not come across: Delta's own templates -are all-PDF and this engine has no rasterizer, so such a skin is refused with -the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are -an older, incompatible schema and are refused by name. +PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's +Image-to-PDF skins, Preview exports, and the like). Import extracts that +JPEG and draws it; a true vector PDF with no embedded image is still refused, +with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files +are an older, incompatible schema and are refused by name. ## Bindable actions @@ -268,6 +272,7 @@ exported file** opens that folder. ## Not implemented -Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this -engine does not carry, so they are refused with a message rather than imported -half-drawn. +True vector Delta skins (PDF artwork with no embedded JPEG). Those still need +a PDF renderer this engine does not carry, so they are refused with a message +rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta +skin case, extract on import. diff --git a/src/core/DeltaSkin.lua b/src/core/DeltaSkin.lua index e2d9bd8f..d0bfb1c9 100644 --- a/src/core/DeltaSkin.lua +++ b/src/core/DeltaSkin.lua @@ -116,11 +116,13 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles) if type(assets) ~= "table" then return nil end pdfFiles = pdfFiles or {} local raster = {} + local pdfName for _, key in ipairs(DeltaSkin.ASSET_LADDER) do local name = pick(assets, key) if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end if type(name) == "string" and name ~= "" then if name:lower():match("%.pdf$") then + pdfName = name pdfFiles[#pdfFiles + 1] = name else raster[#raster + 1] = { key = key, name = name } @@ -130,12 +132,14 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles) local resizable = pick(assets, "resizable") if type(resizable) == "string" and resizable ~= "" then if resizable:lower():match("%.pdf$") then + pdfName = resizable pdfFiles[#pdfFiles + 1] = resizable else raster[#raster + 1] = { key = "large", name = resizable } end end - if #raster == 0 then return nil end + local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil + if #raster == 0 then return nil, pdfPath end local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH) local chosen @@ -145,7 +149,7 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles) end end if not chosen then chosen = raster[#raster].name end - return DeltaSkin.resolveName(chosen, opts) + return DeltaSkin.resolveName(chosen, opts), nil end function DeltaSkin.mergeEdges(base, item) @@ -288,10 +292,12 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) addWarning(warnings, orient .. " has no mappingSize; assuming 320x240") end + local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles) local page = { name = orient, orient = orient, - imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles), + imagePath = imagePath, + pdfPath = pdfPath, fullScreen = true, normalized = true, pixelCoords = false, @@ -309,6 +315,14 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) if screen then page.viewport = screen page.viewportFill = false + else + -- mappingSize is the overlay, not the device. Portrait controller + -- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep + -- that aspect, sit at the bottom, and leave the leftover for the + -- Game Boy picture. A screens/gameScreenFrame rect still fills. + page.aspectFromCfg = true + page.screenFit = "remainder" + if orient == "portrait" then page.anchor = "bottom" end end local baseEdges = pick(obj, "extendedEdges") @@ -368,9 +382,6 @@ function DeltaSkin.parse(text, opts) end end if #pages == 0 then return nil, "info.json has no usable representation" end - if #pdfFiles > 0 then - addWarning(warnings, "PDF artwork cannot be imported yet") - end return { pages = pages, @@ -390,6 +401,10 @@ function DeltaSkin.needsConversion(skin) local files = skin.pdfFiles if type(files) ~= "table" or #files == 0 then return nil end for _, page in ipairs(skin.pages or {}) do + -- A raster asset, or a JPEG recovered from the PDF at load, means the + -- skin can draw. Parse-only callers still see pdfOnly because they + -- have not run extract yet. + if page.rasterData then return nil end if page.imagePath then return nil end end return { pdfOnly = true, files = files } diff --git a/src/core/PdfImage.lua b/src/core/PdfImage.lua new file mode 100644 index 00000000..b4c058ea --- /dev/null +++ b/src/core/PdfImage.lua @@ -0,0 +1,115 @@ +-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins +-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so +-- import pulls the embedded image out instead of refusing the skin. True +-- vector PDFs (no Image XObject, no JPEG) still fail. + +local PdfImage = {} + +local function isPdf(bytes) + return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-" +end + +-- After the `stream` keyword the spec allows \n or \r\n before the bytes. +-- `endstream` also contains the letters "stream", so skip that match. +local function streamDataStart(bytes, from) + local s, e = bytes:find("stream", from, true) + while s do + if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then + local p = e + 1 + if bytes:sub(p, p) == "\r" then p = p + 1 end + if bytes:sub(p, p) == "\n" then p = p + 1 end + return p, s + end + s, e = bytes:find("stream", e + 1, true) + end + return nil +end + +local function dictWindow(bytes, imageAt) + local from = imageAt > 400 and (imageAt - 400) or 1 + local to = math.min(#bytes, imageAt + 800) + return bytes:sub(from, to) +end + +local function dictNumber(window, key) + -- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5. + if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end + return tonumber(window:match("/" .. key .. "%s+(%d+)")) +end + +local function dictFilter(window) + local named = window:match("/Filter%s*/(%w+)") + if named then return named end + return window:match("/Filter%s*%[%s*/(%w+)") +end + +local function jpegIn(bytes, from, to) + if from < 1 then from = 1 end + if not to or to > #bytes then to = #bytes end + if to < from then return nil end + local region = bytes:sub(from, to) + local soi = region:find("\255\216\255", 1, true) + if not soi then return nil end + local eoi = region:find("\255\217", soi + 3, true) + if not eoi then return nil end + return region:sub(soi, eoi + 1) +end + +local function candidate(data, width, height, ext) + if not data or data == "" then return nil end + return { + data = data, + ext = ext or "jpg", + width = width or 0, + height = height or 0, + } +end + +local function bigger(a, b) + if not a then return b end + if not b then return a end + local as = (a.width or 0) * (a.height or 0) + local bs = (b.width or 0) * (b.height or 0) + if bs ~= as then return bs > as and b or a end + return #b.data > #a.data and b or a +end + +-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream. +local function fromImageXObjects(bytes) + local best + local i = 1 + while true do + local s, e = bytes:find("/Subtype%s*/Image", i) + if not s then break end + local window = dictWindow(bytes, s) + local filter = dictFilter(window) + local width = dictNumber(window, "Width") + local height = dictNumber(window, "Height") + local dataStart = streamDataStart(bytes, e) + i = e + 1 + if dataStart and filter == "DCTDecode" then + local es = bytes:find("endstream", dataStart, true) + local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil) + best = bigger(best, candidate(jpeg, width, height, "jpg")) + end + end + return best +end + +-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG +-- body even when /Length is an indirect object we do not resolve. +local function fromBareJpeg(bytes) + local jpeg = jpegIn(bytes, 1, #bytes) + if not jpeg then return nil end + return candidate(jpeg, 0, 0, "jpg") +end + +function PdfImage.extract(bytes) + if not isPdf(bytes) then return nil, "not a pdf" end + local best = fromImageXObjects(bytes) + if not best then best = fromBareJpeg(bytes) end + if not best then return nil, "no extractable image" end + return best +end + +return PdfImage diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index c9aa6080..1b79d620 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -432,6 +432,10 @@ function TouchSkin.parseNative(text) aspectFromCfg = raw.fitAspect == true, orient = (raw.orient == "portrait" or raw.orient == "landscape" or raw.orient == "any") and raw.orient or nil, + screenFit = raw.screenFit == "remainder" and "remainder" or nil, + anchor = (raw.anchor == "top" or raw.anchor == "bottom" + or raw.anchor == "left" or raw.anchor == "right") + and raw.anchor or nil, rect = { x = 0, y = 0, w = 1, h = 1 }, controls = {}, } @@ -507,6 +511,8 @@ function TouchSkin.toNative(skin) alphaMod = page.alphaMod, aspect = page.aspect, fitAspect = page.aspectFromCfg or nil, + screenFit = page.screenFit == "remainder" and "remainder" or nil, + anchor = page.anchor, orient = (page.orient == "portrait" or page.orient == "landscape" or page.orient == "any") and page.orient or nil, controls = {}, @@ -570,6 +576,40 @@ local function loadImage(path) return img end +-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip. +local function loadImageFromBytes(bytes, name) + if not bytes or bytes == "" then return nil end + if not (love and love.graphics and love.graphics.newImage) then return nil end + if not (love.filesystem and love.filesystem.newFileData) then return nil end + local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes) + local cached = imageCache[key] + if cached then return cached end + local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg") + if not okFd or not fd then return nil end + local ok, img = pcall(love.graphics.newImage, fd) + if not ok or not img then + if love.image and love.image.newImageData then + local okData, data = pcall(love.image.newImageData, fd) + if okData and data then ok, img = pcall(love.graphics.newImage, data) end + end + end + if not ok or not img then return nil end + if img.setFilter then img:setFilter("linear", "linear") end + imageCache[key] = img + return img +end + +local function rasterizePdfPage(page, root) + if not page or page.image or not page.pdfPath then return end + local pdf = readFile(joinPath(root, page.pdfPath)) + local raster = require("src.core.PdfImage").extract(pdf) + if not raster then return end + local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext + page.rasterData = raster.data + page.rasterName = name:match("([^/]+)$") or name + page.image = loadImageFromBytes(raster.data, page.rasterName) +end + local function pixelScalePending(page) if page.pixelCoords then return true end for _, ctl in ipairs(page.controls or {}) do @@ -622,6 +662,8 @@ function TouchSkin.load(root, id) for _, page in ipairs(skin.pages) do if page.imagePath then page.image = loadImage(joinPath(root, page.imagePath)) + elseif page.pdfPath then + rasterizePdfPage(page, root) end if not applyPixelScale(page) then return nil, "could not read " .. tostring(page.imagePath) @@ -646,7 +688,7 @@ end TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true } TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } TouchSkin.PDF_ONLY_MESSAGE = - "This skin uses PDF artwork, which cannot be imported yet. " + "This skin uses PDF artwork with no extractable image. " .. "Ask the author for a PNG version." function TouchSkin.archiveId(name) @@ -1246,12 +1288,27 @@ function TouchSkin.pageBox(page, w, h, ox, oy) and page.aspect and page.aspect > 0 and h > 0 if fit then local displayAspect = w / h + local anchor = page.anchor if displayAspect > page.aspect then bw = h * page.aspect - bx = ox + (w - bw) * 0.5 + local extra = w - bw + if anchor == "right" then + bx = ox + extra + elseif anchor == "left" then + bx = ox + else + bx = ox + extra * 0.5 + end else bh = w / page.aspect - by = oy + (h - bh) * 0.5 + local extra = h - bh + if anchor == "bottom" then + by = oy + extra + elseif anchor == "top" then + by = oy + else + by = oy + extra * 0.5 + end end end local r = page.rect @@ -1308,18 +1365,55 @@ end function TouchSkin.hasViewport() local page = TouchSkin.page() - return page ~= nil and page.viewport ~= nil and TouchSkin.drawable() + if not page or not TouchSkin.drawable() then return false end + return page.viewport ~= nil or page.screenFit == "remainder" +end + +-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box. +local function remainderBox(ox, oy, w, h, bx, by, bw, bh) + local right, bottom = ox + w, oy + h + local cand = { + { ox, oy, w, by - oy }, + { ox, by + bh, w, bottom - (by + bh) }, + { ox, oy, bx - ox, h }, + { bx + bw, oy, right - (bx + bw), h }, + } + local best, bestArea + for _, r in ipairs(cand) do + if r[3] > 1 and r[4] > 1 then + local area = r[3] * r[4] + if not best or area > bestArea then + best, bestArea = r, area + end + end + end + if not best then return nil end + return best[1], best[2], best[3], best[4] +end + +function TouchSkin.pageViewport(page, w, h, ox, oy) + if not page then return nil end + ox, oy = ox or 0, oy or 0 + local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) + if page.viewport then + local v = page.viewport + local x, y = bx + v.x * bw, by + v.y * bh + local vw, vh = v.w * bw, v.h * bh + if vw <= 0 or vh <= 0 then return nil end + return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true + end + if page.screenFit == "remainder" then + local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh) + if not x then return nil end + return x, y, vw, vh, false, false + end + return nil end function TouchSkin.viewport(w, h, ox, oy) local page = TouchSkin.page() - if not page or not page.viewport or not TouchSkin.drawable() then return nil end - local v = page.viewport - local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) - local x, y = bx + v.x * bw, by + v.y * bh - local vw, vh = v.w * bw, v.h * bh - if vw <= 0 or vh <= 0 then return nil end - return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true + if not page or not TouchSkin.drawable() then return nil end + return TouchSkin.pageViewport(page, w, h, ox, oy) end return TouchSkin diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 7a94df36..979b4cce 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3119,7 +3119,8 @@ function RomImporter:_ensureSkins(force) format = skin and skin.format or nil, pages = skin and #skin.pages or 0, controls = controls, - screen = page ~= nil and page.viewport ~= nil, + screen = page ~= nil + and (page.viewport ~= nil or page.screenFit == "remainder"), ok = skin ~= nil, } end diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 2e624fa3..99f2b3b5 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -851,6 +851,7 @@ function Studio.detectViewport() end Studio.pushUndo() page.viewport = rect + page.screenFit = nil setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph)) Studio.dirty = true end @@ -860,8 +861,9 @@ function Studio.toggleViewport() if not page then return end if Studio.canvas().lockViewport then return end Studio.pushUndo() - if page.viewport then + if page.viewport or page.screenFit == "remainder" then page.viewport = nil + page.screenFit = nil else page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 } end @@ -892,7 +894,7 @@ function Studio.pageLabel(index) local orient = TouchSkin.pageOrient(page) local bits = { #(page.controls or {}) .. " controls" } if orient then bits[#bits + 1] = orient end - if page.viewport then bits[#bits + 1] = "screen" end + if page.viewport or page.screenFit == "remainder" then bits[#bits + 1] = "screen" end return name, table.concat(bits, " \194\183 ") end @@ -1092,10 +1094,9 @@ function Studio.snapLines(page, r, skipIndex) end local function viewportRect(page, r) - local v = page.viewport - if not v then return nil end - local bx, by, bw, bh = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) - return bx + v.x * bw, by + v.y * bh, v.w * bw, v.h * bh + local x, y, w, h = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y) + if not x then return nil end + return x, y, w, h end local function handleRects(bx, by, bw, bh) @@ -1138,7 +1139,7 @@ function Studio.beginCanvasDrag(mx, my, r) end local vx, vy, vw, vh = viewportRect(page, r) - if vx and not Studio.canvas().lockViewport then + if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then for _, h in ipairs(handleRects(vx, vy, vw, vh)) do if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then Studio.pushUndo() @@ -1162,7 +1163,7 @@ function Studio.beginCanvasDrag(mx, my, r) end end - if vx and not Studio.canvas().lockViewport + if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then Studio.selected = nil Studio.pushUndo() @@ -1270,7 +1271,7 @@ local function drawCanvas(x, y, w, h) if vx then Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2) Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue) - if not Studio.canvas().lockViewport then + if not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then local a = Studio.selectedControl() and 0.45 or 1 for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a) @@ -1374,7 +1375,7 @@ local function inspectorBody(x, y, w) cy = cy + rowH + gap if page then - local bezel = page.imagePath or "(none)" + local bezel = page.imagePath or page.rasterName or "(none)" local pickW = 82 * Kit.scale local cycleW = w - pickW - gap if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then @@ -1385,7 +1386,8 @@ local function inspectorBody(x, y, w) Studio.importImageFile("bezel") end cy = cy + rowH + gap - local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF" + local vpLabel = (page.viewport or page.screenFit == "remainder") + and "Screen cutout: ON" or "Screen cutout: OFF" if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp", enabled = not Studio.canvas().lockViewport }) then Studio.toggleViewport() diff --git a/tests/engine/skin_format_import_test.lua b/tests/engine/skin_format_import_test.lua index 20001bff..a3cfad26 100644 --- a/tests/engine/skin_format_import_test.lua +++ b/tests/engine/skin_format_import_test.lua @@ -364,6 +364,31 @@ eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y") eq(bw, 1000, "delta page box fills the width") eq(bh, 500, "delta page box fills the height") +local DECK_JSON = [[ +{ "name": "Deck", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "large": "deck.png" }, + "mappingSize": {"width":320,"height":240}, + "items": [ { "inputs": ["a"], "frame": {"x":240,"y":60,"width":64,"height":64} } ] + } } } } } +]] +local deck = assert(DeltaSkin.parse(DECK_JSON)) +local deckPage = deck.pages[1] +check(deckPage.aspectFromCfg, "a portrait deck without screens keeps mapping aspect") +eq(deckPage.anchor, "bottom", "and sits at the bottom of the window") +eq(deckPage.screenFit, "remainder", "with the leftover given to the GB picture") +eq(deckPage.viewport, nil, "no screens[] means no baked cutout") +local dbx, dby, dbw, dbh = TouchSkin.pageBox(deckPage, 1080, 1920) +eq(dbx, 0, "deck overlay is full width") +eq(dbw, 1080, "deck overlay width") +near(dbh, 1080 * 240 / 320, "deck overlay height is mapping aspect") +near(dby, 1920 - dbh, "pinned to the bottom, not stretched") +local vx, vy, vw, vh = TouchSkin.pageViewport(deckPage, 1080, 1920) +eq(vx, 0, "screen leftover x") eq(vy, 0, "screen leftover y") +eq(vw, 1080, "screen leftover is full width") +near(vh, dby, "and fills everything above the overlay") +check(vh > dbh, "there is more room for the picture than for the pad") + local LEGACY_SCREEN = [[ { "gameTypeIdentifier": "public.aoshuang.game.gbc", "representations": { "iphone": { "standard": { "landscape": { @@ -419,6 +444,8 @@ local PDF_JSON = [[ ]] local pdf = assert(DeltaSkin.parse(PDF_JSON)) eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art") +eq(pdf.pages[1].pdfPath, "iphone_portrait.pdf", + "but the PDF path is kept so load can extract a JPEG from it") local convert = DeltaSkin.needsConversion(pdf) check(convert ~= nil, "PDF-only skins report that they need conversion") if convert then @@ -471,6 +498,55 @@ check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil, eq(love.filesystem.read("skins/vector.deltaskin"), nil, "and the refused archive is not left behind") +local PdfImage = require("src.core.PdfImage") +local function unhex(s) + return (s:gsub("..", function(cc) + return string.char(tonumber(cc, 16)) + end)) +end +-- 1x1 JFIF JPEG, so extract tests do not need a file on disk. +local TINY_JPEG = unhex( + "ffd8ffe000104a46494600010100000100010000ffdb0043000806060706050807070709" .. + "09080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c283729" .. + "2c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc400140001" .. + "0000000000000000000000000000000008ffc40014100100000000000000000000000000" .. + "00000000ffda0008010100003f007f3fffd9") +local function jpegPdf(jpeg, w, h) + return "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width " + .. tostring(w) .. " /Height " .. tostring(h) + .. " /BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter /DCTDecode /Length " + .. tostring(#jpeg) .. " >>\nstream\n" .. jpeg .. "\nendstream\nendobj\n%%EOF\n" +end +local extracted = assert(PdfImage.extract(jpegPdf(TINY_JPEG, 1, 1))) +eq(extracted.ext, "jpg", "a JPEG-in-PDF yields a jpg") +eq(extracted.data, TINY_JPEG, "and the JPEG body is recovered byte for byte") +eq(extracted.width, 1, "width comes from the Image XObject") +eq(extracted.height, 1, "and so does height") + +local indirect = "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width 1" + .. " /Height 1 /Filter /DCTDecode /Length 5 0 R >>\nstream\n" .. TINY_JPEG + .. "\nendstream\nendobj\n5 0 obj\n" .. tostring(#TINY_JPEG) .. "\nendobj\n%%EOF\n" +local fromRef = assert(PdfImage.extract(indirect)) +eq(fromRef.data, TINY_JPEG, + "an indirect /Length (the 3-Heights Image-to-PDF layout) still extracts") + +eq(select(1, PdfImage.extract("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")), + nil, "a vector PDF with no image is not pretended to be art") +eq(select(1, PdfImage.extract("not a pdf")), nil, "and neither is garbage") + +love.filesystem.write("skins/pikapdf.deltaskin/info.json", PDF_JSON) +love.filesystem.write("skins/pikapdf.deltaskin/iphone_portrait.pdf", + jpegPdf(TINY_JPEG, 1, 1)) +local pikaId, pikaErr = TouchSkin.installArchive("pikapdf.deltaskin", "PK\3\4stub") +eq(pikaId, "pikapdf", "a Delta skin whose PDF wraps a JPEG installs: " + .. tostring(pikaErr)) +local pika = assert(TouchSkin.load("skins/_mounted/pikapdf", "pikapdf")) +check(pika.pages[1].rasterData == TINY_JPEG, + "load recovers the JPEG from the PDF") +check(pika.pages[1].image ~= nil, "and LOVE gets an image from those bytes") +eq(DeltaSkin.needsConversion(pika), nil, + "so the skin no longer reports that it needs conversion") + eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil, "a GBA4iOS .gbcskin is refused at the door") local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub")