local p = {}
local metatable = {}
local methodtable = {}
local create2dContext
local setDefaults
local isFinite
function p.getContext( contextType, contextAttributes )
if contextType == '2d' then
ctx = create2dContext()
if type( contextAttributes ) == 'table' then
if contextAttributes.width ~= nil then
ctx._width = tonumber(contextAttributes.width)
end
if contextAttributes.height ~= nil then
ctx._height = tonumber(contextAttributes.height)
end
if type( contextAttributes.containerClass ) == 'string' then
ctx._containerClass = contextAttributes.containerClass
end
if type( contextAttributes.containerStyle ) == 'string' then
ctx._containerStyle = contextAttributes.containerStyle
end
if contextAttributes.alpha == false then
ctx._alpha = false
end
end
return ctx
end
error( "Unsupported context" )
end
function p.render( frame )
-- Allow calling with full <svg> via template:
-- {{#invoke:M_SVG2CSS|render|<nowiki><svg>...</svg></nowiki>|width=124|height=124}}
local width = (type(frame) == 'table' and tonumber(frame.args.width)) or 600
local height = (type(frame) == 'table' and tonumber(frame.args.height)) or 600
local ctx = p.getContext( '2d', { width = width, height = height } )
-- prefer svg passed in through frame args
local svg = nil
if type(frame) == 'table' and frame.args then
svg = frame.args.svg or frame.args[1]
end
-- If the caller passed an HTML-escaped SVG (e.g. "<svg...>") unescape common entities.
if type(svg) == 'string' then
-- Normalise common HTML-escaped entities (do it unconditionally to be robust).
svg = svg
:gsub("<", "<")
:gsub(">", ">")
:gsub(""", '"')
:gsub("'", "'")
:gsub("&", "&")
-- Extract the first <svg ...>...</svg> block if caller included extra wiki markup
local extracted = svg:match("(%<svg.-%</svg%>)")
if extracted then svg = extracted end
svg = svg:match("^%s*(.-)%s*$") or svg
end
if svg and svg ~= '' then
p.drawSVG(svg, ctx)
return tostring(ctx)
end
-- No svg provided: render built-in test SVG
local testSvg = [[
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="124" viewBox="0 0 124 124" fill="none">
<rect width="124" height="124" rx="24" fill="#F97316"/>
<path d="M19.375 36.7818V100.625C19.375 102.834 21.1659 104.625 23.375 104.625H87.2181C90.7818 104.625 92.5664 100.316 90.0466 97.7966L26.2034 33.9534C23.6836 31.4336 19.375 33.2182 19.375 36.7818Z" fill="white"/>
<circle cx="63.2109" cy="37.5391" r="18.1641" fill="black"/>
<rect opacity="0.4" x="81.1328" y="80.7198" width="17.5687" height="17.3876" rx="4" transform="rotate(-45 81.1328 80.7198)" fill="#FDBA74"/>
</svg>]]
p.drawSVG(testSvg, ctx)
return tostring(ctx)
end
-- Round to 0. To prevent 1.13132e-14 from showing up.
local function r0(x)
if math.abs(x) < 1e-4 then
return 0
end
return x
end
local function normalizeAngle( angle )
return ((angle % (math.pi*2)) + math.pi*2) % (math.pi*2)
end
metatable.__index = methodtable
metatable.__tostring = function( t )
return t:getWikitext()
end
local pathmethods = {}
local pathmeta = {}
pathmeta.__index = pathmethods
setmetatable( methodtable, pathmeta )
function create2dContext()
local ctx = {}
setmetatable( ctx, metatable )
ctx._width = 300
ctx._height = 300
ctx._containerClass = nil
ctx._containerStyle = nil
ctx._alpha = true
-- Default values
setDefaults( ctx )
return ctx
end
setDefaults = function( ctx )
ctx.__stateStack = {}
ctx.__operations = {}
ctx._currentTransform = { 1, 0, 0, 1, 0, 0 }
ctx._path = ""
ctx._fillRule = "nonzero"
ctx._lineDash = {}
ctx.lineWidth = 1.0
ctx.lineCap = 'butt'
ctx.lineJoin = 'miter'
ctx.miterLimit = 10
ctx.lineDashOffset = 0.0
ctx.font = "10px sans-serif"
ctx.textAlign = 'start'
ctx.textBaseline = 'alphabetic'
ctx.direction = 'inherit'
ctx.letterSpacing = '0px'
ctx.fontKerning = 'auto'
ctx.fontStretch = 'normal'
ctx.fontVariantCaps = 'normal'
ctx.textRendering = 'auto'
ctx.wordSpacing = '0px'
ctx.fillStyle = '#000'
ctx.strokeStyle = '#000'
ctx.shadowBlur = 0
ctx.shadowColor = 'rgb(0 0 0 / 0%)'
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.globalAlpha = 1.0
ctx.globalCompositeOperation = "source-over"
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = "low"
ctx.canvas = nil
ctx.filter = "none"
return ctx
end
local newOperation = function( t, operation )
op = {}
op.name = operation
op._path = t._path
op._currentTransform = mw.clone(t._currentTransform)
op._fillRule = t._fillRule
op._lineDash = t._lineDash
op.lineWidth = t.lineWidth
op.lineCap = t.lineCap
op.lineJoin = t.lineJoin
op.miterLimit = t.miterLimit
op.lineDashOffset = t.lineDashOffset
op.font = t.font
op.textAlign = t.textAlign
op.textBaseline = t.textBaseline
op.direction = t.direction
op.letterSpacing = t.letterSpacing
op.fontKerning = t.fontKerning
op.fontStretch = t.fontStretch
op.fontVariantCaps = t.fontVariantCaps
op.textRendering = t.textRendering
op.wordSpacing = t.wordSpacing
op.fillStyle = t.fillStyle
op.strokeStyle = t.strokeStyle
op.shadowBlur = t.shadowBlur
op.shadowColor = t.shadowColor
op.shadowOffsetX = t.shadowOffsetX
op.shadowOffsetY = t.shadowOffsetY
op.globalAlpha = t.globalAlpha
op.globalCompositeOperation = t.globalCompositeOperation
op.imageSmoothingEnabled = t.imageSmoothingEnabled
op.imageSmoothingQuality = t.imageSmoothingQuality
op.canvas = t.canvas
op.filter = t.filter
return op
end
methodtable.setTransform = function( ctx, a, b, c, d, e, f )
-- last 0 0 1 row is left implied
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
ctx:resetTransform()
ctx:transform( a, b, c, d, e, f )
end
methodtable.resetTransform = function( ctx )
-- last 0 0 1 row is left implied
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
ctx._currentTransform = { 1, 0, 0, 1, 0, 0 }
end
methodtable.transform = function( ctx, a, b, c, d, e, f )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
-- Do a matrix multiply
-- a c e
-- b d f
-- 0 0 1
local oa = ctx._currentTransform[1]
local ob = ctx._currentTransform[2]
local oc = ctx._currentTransform[3]
local od = ctx._currentTransform[4]
local oe = ctx._currentTransform[5]
local of = ctx._currentTransform[6]
ctx._currentTransform[1] = a*oa + b*oc
ctx._currentTransform[3] = c*oa + d*oc
ctx._currentTransform[5] = e*oa + f*oc + oe
ctx._currentTransform[2] = a*ob + b*od
ctx._currentTransform[4] = c*ob + d*od
ctx._currentTransform[6] = e*ob + f*od + of
end
methodtable.scale = function( ctx, x, y )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
assert( type(x) == "number", "x argument to scale must be a number" )
assert( type(y) == "number", "y argument to scale must be a number" )
ctx:transform( x, 0, 0, y, 0, 0 )
end
methodtable.rotate = function( ctx, a )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
assert( type(a) == "number", "Argument a to rotate must be number of radians" )
ctx:transform( math.cos(a), math.sin(a), -math.sin(a), math.cos(a), 0, 0 )
end
methodtable.translate = function( ctx, x, y )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
ctx:transform( 1, 0, 0, 1, x, y )
end
methodtable.setLineDash = function( ctx, dashArray )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
assert( type(dashArray) == 'table', 'dashArray (second arg) should be an array' )
local newDash = {}
for i, v in ipairs( dashArray ) do
if type(v) ~= 'number' or v <= 0 or v == 1/0 or v~=v then
-- Normally I would throw an error here, but the canvas spec
-- says you aren't allowed to
mw.log( "Invalid lineDash set. Ignoring" )
return
end
newDash[#newDash+1] = v
end
-- Must always be even.
if #newDash % 2 == 1 then
for i, v in ipairs( dashArray ) do
newDash[#newDash+1] = v
end
end
ctx._lineDash = newDash
end
methodtable.getLineDash = function( ctx, dashArray )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
return ctx._lineDash
end
-- Path2D methods
p.Path2D = function( pathDesc )
local path = {}
path._path = ""
setmetatable( path, pathmeta )
if type( pathDesc ) == "string" then
-- Constructor can take an SVG path description
path._path = pathDesc
end
if type( pathDesc ) == 'table' and type( pathDesc._path ) == 'string' then
-- Constructor can take a Path2D object.
path._path = pathDesc._path
end
return path
end
-- Technically this is only supposed to be on Path2D and not context.
pathmethods.addPath = function( ctx, path, transform )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
if transform ~= nil then
error( "transform argument to addPath is not implemented yet" )
end
if path == nil or path._path == nil then
error( "Second argument should be a Path2D object" )
end
ctx._path = ctx._path .. " " .. path._path
end
pathmethods.moveTo = function( ctx, x, y )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
ctx._path = ctx._path .. string.format( "M %.8g %.8g", r0(x), r0(y) )
end
pathmethods.lineTo = function( ctx, x, y )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
ctx._path = ctx._path .. string.format( "L %.8g %.8g", r0(x), r0(y) )
end
pathmethods.quadraticCurveTo = function( ctx, cx, cy, x, y )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
ctx._path = ctx._path .. " Q " .. cx .. " " .. cy .. " " .. x .. " " .. y
end
pathmethods.bezierCurveTo = function( ctx, c1x, c1y, c2x, c2y, x, y )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
ctx._path = ctx._path .. " C " .. c1x .. " " .. c1y .. " " .. c2x .. " " .. c2y .. " " .. x .. " " .. y
end
pathmethods.beginPath = function( ctx )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
ctx._path = ''
end
pathmethods.closePath = function( ctx )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
ctx._path = ctx._path .. ' Z'
end
pathmethods.rect = function( ctx, x, y, w, h )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
if not isFinite( x ) or not isFinite( y ) or not isFinite( w ) or not isFinite( h ) or w == 0 or h == 0 then
return
end
ctx:moveTo( x, y )
ctx:lineTo( x+w, y )
ctx:lineTo( x+w, y+h )
ctx:lineTo( x, y+h )
ctx:closePath()
end
-- FIXME, behaviour around if a path is closed without calling closePath() is not correct.
-- Draw an arc centered on (x,y). counterClockWise argument is optional and defaults false.
pathmethods.arc = function( ctx, x, y, radius, startAngle, endAngle, counterClockWise )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
if radius < 0 then
error( "IndexSizeError: radius cannot be negative" )
end
-- FIXME test the case of a full circle.
-- Seems like full circle if endAngle-startAngle >= 2pi when CW and startAngle-endAngle >= 2pi when CCW
-- but not normalized. e.g. if startAngle is 10pi and endAngle is pi, that is full circle in CCW but not CW
if counterCLockwise == true then
if endAngle-startAngle >= math.pi*2 then
startAngle=math.pi*2
endAngle=0
end
else
if endAngle-startAngle >= math.pi*2 then
startAngle=math.pi*2
endAngle=0
end
end
startAngle = normalizeAngle( startAngle )
endAngle = normalizeAngle( endAngle )
local startX = x + math.cos( startAngle )*radius
local startY = y + math.sin( startAngle )*radius
local endX = x + math.cos( endAngle )*radius
local endY = y + math.sin( endAngle )*radius
local circle = false
assert( startX == startX and endX == endX and startY == startY and endY == endY, "NaN detected when calculating angle" )
if startX == endX and startY == endY then
-- SVG arc command doesn't like drawing perfect circles
endX = endX + 0.01
endY = endY + 0.01
end
local large, ccw
-- FIXME, if there is not subpath yet, the lineTo() should be a moveTo().
if counterClockWise == true then
ccw = 1
if normalizeAngle( startAngle - endAngle ) > math.pi then
large = 1
if startAngle < endAngle or ( startAngle > 3*math.pi/4 and endAngle < math.pi/4 ) then
startX, endX = endX, startX
startY, endY = endY, startY
end
else
if startAngle > endAngle and not( startAngle > 3*math.pi/4 and endAngle < math.pi/4 ) then
startX, endX = endX, startX
startY, endY = endY, startY
end
large = 0
end
else
ccw = 0
if normalizeAngle( startAngle - endAngle ) > math.pi then
large = 0
if startAngle < endAngle or ( startAngle > 3*math.pi/4 and endAngle < math.pi/4 ) then
startX, endX = endX, startX
startY, endY = endY, startY
end
else
if startAngle > endAngle and not( startAngle > 3*math.pi/4 and endAngle < math.pi/4 ) then
startX, endX = endX, startX
startY, endY = endY, startY
end
large = 1
end
end
-- FIXME, is this equivalent to the need-new-subpath flag in spec?
if ctx._path == '' then
ctx:moveTo( startX, startY )
end
ctx:lineTo( startX, startY )
ctx:addPath( p.Path2D( string.format(
"A %.8g %.8g 0 %d %d %.8g %.8g",
r0(radius),
r0(radius),
large,
ccw,
r0(endX),
r0(endY)
)))
if circle then
ctx:lineTo( startX, startY )
end
end
pathmethods.arcTo = function( ctx, x1, y1, x2, y2, radius )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
x0, y0 = string.match( ctx._path, "(%-?[0-9.]+)%s+(%-?[0-9.]+)%s*$")
if x0 == nil or y0 == nil then
-- FIXME, this isn't right if the last item was z (closePath()).
if ctx._path ~= '' then
mw.log( "FIXME: arcTo() might be broken in this case" )
end
ctx:moveTo( x1, y1 )
x0 = x1
y0 = y1
end
x0 = tonumber(x0)
y0 = tonumber(y0)
assert( radius >= 0, "IndexSizeError: radius must be positive in arcTo()" )
if
( x0 == x1 and y0 == y1 ) or
( x1 == x2 and y1 == y2 ) or
radius == 0 or
( x0 == x1 and x1 == x2 ) or
( y0 == y1 and y1 == y2 )
then
ctx:lineTo( x1, y1 )
return
end
local angle1 = math.atan2( y1-y0, x1-x0 )
local angle2 = math.atan2( y2-y1, x2-x1 )
local avgAngle = (math.abs(angle1)+math.abs(angle2))/2
local amtOfLine1 = radius/math.tan(avgAngle)
local curveStartX = x1 - math.cos(angle1)*amtOfLine1
local curveStartY = y1 - math.sin(angle1)*amtOfLine1
local curveEndX = x1 + math.cos(angle2)*amtOfLine1
local curveEndY = y1 + math.sin(angle2)*amtOfLine1
local ccw = 0
if angle2 > angle1 then
ccw = 1
end
ctx:lineTo( curveStartX, curveStartY )
ctx:addPath( p.Path2D( string.format(
"A %.8g %.8g 0 %d %d %.8g %.8g",
r0(radius),
r0(radius),
0, -- large
ccw,
r0(curveEndX),
r0(curveEndY)
)))
end
pathmethods.ellipse = function( ctx, x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterClockWise )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
if radiusX == radiusY and rotation == 0 then
-- Easy case.
ctx:arc( x, y, radiusX, startAngle, endAngle, counterClockWise )
return
end
error( "FIXME. ellipse is not implemented yet." )
end
pathmethods.roundRect = function( ctx, x, y, w, h, radii )
assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
if not( isFinite( x ) ) or not( isFinite( y ) ) or not( isFinite( w ) ) or not( isFinite( h ) ) then
-- per spec, silently ignore.
return
end
if type( radii ) == 'number' then
radii = { radii }
end
assert( type( radii ) == 'table' and #radii >= 1 and #radii <= 4, 'RangeError: invalid radii' )
for i, v in ipairs( radii ) do
if type( v ) == 'table' and type( v[1] ) == 'number' and v[1] == v[2] then
radii[i] = v[1]
elseif type( v ) == 'table' and type( v.x ) == 'number' and v.x == v.y then
radii[i] =v.x
elseif type( v ) ~= 'number' then
-- FIXME todo.
error( "Using ellipse corners for roundRect is not currently supported" )
end
end
local topLeftR, topRightR, bottomLeftR, bottomRightR
if #radii == 1 then
topLeftR, topRightR, bottomLeftR, bottomRightR = radii[1], radii[1], radii[1], radii[1]
elseif #radii == 2 then
topLeftR, topRightR, bottomLeftR, bottomRightR = radii[1], radii[2], radii[2], radii[1]
elseif #radii == 3 then
topLeftR, topRightR, bottomLeftR, bottomRightR = radii[1], radii[2], radii[2], radii[3]
elseif #radii == 4 then
topLeftR, topRightR, bottomLeftR, bottomRightR = radii[1], radii[2], radii[4], radii[3]
else
error( "invalid radius" )
end
local top, bottom, left, right = topRightR + topLeftR, bottomRightR + bottomLeftR, topLeftR + bottomLeftR, topRightR + bottomRightR
local scale = math.min( w/top, h/left, h/right, w/bottom )
if scale < 1 then
topLeftR = topLeftR * scale
topRightR = topRightR * scale
bottomLeftR = bottomLeftR * scale
bottomRightR = bottomRightR * scale
end
local ccw = 1
if (w >= 0 and h < 0) or (w < 0 and h >= 0) then
ccw = 0
end
local function addArc( radius, endX, endY )
ctx:addPath( p.Path2D( string.format(
"A %.8g %.8g 0 %d %d %.8g %.8g",
r0(radius),
r0(radius),
0, -- large flag
ccw,
r0(endX),
r0(endY)
)))
end
ctx:beginPath()
ctx:moveTo( x + topLeftR, y )
ctx:lineTo( x + w - topRightR, y )
addArc( topRightR, x+w, y + topRightR )
ctx:lineTo( x + w, y + h - bottomRightR )
addArc( bottomRightR, x+w-bottomRightR, y+h )
ctx:lineTo( x+bottomLeftR, y+h)
addArc(bottomLeftR, x, y+h-bottomLeftR )
ctx:lineTo(x, y+topLeftR )
addArc( topLeftR, x+topLeftR, y )
ctx:closePath()
ctx:moveTo( x, y )
end
-- End of Path2D methods
-- can be fill(fillRule), fill(path), fill(path, fillRule)
methodtable.fill = function( ctx, arg1, arg2 )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
local op = newOperation( ctx, 'fill' )
if arg2 == 'evenodd' or arg1 == 'evenodd' then
op._fillRule = 'evenodd'
end
if type( arg1 ) == 'table' and type( arg1._path ) == 'string' then
op._path = arg1._path
end
table.insert( ctx.__operations, op )
end
-- returns an iterator
local function parsePath(path)
-- FIXME, in the path syntax, you can technically omit spaces, which doesn't work here.
-- https://www.w3.org/TR/SVG11/paths.html#PathData
local getNextEntry = string.gmatch( path, "(%a)%s*([-+0-9eE., ]+)" )
local curType = ''
local points = {}
return function()
while true do
local curTypeU = curType:upper()
if (curTypeU == 'L' or curTypeU == 'M' or curTypeU == 'T' ) and #points >= 2 then
return curType, { tonumber(table.remove( points, 1 )), tonumber(table.remove( points, 1 )) }
elseif ( curTypeU == 'Z' ) then
points = {}
curType = ''
return 'Z', {}
elseif ( curTypeU == 'H' or curTypeU == 'V' ) and #points >= 1 then
return curType, { tonumber(table.remove( points, 1 )) }
elseif ( curTypeU == 'S' or curTypeU == 'Q' ) and #points >= 4 then
return curType, {
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 ))
}
elseif ( curTypeU == 'C' ) and #points >= 6 then
return curType, {
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 ))
}
elseif ( curTypeU == 'A' ) and #points >= 7 then
return curType, {
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 )),
tonumber(table.remove( points, 1 ))
}
end
-- We need to get the next entry.
local pointsString
curType, pointsString = getNextEntry()
if curType == nil then
curType = ''
return
end
-- TODO this isn't quite right. 5.3.4 is supposed to be 5.3 0.4
points = mw.text.split( mw.text.trim(pointsString), "[%s,]+" )
end
end
end
-- Normalize the path into just L and M commands
local function convertToLines(pathIt)
local type, points
local startX, startY = 0,0
local curX, curY = 0, 0
local it
it = function()
type, points = pathIt()
-- For many of these commands, we never make them, but the user
-- can specify using p.Path2d( '...' )
-- TODO better handle zero length line segments.
if type == nil then
-- we are done
return
elseif type == 'L' and #points == 2 then
if curX == points[#points-1] and curY == points[#points] then
-- rm zero length lines
return it()
end
curX, curY = points[#points-1], points[#points]
return type, points
elseif type == 'l' and #points == 2 then
curX, curY = points[#points-1]+curX, points[#points]+curY
return 'L', { curX, curY }
elseif type == 'M' and #points == 2 then
if curX == points[#points-1] and curY == points[#points] then
-- moving zero doesn't count as a new subpath
return it()
end
curX, curY = points[#points-1], points[#points]
startX, startY = curX, curY
return 'M', { curX, curY }
elseif type == 'm' and #points == 2 then
if points[#points-1] == 0 and points[#points] == 0 then
-- moving zero doesn't count as a new subpath
return it()
end
curX, curY = points[#points-1]+curX, points[#points]+curY
startX, startY = curX, curY
return 'M', { curX, curY }
elseif (type == 'z' or type == 'Z') and #points == 0 then
curX, curY = startX, startY
return 'M', {startX, startY}
elseif type == 'H' and #points == 1 then
curX = points[1]
return 'L', { curX, curY }
elseif type == 'h' and #points == 1 then
curX = points[1] + curX
return 'L', { curX, curY }
elseif type == 'V' and #points == 1 then
curY = points[1]
return 'L', { curX, curY }
elseif type == 'v' and #points == 1 then
curY = points[1] + curX
return 'L', { curX, curY }
else
-- TODO q s c t a
error( "Either wrong number of points, or command " .. type .. " is not yet supported for stroking" )
end
end
return it
end
-- Convert line segment into multiple line segments if we are drawing a dashed line
local function doDashes(ctx, pathIt)
assert( type(ctx) == 'table' and type( pathIt ) == 'function' )
local patternWidth = 0
for i,v in ipairs( ctx._lineDash ) do
patternWidth = patternWidth + v
end
if patternWidth == 0 then
return function()
return pathIt()
end
end
local curSegmentPoints = nil
local pathType = nil
local curX, curY = 0, 0
local endX, endY = 0, 0
local dashOffset = ((ctx.lineDashOffset % patternWidth ) + patternWidth) % patternWidth
local position = 0 - dashOffset
local index = 0 -- note, array is 1-indexed
local on = true
local angle = nil
local position = 0
local subpathLen = 0
local curDash = nil
it = function ()
if curSegmentPoints == nil or pathType == 'M' or position >= subpathLen then
repeat
pathType, curSegmentPoints = pathIt()
if curSegmentPoints == nil then
-- all done
return
end
if pathType == 'M' then
-- new subpath
curX, curY = curSegmentPoints[1], curSegmentPoints[2]
dashOffset = ((ctx.lineDashOffset % patternWidth ) + patternWidth) % patternWidth
position = 0 - dashOffset
index = 0
curDash = ctx._lineDash[index+1]
return pathType, curSegmentPoints
else
assert( pathType == 'L' )
assert( #curSegmentPoints == 2, "Expected 2 points. got " .. pathType .. ' with ' .. #curSegmentPoints )
endX, endY = curSegmentPoints[1], curSegmentPoints[2]
subpathLen = math.sqrt( (endX-curX)*(endX-curX) + (endY-curY)*(endY-curY) )
angle = math.atan2((endY-curY),(endX-curX))
if position > 0 then
-- We want to reset position if we are drawing a new line in same subpath, but not for new subpath
position = 0
end
end
until pathType == 'L'
end
segmentLen = curDash
assert( type( segmentLen) == 'number', "lineDash " .. index .. "+1 is not a number" )
local effectiveDashLen
local curOn = on -- We want the change to on variable to apply next round not this round.
if position < 0 and position+segmentLen > 0 then
effectiveDashLen = -position
curDash = curDash-effectiveDashLen
elseif position+segmentLen <= subpathLen then
effectiveDashLen = segmentLen
index = (index+1) % #ctx._lineDash
curDash = ctx._lineDash[index+1]
on = not on
else
effectiveDashLen = subpathLen-position
curDash = curDash-effectiveDashLen
end
dashOffset = dashOffset + effectiveDashLen
position = position+effectiveDashLen
local dashEndXRel = math.cos(angle)*effectiveDashLen
local dashEndYRel = math.sin(angle)*effectiveDashLen
-- The dash end before the current subpath ends.
if position > 0 then
curX = dashEndXRel+curX
curY = dashEndYRel+curY
end
if curOn and position > 0 then
return 'L', { curX, curY }
else
return 'M', { curX, curY }
end
end
return it
end
-- for type, pointsCombined in string.gmatch( ctx._path, "(%a)%s*([0-9. ]+)" ) do repeat
-- points = mw.text.split( mw.text.trim(pointsCombined), "%s+" )
-- Not properly implemented. Only works on straight lines.
methodtable.stroke = function( ctx, path )
ctx:save()
if type( path ) == 'table' and type( path._path ) == 'string' then
ctx._path = path._path
elseif path ~= nil then
error( "Invalid second argument to stroke" )
end
local newPath = p.Path2D()
local curX = 0
local curY = 0
local startX = 0
local startY = 0
local offset = ctx.lineWidth/2
local deg90 = math.pi/2
local lastAngle = nil
local startAngle = nil
-- When drawing, it is important that we always draw in a clockwise direction
-- per https://www.w3.org/TR/SVG2/painting.html#WindingRule clockwise and anti-clockwise
-- cancel each other out.
local draw = function( path, curX, newX, curY, newY )
local angle = math.atan2((newY-curY),(newX-curCurY))-deg90
local ypt = math.sin(angle)*offset
local xpt = math.cos(angle)*offset
newPath:moveTo( curX+xpt, curY+ypt )
newPath:lineTo( newX+xpt, newY+ypt )
newPath:lineTo( newX-xpt, newY-ypt )
newPath:lineTo( curX-xpt, curY-ypt )
newPath:lineTo( curX+xpt, curY+ypt )
return angle+deg90
end
local function drawLineJoin( prevAngle, nextAngle, curX, curY )
if prevAngle == newAngle then
return
end
local xOffsetPrev = math.cos(prevAngle+deg90)*offset
local yOffsetPrev = math.sin(prevAngle+deg90)*offset
local xOffsetNext = math.cos(nextAngle+deg90)*offset
local yOffsetNext = math.sin(nextAngle+deg90)*offset
-- is the angle facing inwards or outwards
local diff = ((nextAngle-prevAngle)+math.pi*2) % (math.pi*2)
local xNextPoint, yNextPoint, xPrevPoint, yPrevPoint
if diff > math.pi then
xNextPoint, yNextPoint = curX+xOffsetNext, curY+yOffsetNext
xPrevPoint, yPrevPoint = curX+xOffsetPrev, curY+yOffsetPrev
else
xNextPoint, yNextPoint = curX-xOffsetNext, curY-yOffsetNext
xPrevPoint, yPrevPoint = curX-xOffsetPrev, curY-yOffsetPrev
end
newPath:moveTo( curX, curY )
newPath:lineTo( xNextPoint, yNextPoint )
newPath:lineTo( xPrevPoint, yPrevPoint )
newPath:closePath()
if ctx.lineJoin == 'round' then
if diff > math.pi then
newPath:moveTo( xNextPoint, yNextPoint )
newPath:addPath( p.Path2D( string.format(
"A %.8g %.8g 0 0 1 %.8g %.8g",
r0(offset),
r0(offset),
r0(xPrevPoint),
r0(yPrevPoint)
)))
else
newPath:moveTo( xPrevPoint, yPrevPoint )
newPath:addPath( p.Path2D( string.format(
"A %.8g %.8g 0 0 1 %.8g %.8g",
r0(offset),
r0(offset),
r0(xNextPoint),
r0(yNextPoint)
)))
end
elseif ctx.lineJoin == 'miter' then
-- We have to determine where the intersection point is
local prevIntercept = yPrevPoint-math.tan(prevAngle)*xPrevPoint
local nextIntercept = yNextPoint-math.tan(nextAngle)*xNextPoint
local xIntersect = (nextIntercept-prevIntercept)/(math.tan(prevAngle)-math.tan(nextAngle))
local yIntersect = math.tan(prevAngle)*xIntersect + prevIntercept
local maxMiter = ctx.miterLimit*offset
-- FIXME I'm a bit confused by the definition of miter length in spec, so not
-- sure if this is right.
if math.sqrt( (curX-xIntersect)*(curX-xIntersect)+(curY-yIntersect)*(curY-yIntersect) ) < maxMiter then
if xIntersect > xPrevPoint then
newPath:moveTo( xPrevPoint, yPrevPoint )
newPath:lineTo( xNextPoint, yNextPoint )
newPath:lineTo( xIntersect, yIntersect )
newPath:closePath()
else
newPath:moveTo( xNextPoint, yNextPoint )
newPath:lineTo( xPrevPoint, yPrevPoint )
newPath:lineTo( xIntersect, yIntersect )
newPath:closePath()
end
end
end
end
local function drawLineCap( newPath, lineCap, angle, curX, curY )
if angle == nil then
mw.log( "nil angle. Possibly a bug in canvas" )
return
end
if lineCap == 'butt' then
-- do nothing
return
elseif lineCap == 'square' then
local endPointX = curX + math.cos(angle)*offset
local endPointY = curY + math.sin(angle)*offset
draw( newPath, curX, endPointX, curY, endPointY )
elseif lineCap == 'round' then
local startPtX = curX + math.cos(angle+deg90)*offset
local startPtY = curY + math.sin(angle+deg90)*offset
local endPtX = curX - math.cos(angle+deg90)*offset
local endPtY = curY - math.sin(angle+deg90)*offset
newPath:moveTo( endPtX, endPtY )
-- A rx ry x-axis-rotation large-arc-flag sweep-flag(clockwise) x y
newPath:addPath( p.Path2D( string.format(
"A %.8g %.8g 0 0 1 %.8g %.8g",
r0(offset),
r0(offset),
r0(startPtX),
r0(startPtY)
)))
else
error( "Unrecognized lineCap of " .. ctx.lineCap )
end
end
-- Note, its important we always draw clockwise, as CCW can create holes
for type, points in doDashes( ctx, convertToLines( parsePath( ctx._path ) ) ) do repeat
assert( #points == 2, "expected 2 points")
assert( _G.type(points[1]) == 'number' and _G.type(points[2]) == 'number', "Expected points to be numbers" )
if curX-points[#points-1] == 0 and curY-points[#points] == 0 then
-- Zero-length line. Skip
break
end
if type == 'L' then
-- This is probably doing it totally wrong way.
local prevAngle = lastAngle
lastAngle = draw( newPath, curX, points[1], curY, points[2] )
assert( _G.type( lastAngle ) == 'number', 'expected last angle to be a number' )
if startAngle == nil then
startAngle = lastAngle + math.pi
else
-- Having a previous startAngle means that we are drawing a line
-- that connects to a previous line, so we have to join it.
-- TODO this seems to get incorrectly called on first line of subpath.
drawLineJoin( prevAngle, lastAngle, curX, curY )
end
curX = points[1]
curY = points[2]
elseif type == 'M' then
if curX ~= startX or curY ~= startY then
-- We are at the end of a subpath so need to draw a line ending
drawLineCap( newPath, ctx.lineCap, lastAngle, curX, curY )
drawLineCap( newPath, ctx.lineCap, startAngle, startX, startY )
elseif startAngle ~= nil then
-- Closed path and we drew at least one thing.
drawLineJoin( startAngle, lastAngle, curX, curY )
end
angle = nil
curX = points[1]
curY = points[2]
startX = points[1]
startY = points[2]
startAngle = nil
else
-- Should be impossible to reach
error( "Unexpected path command" )
end
until true end
-- draw caps for final line segment.
if curX ~= startX or curY ~= startY then
-- We are at the end of a subpath so need to draw a line ending
drawLineCap( newPath, ctx.lineCap, lastAngle, curX, curY )
drawLineCap( newPath, ctx.lineCap, startAngle, startX, startY )
elseif startAngle ~= nil then
-- Closed path and we drew at least one thing.
drawLineJoin( startAngle, (lastAngle+math.pi) % (math.pi*2), curX, curY )
end
ctx.fillStyle = ctx.strokeStyle
ctx:fill(newPath)
ctx:restore()
end
methodtable.createWikitextPattern = function( ctx, args )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
if type( args ) == 'string' then
args = { background = args }
end
local res = {
background = args.background or 'transparent',
class = args.class or nill,
style = args.style or nill,
content = args.content or '', -- should this be parsed?
offsetx = args.offsetx or 0, -- FIXME this should be removed ??
offsety = args.offsety or 0,
attr = args.attr or nil
}
return res
end
methodtable.drawImage = function( ctx, image, sx, sy, sw, sh, dx, dy, dw, dh )
-- FIXME this doesn't work properly yet
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
if image == nil then
return
end
if type( image ) == 'string' then
image = mw.title.new( image, 6 )
end
if ( not image:inNamespace( 6 ) ) or (not image.file.exists) then
return
end
if sx == nil then
sx = 0
end
if sy == nil then
sx = 0
end
if sw == nil then
sw = image.file.width
end
if sh == nil then
sh = image.file.height
end
if dx == nil then
dx = sx
end
if dy == nil then
dy = sy
end
if dw == nil then
dw = sw
end
if dh == nil then
dh = sh
end
ctx:save()
-- FIXME, this is broken and doesn't work right for all arg types
local img
if image.file.width > image.file.height then
img = '[[File:' .. image.text .. '|' .. dw .. 'px' .. '|link=]]'
else
img = '[[File:' .. image.text .. '|x' .. dh .. 'px' .. '|link=]]'
end
-- FIXME doesn't work with negative values properly
local clip = 'path("M ' .. sx .. ' ' .. sy .. ' L ' .. (sw+sx) .. ' ' .. sy .. ' L ' .. (sw+sx) .. ' ' .. (sh+sy) .. ' L ' .. sx .. ' ' .. (sh+sy) ..' )'
-- Note in timeless, if the image is linked, then there are css rules that resize it which we don't want.
img = '<div style="position:relative;left:' .. (-sx) .. 'px;top:' .. (-sy) .. 'px;clip-path:' .. clip .. '">' .. img .. '</div>'
ctx.fillStyle = ctx:createWikitextPattern{
--offsetx = dx,
offsetx = 0,
offsety = 0,
--offsety = dy,
content = img
}
ctx:fillRect( dx, dy, dw, dh )
ctx:restore()
end
isFinite = function( n )
return n > -math.huge and n < math.huge
end
methodtable.fillRect = function( ctx, x, y, w, h )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
if not isFinite( x ) or not isFinite( y ) or not isFinite( w ) or not isFinite( h ) or w == 0 or h == 0 then
return
end
local oldPath = ctx._path
ctx:beginPath()
ctx:rect( x, y, w, h )
ctx:fill()
ctx:beginPath()
ctx._path = oldPath
end
methodtable.clearRect = function( ctx, x, y, w, h )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
-- FXIME, this should make it transparent to html below canvas, not just be white.
ctx:save()
ctx.fillStyle = "var(--background-color-base, '#fff')"
ctx:fillRect( x, y, w, h )
ctx:restore()
end
local doText = function( ctx, text, x, y, maxWidth, stroke )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
assert( maxWidth == nil, "maxWidth parameter to fillText is not supported" )
ctx:save()
local oldStyle = stroke and ctx.strokeStyle or ctx.fillStyle
-- Maybe complex background is possible here with mix-blend-mode: screen. Main issue is it seems like
-- we couldn't make the non-text part be transparent.
assert( type(oldStyle) == 'string', 'Complex backgrounds for fillText() not currently supported' )
text = string.gsub( text, "[\n\r\t\f]", " " ) -- per spec, replace whitespace (Note: we use white-space: pre css)
local textLayer = mw.html.create( 'div' )
:css( 'position', 'absolute' )
:css( 'width', 'max-content' )
:css( 'font', ctx.font )
:css( 'text-rendering', ctx.textRendering )
:css( 'font-kerning', ctx.fontKerning )
:css( 'font-stretch', ctx.fontStretch )
:css( 'font-variant-caps', ctx.fontVariantCaps )
:css( 'letter-spacing', ctx.letterSpacing )
:css( 'word-spacing', ctx.wordSpacing )
:css( 'text-align', 'left' )
:css( 'white-space', 'pre' )
:wikitext( text ) -- FIXME should we escape
if ctx.direction ~= 'inherit' then
textLayer:attr( 'dir', ctx.direction )
end
if ctx.textBaseline == 'alphabetic' or ctx.textBaseline == 'bottom' then
-- This isn't 100% right for alphabetic, but it is the default and this is close
textLayer:css( 'bottom', 'calc( 100% - ' .. y .. 'px' .. ' )' )
elseif ctx.textBaseline == 'top' then
textLayer:css( 'top', y .. 'px' )
else
-- We can approximate some values, but better to just give an error.
error( "Unsupported value for textBaseline: " .. ctx.textBaseline )
end
-- not perfect, as its supposed to inherit from containing element
local realDir = ctx.direction == 'inherit' and mw.getContentLanguage():getDir() or ctx.direction
local realAlign = 'left'
if ctx.textAlign == 'start' and realDir == 'rtl' then
realAlign = 'right'
elseif ctx.textAlign == 'end' and realDir == 'ltr' then
realAlign = 'right'
end
if ctx.textAlign == 'center' then
textLayer:css( 'width', ctx._width .. 'px' )
textLayer:css( 'left', (x-ctx._width)/2 .. 'px' )
textLayer:css( 'text-align', 'center' )
elseif realAlign == 'left' then
textLayer:css( 'left', x .. 'px' )
else
textLayer:css( 'right', 'calc( 100% - ' .. x .. 'px )' )
end
local style = 'color:' .. oldStyle
if stroke then
style = 'color: transparent; -webkit-text-stroke-color: ' .. oldStyle .. ';text-stroke-color:' .. oldStyle ..
'; -webkit-text-stroke-width:' .. ctx.lineWidth .. 'px; text-stroke-width:' .. ctx.lineWidth .. 'px;'
end
ctx.fillStyle = ctx:createWikitextPattern{
style = style,
content = textLayer
}
local op = newOperation( ctx, 'text' )
table.insert( ctx.__operations, op )
ctx:restore()
end
methodtable.fillText = function( ctx, text, x, y, maxWidth )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
ctx:fillTextRaw( mw.text.nowiki( text ), x, y, maxWidth )
end
-- For use if you want to include wikitext. Note you still need to use frame:preprocess before calling this.
methodtable.fillTextRaw = function( ctx, text, x, y, maxWidth )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
doText( ctx, text, x, y, maxWidth, false )
end
-- For use if you want to include wikitext. Note you still need to use frame:preprocess before calling this.
methodtable.strokeTextRaw = function( ctx, text, x, y, maxWidth )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
doText( ctx, text, x, y, maxWidth, true )
end
methodtable.strokeText = function( ctx, text, x, y, maxWidth )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
ctx:strokeTextRaw( mw.text.nowiki( text ), x, y, maxWidth )
end
methodtable.save = function (ctx)
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
-- Note, path is included in operation, however it is not part of save state.
table.insert( ctx.__stateStack, newOperation( ctx, 'save' ) )
end
methodtable.restore = function(ctx)
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
if #ctx.__stateStack == 0 then
-- spec says silently ignore if no saved state
return
end
op = table.remove( ctx.__stateStack )
ctx._currentTransform = op._currentTransform
ctx.lineWidth = op.lineWidth
ctx.lineCap = op.lineCap
ctx.lineJoin = op.lineJoin
ctx.miterLimit = op.miterLimit
ctx.lineDashOffset = op.lineDashOffset
ctx.font = op.font
ctx.textAlign = op.textAlign
ctx.textBaseline = op.textBaseline
ctx.direction = op.direction
ctx.letterSpacing = op.letterSpacing
ctx.fontKerning = op.fontKerning
ctx.fontStretch = op.fontStretch
ctx.fontVariantCaps = op.fontVariantCaps
ctx.textRendering = op.textRendering
ctx.wordSpacing = op.wordSpacing
ctx.fillStyle = op.fillStyle
ctx.strokeStyle = op.strokeStyle
ctx.shadowBlur = op.shadowBlur
ctx.shadowColor = op.shadowColor
ctx.shadowOffsetX = op.shadowOffsetX
ctx.shadowOffsetY = op.shadowOffsetY
ctx.globalAlpha = op.globalAlpha
ctx.globalCompositeOperation = op.globalCompositeOperation
ctx.imageSmoothingEnabled = op.imageSmoothingEnabled
ctx.imageSmoothingQuality = op.imageSmoothingQuality
ctx.canvas = op.canvas
ctx.filter = op.filter
end
methodtable.reset = function (ctx)
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
setDefaults( ctx )
end
methodtable.isContextLost = function( t )
return false
end
methodtable.getContextAttributes = function( ctx )
assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
return {
width = ctx._width,
height = ctx._height,
containerClass = ctx._containerClass,
containerStyle = ctx._containerStyle,
alpha = ctx._alpha
}
end
local getBlendMode = function ( compositeOp )
-- css mix-blend-mode only supports blending not composite operators (except plus-darker and plus-lighter)
-- So we don't support the following values: clear | copy | source-over | destination-over | source-in |
-- destination-in | source-out | destination-out | source-atop |
-- destination-atop | xor | lighter
-- Also this doesn't work for images properly as images have isolated blend modes.
validOps = {
normal = true,
multiply = true,
screen = true,
overlay = true,
darken = true,
lighten = true,
["color-dodge"] = true,
["color-burn"] = true,
["hard-light"] = true,
["soft-light"] = true,
difference = true,
exclusion = true,
hue = true,
saturation = true,
color = true,
luminosity = true,
['plus-darker'] = true,
['plus-lighter'] = true,
}
if validOps[compositeOp] then
return compositeOp
end
return "normal"
end
local getTransform = function( t )
local res = 'matrix(' .. t[1] .. ',' .. t[2] .. ',' .. t[3] .. ',' .. t[4] .. ',' .. t[5] .. ',' .. t[6] .. ')'
if res == 'matrix(1,0,0,1,0,0)' then
return 'none'
end
return res
end
-- TODO this is a hack that doesn't really work.
local getAdjustedWidth = function( w, h, t )
-- ( a x + c y + e , b x + d y + f )
-- we should really invert the matrix instead of this hack
return math.abs(math.ceil(w*w/(w*t[1]+h*t[3])+math.abs(t[5])))
end
local getAdjustedHeight = function( w, h, t )
-- ( a x + c y + e , b x + d y + f )
-- we should really invert the matrix instead of this hack
return math.abs(math.ceil(h*h/(w*t[2]+h*t[4])+math.abs(t[6])))
end
local getFilter = function( op )
if op.shadowColor == 'transparent' then
if op.filter == 'none' then
return nil
end
return op.filter
end
local shadow = " drop-shadow(" .. op.shadowColor .. ' ' .. op.shadowOffsetX .. 'px '
.. op.shadowOffsetY .. 'px ' .. op.shadowBlur .. 'px)'
if op.filter == 'none' then
return shadow
end
return op.filter .. shadow
end
-- Lightweight SVG attribute parser / renderer
local function trim(s) return (s and s:gsub("^%s*(.-)%s*$","%1")) end
local function parseAttrs(attrStr)
local attrs = {}
if not attrStr then return attrs end
for k,v in string.gmatch(attrStr, '(%w+)%s*=%s*"([^"]*)"') do attrs[k] = v end
for k,v in string.gmatch(attrStr, "(%w+)%s*=%s*'([^']*)'") do attrs[k] = v end
-- inline style
if attrs.style then
for prop,val in string.gmatch(attrs.style, "([%w-]+)%s*:%s*([^;]+)") do
attrs[prop] = trim(val)
end
end
return attrs
end
local function parseNum(s, fallback)
if s == nil then return fallback end
-- strip units like px, %
s = tostring(s):gsub("px","")
local n = tonumber(s)
return n == nil and fallback or n
end
local function applyTransformString(ctx, tstr)
if not tstr then return end
-- Apply common transforms; caller must ctx:save() / ctx:restore()
for fn, args in string.gmatch(tstr, '(%a+)%s*%(([^)]*)%)') do
local parts = {}
for v in string.gmatch(args, '[-+]?%d*%.?%d+[%a%%]*') do
local s = v:gsub("px",""):gsub("deg",""):gsub("°",""):gsub("%%","")
parts[#parts+1] = tonumber(s) or 0
end
if fn == 'translate' then
ctx:translate(parts[1] or 0, parts[2] or 0)
elseif fn == 'rotate' then
local a = (parts[1] or 0) * math.pi / 180
if parts[2] and parts[3] then
ctx:translate(parts[2], parts[3])
ctx:rotate(a)
ctx:translate(-(parts[2]), -(parts[3]))
else
ctx:rotate(a)
end
elseif fn == 'scale' then
ctx:scale(parts[1] or 1, parts[2] or parts[1] or 1)
elseif fn == 'matrix' and #parts >= 6 then
ctx:transform(parts[1], parts[2], parts[3], parts[4], parts[5], parts[6])
end
end
end
-- Accepts svg markup (string) and a ctx (optional). If ctx is nil, a default 300x300 ctx is created.
-- Supports: rect, circle, ellipse, path, line, polyline, polygon. Basic fill/stroke/opacity/transform/style parsing.
p.drawSVG = function(svgString, ctx)
assert(type(svgString) == 'string', "svgString must be a string")
if not ctx then ctx = p.getContext('2d', { width=300, height=300 }) end
-- Optional: read svg width/height/viewBox to size ctx
local svgAttrs = parseAttrs( string.match(svgString, "<svg([^>]*)>") or "" )
if svgAttrs.width then ctx._width = parseNum(svgAttrs.width, ctx._width) end
if svgAttrs.height then ctx._height = parseNum(svgAttrs.height, ctx._height) end
if svgAttrs.viewBox then
-- leave as-is; user can scale transforms if needed
end
for tag, attrStr in string.gmatch(svgString, "<(%w+)([^>]*)/?>") do
local a = parseAttrs(attrStr)
-- common style values
local fill = a.fill or a["fill"] or a["fill-opacity"] -- sometimes style gives fill directly
local stroke = a.stroke
local opacity = tonumber(a.opacity) or tonumber(a["fill-opacity"]) or 1
-- if style keys present, they have been added to a by parseAttrs
if tag == "rect" then
local x = parseNum(a.x, 0)
local y = parseNum(a.y, 0)
local w = parseNum(a.width, 0)
local h = parseNum(a.height, 0)
ctx:save()
if a.transform then applyTransformString(ctx, a.transform) end
if a.opacity then ctx.globalAlpha = ctx.globalAlpha * tonumber(a.opacity) end
if fill and fill ~= "none" then
ctx:beginPath()
if a.rx and tonumber(a.rx)>0 then
-- roundRect is implemented on Path2D; simulate by path commands
local r = tonumber(a.rx)
ctx:rect(x,y,w,h) -- fallback; fine for many cases
else
ctx:rect(x,y,w,h)
end
ctx.fillStyle = fill or ctx.fillStyle
ctx:fill()
end
if stroke and stroke ~= "none" then
ctx.strokeStyle = stroke
ctx:stroke()
end
ctx:restore()
elseif tag == "circle" then
local cx = parseNum(a.cx,0)
local cy = parseNum(a.cy,0)
local r = parseNum(a.r,0)
ctx:save()
if a.transform then applyTransformString(ctx, a.transform) end
ctx:beginPath()
ctx:arc(cx, cy, r, 0, math.pi*2)
if fill and fill ~= "none" then
ctx.fillStyle = fill
ctx:fill()
end
if stroke and stroke ~= "none" then
ctx.strokeStyle = stroke
ctx:stroke()
end
ctx:restore()
elseif tag == "ellipse" then
local cx = parseNum(a.cx,0)
local cy = parseNum(a.cy,0)
local rx = parseNum(a.rx,0)
local ry = parseNum(a.ry,0)
ctx:save()
if a.transform then applyTransformString(ctx, a.transform) end
-- fallback: scale circle
ctx:beginPath()
if ctx.ellipse then
-- If ellipse method exists, use it
ctx:ellipse(cx, cy, rx, ry, 0, 0, math.pi*2)
else
-- approximate via path arc with scale transform
ctx:translate(cx, cy)
ctx:scale(1, ry/rx)
ctx:arc(0, 0, rx, 0, math.pi*2)
ctx:resetTransform()
end
if fill and fill ~= "none" then ctx.fillStyle = fill; ctx:fill() end
if stroke and stroke ~= "none" then ctx.strokeStyle = stroke; ctx:stroke() end
ctx:restore()
elseif tag == "path" then
local d = a.d or ""
ctx:save()
if a.transform then applyTransformString(ctx, a.transform) end
if a.opacity then ctx.globalAlpha = ctx.globalAlpha * tonumber(a.opacity) end
if fill and fill ~= "none" then
ctx.fillStyle = fill
ctx:fill( p.Path2D(d) )
end
if stroke and stroke ~= "none" then
ctx.strokeStyle = stroke
ctx:stroke( p.Path2D(d) )
end
ctx:restore()
elseif tag == "line" then
local x1 = parseNum(a.x1,0)
local y1 = parseNum(a.y1,0)
local x2 = parseNum(a.x2,0)
local y2 = parseNum(a.y2,0)
ctx:save()
if a.transform then applyTransformString(ctx, a.transform) end
ctx:beginPath()
ctx:moveTo(x1,y1)
ctx:lineTo(x2,y2)
if stroke and stroke ~= "none" then ctx.strokeStyle = stroke; ctx:stroke() end
ctx:restore()
elseif tag == "polyline" or tag == "polygon" then
local points = a.points or ""
local pts = {}
for x,y in string.gmatch(points, "([%-%d%.]+)[, ]+([%-%d%.]+)") do
pts[#pts+1] = tonumber(x); pts[#pts+1] = tonumber(y)
end
if #pts >= 2 then
ctx:save()
if a.transform then applyTransformString(ctx, a.transform) end
ctx:beginPath()
ctx:moveTo(pts[1], pts[2])
for i=3,#pts,2 do ctx:lineTo(pts[i], pts[i+1]) end
if tag == "polygon" then ctx:closePath() end
if fill and fill ~= "none" then ctx.fillStyle = fill; ctx:fill() end
if stroke and stroke ~= "none" then ctx.strokeStyle = stroke; ctx:stroke() end
ctx:restore()
end
else
-- unknown tag: ignore (svg groups, gradients etc. are unsupported in this lightweight parser)
end
end
return ctx
end
methodtable.getWikitext = function(ctx)
assert(type(ctx) == 'table' and type(ctx.__operations) == 'table', "First argument must be a CanvasRenderingContext2D")
local container = mw.html.create('div')
:css('position', 'relative')
:css('width', (ctx._width or 300) .. 'px')
:css('height', (ctx._height or 300) .. 'px')
if ctx._containerClass then container:addClass(ctx._containerClass) end
if ctx._containerStyle then container:attr('style', ctx._containerStyle) end
-- create a layer for each operation (simple model: fills/text)
for _, op in ipairs(ctx.__operations) do
-- only handle fill and text for now
if op.name == 'fill' or op.name == 'text' then
local fillPattern = op.fillStyle
local layer = mw.html.create('div')
:css('position', 'absolute')
:css('left', '0px')
:css('top', '0px')
:css('width', (ctx._width or 300) .. 'px')
:css('height', (ctx._height or 300) .. 'px')
:css('filter', getFilter(op) or '')
:css('mix-blend-mode', getBlendMode(op.globalCompositeOperation))
:css('opacity', op.globalAlpha)
-- apply transform if present
if op._currentTransform then
local tf = getTransform(op._currentTransform)
if tf ~= 'none' then
layer:css('transform', tf)
layer:css('transform-origin', 'top left')
end
end
-- clipping by path if available
if op._path and op._path ~= '' then
-- CSS clip-path path() expects an SVG path string. Wrap in path("").
-- Escape double quotes in path
local safePath = tostring(op._path):gsub('"','\\"')
layer:css('clip-path', 'path("' .. safePath .. '")')
layer:css('pointer-events', 'none')
end
-- If fillStyle is a pattern table (createWikitextPattern), use its content
if type(fillPattern) == 'table' and fillPattern.content then
-- inner container to hold content, using provided style/background/class
local inner = mw.html.create('div')
:cssText(fillPattern.style or '')
:addClass(fillPattern.class or '')
:attr(fillPattern.attr or {})
:css('width', '100%')
:css('height', '100%')
:css('background-color', fillPattern.background or 'transparent')
if type(fillPattern.content) == 'string' then
inner:wikitext(fillPattern.content)
else
inner:node(fillPattern.content)
end
layer:node(inner)
elseif type(fillPattern) == 'string' then
-- simple solid color
layer:css('background-color', fillPattern)
else
-- nothing to render for this op
end
container:node(layer)
else
-- ignore unsupported operations for now
end
end
return tostring(container)
end
return p
Skip to content