mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 04:31:09 +02:00
shamona
This commit is contained in:
+21
-6
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
+105
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-11
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user