This is an implementation of the 2D canvas drawing API using just CSS.
local canvas = require( "Module:Sandbox/Bawolff/canvas" ) local ctx = canvas.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 = canvas.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)
Makes:
This is a bit of an experiment, so it doesn't fully work. However in theory I believe most of the canvas API could be implemented.
The largest current limitation is that you cannot :stroke() curves only line segments.
Parts still TODO (but sound do-able)
Parts not supported which might in theory be possible but would probably be high effort
The parts that i think are impossible to implement:
I suspect everything else could be implemented given enough effort.
-- 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 .. '|' .. 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
--[[
]]
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