Module:Sandbox/Bawolff/canvas

-- The {{qif}} of drawing pictures.

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.demo( frame )

local ctx = p.getContext( '2d', { width=600, height=600 } )

-- draw some eyes

ctx:moveTo( 280, 100 )

ctx:quadraticCurveTo( 290, 110, 280, 120 )

ctx:quadraticCurveTo( 270, 110, 280, 100 )

ctx:moveTo( 320, 100 )

ctx:quadraticCurveTo( 330, 110, 320, 120 )

ctx:quadraticCurveTo( 310, 110, 320, 100 )

ctx:fill()

-- A mouth

ctx.fillStyle = 'pink'

ctx:beginPath()

ctx:moveTo( 250, 160 )

ctx:bezierCurveTo( 260, 200, 340, 185, 350, 160 )

ctx:bezierCurveTo( 340, 180, 260, 180, 250, 160 )

ctx:fill()

-- You can also use SVG paths. Taken from Example.svg

ctx:setTransform( 6.3951354,0,0,6.3951354,-22.626246,-7.1082509 )

local path = p.Path2D( "M 17.026327,63.789847 C 0.7506376,64.058469 13.88279,66.387154 13.113883,69.323258 C 8.0472417,70.287093 3.5936285,63.565714 6.8090451,59.370548 C 8.7591553,55.717791 15.269922,55.198361 16.902068,59.393261 C 17.532581,60.758947 17.628237,62.396589 17.026327,63.789847 z M 15.306463,62.656109 C 18.852566,58.713773 7.6543584,56.609143 10.765803,61.304742 C 12.124789,62.217715 13.961359,61.705342 15.306463,62.656109 z M 31.307931,62.391383 C 27.130518,63.524026 24.669863,68.663004 27.470717,72.229472 C 25.946657,74.052316 24.253697,71.076237 24.857281,69.636909 C 23.737444,67.038428 17.399862,72.254246 19.386636,68.888657 C 23.159719,67.551193 22.398496,63.711301 22.06067,60.848671 C 24.064085,60.375294 24.370376,65.772689 27.167918,63.326048 C 28.350126,62.546369 29.927362,61.067531 31.307931,62.391383 z M 37.66875,70.598623 C 33.467314,66.62264 32.517064,77.972723 37.30626,74.466636 C 38.742523,73.853608 40.55904,70.38932 37.66875,70.598623 z M 41.677321,70.973131 C 42.340669,75.308182 36.926157,78.361257 33.331921,76.223155 C 29.43435,74.893988 30.618698,67.677232 35.003806,68.567885 C 37.137393,70.592854 42.140265,67.002221 37.656192,66.290007 C 35.242233,65.914214 35.166503,62.640757 38.036954,63.926404 C 40.847923,64.744926 43.227838,68.124735 41.677321,70.973131 z M 62.379099,76.647079 C 62.007404,78.560417 61.161437,84.034535 58.890565,82.010019 C 59.769679,79.039958 62.536382,72.229115 56.947899,72.765789 C 53.790416,73.570863 54.908257,80.968388 51.529286,79.496859 C 51.707831,76.559817 55.858125,71.896837 50.8321,70.678504 C 45.898113,69.907818 47.485944,75.735824 45.286883,78.034703 C 42.916393,76.333396 45.470823,71.647155 46.624124,69.414735 C 50.919507,67.906486 63.618534,70.878704 62.379099,76.647079 z M 66.426447,83.84905 C 67.616398,85.777591 62.114624,94.492698 62.351124,90.31711 C 63.791684,86.581961 65.730376,78.000636 67.391891,74.85575 C 71.027815,73.781175 76.383067,75.350289 76.591972,79.751898 C 77.048545,83.793048 73.066803,88.429945 68.842187,86.765936 C 67.624386,86.282034 66.56741,85.195132 66.426447,83.84905 z M 74.086569,81.803435 C 76.851893,78.050524 69.264402,74.310256 67.560734,78.378191 C 65.893402,80.594099 67.255719,83.775746 69.700555,84.718558 C 72.028708,85.902224 73.688639,83.888662 74.086569,81.803435 z M 82.318799,73.124577 C 84.30523,75.487059 81.655015,88.448086 78.247183,87.275736 C 78.991935,82.387828 81.291029,77.949394 82.318799,73.124577 z M 95.001985,87.684695 C 78.726298,87.953319 91.858449,90.281999 91.089542,93.218107 C 86.0229,94.18194 81.569287,87.460562 84.784701,83.265394 C 86.734814,79.612637 93.245582,79.09321 94.877729,83.28811 C 95.508245,84.653796 95.603892,86.291438 95.001985,87.684695 z M 93.282122,86.550957 C 96.828223,82.608621 85.630017,80.503993 88.741461,85.199592 C 90.100447,86.112565 91.937018,85.600192 93.282122,86.550957 z " )

ctx.fillStyle = 'red'

ctx:beginPath()

ctx:fill(path)

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( ctx._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-curX))-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 .. ''

else

img = 'File:' .. image.text .. ''

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 = '

' .. img .. '
'

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

--[[

]]

methodtable.getWikitext = function( ctx )

local container = mw.html.create( 'div' )

container:attr( "role", 'presentation' )

:attr( "aria-hidden", "true" ) -- not sure if this is right

:css( 'width', ctx._width .. 'px' )

:css( 'height', ctx._height .. 'px' )

:css( 'overflow', 'hidden' )

:css( 'position', 'relative' )

:cssText( ctx._containerStyle )

:addClass( ctx._containerClass )

if ctx._alpha == false then

container:css( 'isolation', 'isolate' )

end

local layers = ''

for i, op in ipairs( ctx.__operations) do

if op['name'] == 'fill' or op['name'] == 'text' then

local fillPattern = ctx:createWikitextPattern( op.fillStyle )

local layer = mw.html.create( 'div' )

:cssText( fillPattern.style )

:addClass( fillPattern.class )

:attr( fillPattern.attr or {} ) -- FIXME should class and attr be set on inner div instead?

:css( 'width', getAdjustedWidth( ctx._width, ctx._height, op._currentTransform ) .. 'px' ) -- Should this be adjusted based on fillPattern?

:css( 'height', getAdjustedHeight( ctx._width, ctx._height, op._currentTransform ) .. 'px' )

:css( 'left', fillPattern.offsetx ) -- FIXME i think this is wrong

:css( 'top', fillPattern.offsety )

:css( 'position', 'absolute' )

:css( 'filter', getFilter(op) )

:css( 'mix-blend-mode', getBlendMode(op.globalCompositeOperation))

:css( 'opacity', op.globalAlpha )

:css( 'transform', getTransform( op._currentTransform ) )

:css( 'transform-origin', 'top left' )

:css( 'pointer-events', 'none' ) -- Make sure we pass :hover to layer below

:tag( 'div' )

:css( 'width', '100%' )

:css( 'height', '100%' )

-- FIXME this isn't really right. Clear should be transparent to the non-canvas background

:css( 'background-color', op.globalCompositeOperation == 'clear' and "var(--background-color-base, '#fff')" or fillPattern.background )

:css( 'color', 'inherit' ) -- Hack for night mode

:css( 'clip-path', op['name'] == 'fill' and 'path(' .. op._fillRule .. ', \'' .. op._path .. '\')' or 'none' )

:css( 'pointer-events', 'all' )

:wikitext( tostring(fillPattern.content) )

:allDone()

if op.globalCompositeOperation == "source-over"

or op.globalCompositeOperation == 'clear'

or op.globalCompositeOperation == 'normal'

or getBlendMode(op.globalCompositeOperation) ~= 'normal'

then

layers = layers .. tostring( layer )

elseif op.globalCompositeOperation == 'destination-over' then

layers = tostring( layer ) .. layers

else

error( "Unsupported globalCompositeOperation " .. op.globalCompositeOperation )

end

else

error( "unsupported operation " .. v['name'] )

end

end

container:wikitext( layers )

return tostring( container )

end

return p