Fandom Developers Wiki

local p = {}
local metatable = {}
local methodtable = {}
local create2dContext
local setDefaults
local isFinite

function p.getContext( contextType, contextAttributes )
	if contextType == '2d' then
		ctx = create2dContext()

		if type( contextAttributes ) == 'table' then
			if contextAttributes.width ~= nil then
				ctx._width = tonumber(contextAttributes.width)
			end
			if contextAttributes.height ~= nil then
				ctx._height = tonumber(contextAttributes.height)
			end
			if type( contextAttributes.containerClass ) == 'string' then
				ctx._containerClass = contextAttributes.containerClass
			end
			if type( contextAttributes.containerStyle ) == 'string' then
				ctx._containerStyle = contextAttributes.containerStyle
			end
			if contextAttributes.alpha == false then
				ctx._alpha = false
			end
		end
		return ctx
	end
	error( "Unsupported context" )
end

function p.render( frame )
    -- Allow calling with full <svg> via template:
    --   {{#invoke:M_SVG2CSS|render|<nowiki><svg>...</svg></nowiki>|width=124|height=124}}
    local width = (type(frame) == 'table' and tonumber(frame.args.width)) or 600
    local height = (type(frame) == 'table' and tonumber(frame.args.height)) or 600
    local ctx = p.getContext( '2d', { width = width, height = height } )

    -- prefer svg passed in through frame args
    local svg = nil
    if type(frame) == 'table' and frame.args then
        svg = frame.args.svg or frame.args[1]
    end

    -- If the caller passed an HTML-escaped SVG (e.g. "&lt;svg...&gt;") unescape common entities.
    if type(svg) == 'string' then
        -- Normalise common HTML-escaped entities (do it unconditionally to be robust).
        svg = svg
            :gsub("&lt;", "<")
            :gsub("&gt;", ">")
            :gsub("&quot;", '"')
            :gsub("&#39;", "'")
            :gsub("&amp;", "&")
        -- Extract the first <svg ...>...</svg> block if caller included extra wiki markup
        local extracted = svg:match("(%<svg.-%</svg%>)")
        if extracted then svg = extracted end
        svg = svg:match("^%s*(.-)%s*$") or svg
    end

    if svg and svg ~= '' then
        p.drawSVG(svg, ctx)
        return tostring(ctx)
    end

    -- No svg provided: render built-in test SVG
    local testSvg = [[
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="124" viewBox="0 0 124 124" fill="none">
  <rect width="124" height="124" rx="24" fill="#F97316"/>
  <path d="M19.375 36.7818V100.625C19.375 102.834 21.1659 104.625 23.375 104.625H87.2181C90.7818 104.625 92.5664 100.316 90.0466 97.7966L26.2034 33.9534C23.6836 31.4336 19.375 33.2182 19.375 36.7818Z" fill="white"/>
  <circle cx="63.2109" cy="37.5391" r="18.1641" fill="black"/>
  <rect opacity="0.4" x="81.1328" y="80.7198" width="17.5687" height="17.3876" rx="4" transform="rotate(-45 81.1328 80.7198)" fill="#FDBA74"/>
</svg>]]
    p.drawSVG(testSvg, ctx)
    return tostring(ctx)
end

-- Round to 0. To prevent 1.13132e-14 from showing up.
local function r0(x)
	if math.abs(x) < 1e-4 then
		return 0
	end
	return x
end

local function normalizeAngle( angle )
	return ((angle % (math.pi*2)) + math.pi*2) % (math.pi*2)
end

metatable.__index = methodtable

metatable.__tostring = function( t )
	return t:getWikitext()	
end

local pathmethods = {}
local pathmeta = {}
pathmeta.__index = pathmethods
setmetatable( methodtable, pathmeta )


function create2dContext()
	local ctx = {}
	setmetatable( ctx, metatable )

	ctx._width = 300 
	ctx._height = 300
	ctx._containerClass = nil
	ctx._containerStyle = nil
	ctx._alpha = true
	
	-- Default values
	setDefaults( ctx )
	return ctx
end

setDefaults = function( ctx )
	ctx.__stateStack = {}
	ctx.__operations = {}

	ctx._currentTransform = { 1, 0, 0, 1, 0, 0 }
	ctx._path = ""
	ctx._fillRule = "nonzero"
	ctx._lineDash = {}
	ctx.lineWidth = 1.0
	ctx.lineCap = 'butt'
	ctx.lineJoin = 'miter'
	ctx.miterLimit = 10
	ctx.lineDashOffset = 0.0
	ctx.font = "10px sans-serif"
	ctx.textAlign = 'start'
	ctx.textBaseline = 'alphabetic'
	ctx.direction = 'inherit'
	ctx.letterSpacing = '0px'
	ctx.fontKerning = 'auto'
	ctx.fontStretch = 'normal'
	ctx.fontVariantCaps = 'normal'
	ctx.textRendering = 'auto'
	ctx.wordSpacing = '0px'
	ctx.fillStyle = '#000'
	ctx.strokeStyle = '#000'
	ctx.shadowBlur = 0
	ctx.shadowColor = 'rgb(0 0 0 / 0%)'
	ctx.shadowOffsetX = 0
	ctx.shadowOffsetY = 0
	ctx.globalAlpha = 1.0
	ctx.globalCompositeOperation = "source-over"
	ctx.imageSmoothingEnabled = true
	ctx.imageSmoothingQuality = "low"
	ctx.canvas = nil
	ctx.filter = "none"
	return ctx
end

local newOperation = function( t, operation )
	op = {}
	op.name = operation
	op._path = t._path
	op._currentTransform = mw.clone(t._currentTransform)
	op._fillRule = t._fillRule
	op._lineDash = t._lineDash

	op.lineWidth = t.lineWidth 
	op.lineCap = t.lineCap 
	op.lineJoin = t.lineJoin 
	op.miterLimit = t.miterLimit 
	op.lineDashOffset = t.lineDashOffset 
	op.font = t.font 
	op.textAlign = t.textAlign 
	op.textBaseline = t.textBaseline 
	op.direction = t.direction 
	op.letterSpacing = t.letterSpacing 
	op.fontKerning = t.fontKerning 
	op.fontStretch = t.fontStretch 
	op.fontVariantCaps = t.fontVariantCaps 
	op.textRendering = t.textRendering 
	op.wordSpacing = t.wordSpacing 
	op.fillStyle = t.fillStyle 
	op.strokeStyle = t.strokeStyle 
	op.shadowBlur = t.shadowBlur 
	op.shadowColor = t.shadowColor 
	op.shadowOffsetX = t.shadowOffsetX 
	op.shadowOffsetY = t.shadowOffsetY 
	op.globalAlpha = t.globalAlpha 
	op.globalCompositeOperation = t.globalCompositeOperation 
	op.imageSmoothingEnabled = t.imageSmoothingEnabled 
	op.imageSmoothingQuality = t.imageSmoothingQuality 
	op.canvas = t.canvas 
	op.filter = t.filter
	return op
end

methodtable.setTransform = function( ctx, a, b, c, d, e, f )
	-- last 0 0 1 row is left implied
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	ctx:resetTransform()
	ctx:transform( a, b, c, d, e, f )
end


methodtable.resetTransform = function( ctx )
	-- last 0 0 1 row is left implied
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	ctx._currentTransform = { 1, 0, 0, 1, 0, 0 }
end

methodtable.transform = function( ctx, a, b, c, d, e, f )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	-- Do a matrix multiply
	-- a c e
	-- b d f
	-- 0 0 1

	local oa = ctx._currentTransform[1]
	local ob = ctx._currentTransform[2]
	local oc = ctx._currentTransform[3]
	local od = ctx._currentTransform[4]
	local oe = ctx._currentTransform[5]
	local of = ctx._currentTransform[6]
	
	ctx._currentTransform[1] = a*oa + b*oc
	ctx._currentTransform[3] = c*oa + d*oc
	ctx._currentTransform[5] = e*oa + f*oc + oe
	ctx._currentTransform[2] = a*ob + b*od
	ctx._currentTransform[4] = c*ob + d*od
	ctx._currentTransform[6] = e*ob + f*od + of
	
end

methodtable.scale = function( ctx, x, y )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	assert( type(x) == "number", "x argument to scale must be a number" )
	assert( type(y) == "number", "y argument to scale must be a number" )
	ctx:transform( x, 0, 0, y, 0, 0 )
end

methodtable.rotate = function( ctx, a )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	assert( type(a) == "number", "Argument a to rotate must be number of radians" )
	ctx:transform( math.cos(a), math.sin(a), -math.sin(a), math.cos(a), 0, 0 )
end

methodtable.translate = function( ctx, x, y )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	ctx:transform( 1, 0, 0, 1, x, y )
end

methodtable.setLineDash = function( ctx, dashArray )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	assert( type(dashArray) == 'table', 'dashArray (second arg) should be an array' )

	local newDash = {}
	for i, v in ipairs( dashArray ) do
		if type(v) ~= 'number' or v <= 0 or v == 1/0 or v~=v then
			-- Normally I would throw an error here, but the canvas spec
			-- says you aren't allowed to
			mw.log( "Invalid lineDash set. Ignoring" )
			return
		end
		newDash[#newDash+1] = v
	end
	-- Must always be even.
	if #newDash % 2 == 1 then
		for i, v in ipairs( dashArray ) do
			newDash[#newDash+1] = v
		end
	end
	ctx._lineDash = newDash
end

methodtable.getLineDash = function( ctx, dashArray )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	return ctx._lineDash
end


-- Path2D methods

p.Path2D = function( pathDesc )
	local path = {}
	path._path = ""
	setmetatable( path, pathmeta )
	if type( pathDesc ) == "string" then
		-- Constructor can take an SVG path description
		path._path = pathDesc
	end
	if type( pathDesc ) == 'table' and type( pathDesc._path ) == 'string' then
		-- Constructor can take a Path2D object.
		path._path = pathDesc._path
	end
	return path
end

-- Technically this is only supposed to be on Path2D and not context.
pathmethods.addPath = function( ctx, path, transform )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
	if transform ~= nil then
		error( "transform argument to addPath is not implemented yet" )
	end

	if path == nil or path._path == nil then
		error( "Second argument should be a Path2D object" )
	end
	ctx._path = ctx._path .. " " .. path._path
end

pathmethods.moveTo = function( ctx, x, y )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
	ctx._path = ctx._path .. string.format( "M %.8g %.8g", r0(x), r0(y) )
end

pathmethods.lineTo = function( ctx, x, y )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
	ctx._path = ctx._path .. string.format( "L %.8g %.8g", r0(x), r0(y) )
end

pathmethods.quadraticCurveTo = function( ctx, cx, cy, x, y )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
	ctx._path = ctx._path .. " Q " .. cx .. " " .. cy .. " " .. x .. " " .. y
end

pathmethods.bezierCurveTo = function( ctx, c1x, c1y, c2x, c2y, x, y )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
	ctx._path = ctx._path .. " C " .. c1x .. " " .. c1y .. " " .. c2x .. " " .. c2y .. " " .. x .. " " .. y
end


pathmethods.beginPath = function( ctx )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
	ctx._path = ''
end

pathmethods.closePath = function( ctx )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
	ctx._path = ctx._path .. ' Z'
end

pathmethods.rect = function( ctx, x, y, w, h )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
	if not isFinite( x ) or not isFinite( y ) or not isFinite( w ) or not isFinite( h ) or w == 0 or h == 0 then
		return
	end
	ctx:moveTo( x, y )
	ctx:lineTo( x+w, y )
	ctx:lineTo( x+w, y+h )
	ctx:lineTo( x, y+h )
	ctx:closePath()
end

-- FIXME, behaviour around if a path is closed without calling closePath() is not correct.
-- Draw an arc centered on (x,y). counterClockWise argument is optional and defaults false.
pathmethods.arc = function( ctx, x, y, radius, startAngle, endAngle, counterClockWise )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )

	if radius < 0 then
		error( "IndexSizeError: radius cannot be negative" )
	end

	-- FIXME test the case of a full circle.
	-- Seems like full circle if endAngle-startAngle >= 2pi when CW and startAngle-endAngle >= 2pi when CCW
	-- but not normalized. e.g. if startAngle is 10pi and endAngle is pi, that is full circle in CCW but not CW
	if counterCLockwise == true then
		if endAngle-startAngle >= math.pi*2 then
			startAngle=math.pi*2
			endAngle=0
		end
	else
		if endAngle-startAngle >= math.pi*2 then
			startAngle=math.pi*2
			endAngle=0
		end
	end

	startAngle = normalizeAngle( startAngle )
	endAngle = normalizeAngle( endAngle )
	

	local startX = x + math.cos( startAngle )*radius
	local startY = y + math.sin( startAngle )*radius
	local endX = x + math.cos( endAngle )*radius
	local endY = y + math.sin( endAngle )*radius
	local circle = false
	assert( startX == startX and endX == endX and startY == startY and endY == endY, "NaN detected when calculating angle" )
	if startX == endX and startY == endY then
		-- SVG arc command doesn't like drawing perfect circles
		endX = endX + 0.01
		endY = endY + 0.01
	end
	local large, ccw

	-- FIXME, if there is not subpath yet, the lineTo() should be a moveTo().
	if counterClockWise == true then
		ccw = 1
		if normalizeAngle( startAngle - endAngle ) > math.pi then
			large = 1
			if startAngle < endAngle or ( startAngle > 3*math.pi/4 and endAngle < math.pi/4 ) then
				startX, endX = endX, startX
				startY, endY = endY, startY
			end
		else
			if startAngle > endAngle and not( startAngle > 3*math.pi/4 and endAngle < math.pi/4 ) then
				startX, endX = endX, startX
				startY, endY = endY, startY
			end
			large = 0
		end
	else
		ccw = 0
		if normalizeAngle( startAngle - endAngle ) > math.pi then
			large = 0
			if startAngle < endAngle or ( startAngle > 3*math.pi/4 and endAngle < math.pi/4 ) then
				startX, endX = endX, startX
				startY, endY = endY, startY
			end
		else
			if startAngle > endAngle and not( startAngle > 3*math.pi/4 and endAngle < math.pi/4 ) then
				startX, endX = endX, startX
				startY, endY = endY, startY
			end
			large = 1
		end
	end

	-- FIXME, is this equivalent to the need-new-subpath flag in spec?
	if ctx._path == '' then
		ctx:moveTo( startX, startY )
	end
	ctx:lineTo( startX, startY )
	ctx:addPath( p.Path2D( string.format(
		"A %.8g %.8g 0 %d %d %.8g %.8g",
		r0(radius),
		r0(radius),
		large,
		ccw,
		r0(endX),
		r0(endY)
	)))

	if circle then
		ctx:lineTo( startX, startY )
	end
end

pathmethods.arcTo = function( ctx, x1, y1, x2, y2, radius )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )

	x0, y0 = string.match( ctx._path, "(%-?[0-9.]+)%s+(%-?[0-9.]+)%s*$")
	if x0 == nil or y0 == nil then
		-- FIXME, this isn't right if the last item was z (closePath()).
		if ctx._path ~= '' then
			mw.log( "FIXME: arcTo() might be broken in this case" )
		end
		ctx:moveTo( x1, y1 )
		x0 = x1
		y0 = y1
	end
	x0 = tonumber(x0)
	y0 = tonumber(y0)

	assert( radius >= 0, "IndexSizeError: radius must be positive in arcTo()" )

	if
		( x0 == x1 and y0 == y1 ) or
		( x1 == x2 and y1 == y2 ) or
		radius == 0 or
		( x0 == x1 and x1 == x2 ) or
		( y0 == y1 and y1 == y2 )
	then
		ctx:lineTo( x1, y1 )
		return
	end

	local angle1 = math.atan2( y1-y0, x1-x0 )
	local angle2 = math.atan2( y2-y1, x2-x1 )
	local avgAngle = (math.abs(angle1)+math.abs(angle2))/2
	local amtOfLine1 = radius/math.tan(avgAngle)

	local curveStartX = x1 - math.cos(angle1)*amtOfLine1
	local curveStartY = y1 - math.sin(angle1)*amtOfLine1
	local curveEndX = x1 + math.cos(angle2)*amtOfLine1
	local curveEndY = y1 + math.sin(angle2)*amtOfLine1

	local ccw = 0

	if angle2 > angle1 then
		ccw = 1
	end

	ctx:lineTo( curveStartX, curveStartY )
	ctx:addPath( p.Path2D( string.format(
		"A %.8g %.8g 0 %d %d %.8g %.8g",
		r0(radius),
		r0(radius),
		0, -- large
		ccw,
		r0(curveEndX),
		r0(curveEndY)
	)))

end

pathmethods.ellipse = function( ctx, x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterClockWise )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )
	if radiusX == radiusY and rotation == 0 then
		-- Easy case.
		ctx:arc( x, y, radiusX, startAngle, endAngle, counterClockWise )
		return
	end
	error( "FIXME. ellipse is not implemented yet." )
end

pathmethods.roundRect = function( ctx, x, y, w, h, radii )
	assert( type(ctx) == 'table' and type(ctx._path) == 'string', "First argument must be a CanvasRenderingContext2D or Path2D object. Did you use '.' instead of ':'?" )

	if not( isFinite( x ) ) or not( isFinite( y ) ) or not( isFinite( w ) ) or not( isFinite( h ) ) then
		-- per spec, silently ignore.
		return
	end
	
	if type( radii ) == 'number' then
		radii = { radii }
	end
	assert( type( radii ) == 'table' and #radii >= 1 and #radii <= 4, 'RangeError: invalid radii' )
	for i, v in ipairs( radii ) do
		if type( v ) == 'table' and type( v[1] ) == 'number' and v[1] == v[2] then
			radii[i] = v[1]
		elseif type( v ) == 'table' and type( v.x ) == 'number' and v.x == v.y then
			radii[i] =v.x
		elseif type( v ) ~= 'number' then
			-- FIXME todo.
			error( "Using ellipse corners for roundRect is not currently supported" )
		end
	end
	local topLeftR, topRightR, bottomLeftR, bottomRightR
	if #radii == 1 then
		topLeftR, topRightR, bottomLeftR, bottomRightR = radii[1], radii[1], radii[1], radii[1]
	elseif #radii == 2 then
		topLeftR, topRightR, bottomLeftR, bottomRightR = radii[1], radii[2], radii[2], radii[1]
	elseif #radii == 3 then
		topLeftR, topRightR, bottomLeftR, bottomRightR = radii[1], radii[2], radii[2], radii[3]
	elseif #radii == 4 then
		topLeftR, topRightR, bottomLeftR, bottomRightR = radii[1], radii[2], radii[4], radii[3]
	else
		error( "invalid radius" )
	end

	local top, bottom, left, right = topRightR + topLeftR, bottomRightR + bottomLeftR, topLeftR + bottomLeftR, topRightR + bottomRightR
	local scale = math.min( w/top, h/left, h/right, w/bottom )
	if scale < 1 then
		topLeftR = topLeftR * scale
		topRightR = topRightR * scale
		bottomLeftR = bottomLeftR * scale
		bottomRightR = bottomRightR * scale
	end
	local ccw = 1
	if (w >= 0 and h < 0) or (w < 0 and h >= 0) then
		ccw = 0
	end

	local function addArc( radius, endX, endY )
		ctx:addPath( p.Path2D( string.format(
			"A %.8g %.8g 0 %d %d %.8g %.8g",
			r0(radius),
			r0(radius),
			0, -- large flag
			ccw,
			r0(endX),
			r0(endY)
		)))
	end

	ctx:beginPath()
	ctx:moveTo( x + topLeftR, y )
	ctx:lineTo( x + w - topRightR, y )
	addArc( topRightR, x+w, y + topRightR )
	ctx:lineTo( x + w, y + h - bottomRightR )
	addArc( bottomRightR, x+w-bottomRightR, y+h )
	ctx:lineTo( x+bottomLeftR, y+h)
	addArc(bottomLeftR, x, y+h-bottomLeftR )
	ctx:lineTo(x, y+topLeftR )
	addArc( topLeftR, x+topLeftR, y )
	ctx:closePath()
	ctx:moveTo( x, y )

end


-- End of Path2D methods

-- can be fill(fillRule), fill(path), fill(path, fillRule)
methodtable.fill = function( ctx, arg1, arg2 )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	local op = newOperation( ctx, 'fill' )
	if arg2 == 'evenodd' or arg1 == 'evenodd' then
		op._fillRule = 'evenodd'
	end
	if type( arg1 ) == 'table' and type( arg1._path ) == 'string' then
		op._path = arg1._path
	end
	table.insert( ctx.__operations, op )
end


-- returns an iterator
local function parsePath(path)
    -- FIXME, in the path syntax, you can technically omit spaces, which doesn't work here.
    -- https://www.w3.org/TR/SVG11/paths.html#PathData
	local getNextEntry = string.gmatch( path, "(%a)%s*([-+0-9eE., ]+)" )
    local curType = ''
    local points = {}
    return function()
        while true do
            local curTypeU = curType:upper()
            if (curTypeU == 'L' or curTypeU == 'M' or curTypeU == 'T' ) and #points >= 2 then
                return curType, { tonumber(table.remove( points, 1 )), tonumber(table.remove( points, 1 )) }
            elseif ( curTypeU == 'Z' ) then
                points = {}
                curType = ''
                return 'Z', {}
            elseif ( curTypeU == 'H' or curTypeU == 'V' ) and #points >= 1 then
                return curType, { tonumber(table.remove( points, 1 )) }
            elseif ( curTypeU == 'S' or curTypeU == 'Q' ) and #points >= 4 then
                return curType, {
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 ))
                }
            elseif ( curTypeU == 'C' ) and #points >= 6 then
                return curType, {
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 ))
                }
            elseif ( curTypeU == 'A' ) and #points >= 7 then
                return curType, {
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 )),
                    tonumber(table.remove( points, 1 ))
                }
            end
            -- We need to get the next entry.
            local pointsString
            curType, pointsString = getNextEntry()
            if curType == nil then
                curType = ''
                return
            end
            -- TODO this isn't quite right. 5.3.4 is supposed to be 5.3 0.4
            points = mw.text.split( mw.text.trim(pointsString), "[%s,]+" )
        end
    end	
end

-- Normalize the path into just L and M commands
local function convertToLines(pathIt)
	local type, points
	local startX, startY = 0,0
	local curX, curY = 0, 0
	local it
	it = function()
		type, points = pathIt()

		-- For many of these commands, we never make them, but the user
		-- can specify using p.Path2d( '...' )
		-- TODO better handle zero length line segments.
		if type == nil then
			-- we are done
			return
		elseif type == 'L' and #points == 2 then
			if curX == points[#points-1] and curY == points[#points] then
				-- rm zero length lines
				return it()
			end
			curX, curY = points[#points-1], points[#points]
			return type, points
		elseif type == 'l' and #points == 2 then
			curX, curY = points[#points-1]+curX, points[#points]+curY
			return 'L', { curX, curY }
		elseif type == 'M' and #points == 2 then
			if curX == points[#points-1] and curY == points[#points] then
				-- moving zero doesn't count as a new subpath
				return it()
			end
			curX, curY = points[#points-1], points[#points]
			startX, startY = curX, curY
			return 'M', { curX, curY }
		elseif type == 'm' and #points == 2 then
			if points[#points-1] == 0 and points[#points] == 0 then
				-- moving zero doesn't count as a new subpath
				return it()
			end
			curX, curY = points[#points-1]+curX, points[#points]+curY
			startX, startY = curX, curY
			return 'M', { curX, curY }
		elseif (type == 'z' or type == 'Z') and #points == 0 then
			curX, curY = startX, startY
			return 'M', {startX, startY}
		elseif type == 'H' and #points == 1 then
			curX = points[1]
			return 'L', { curX, curY }
		elseif type == 'h' and #points == 1 then
			curX = points[1] + curX
			return 'L', { curX, curY }
		elseif type == 'V' and #points == 1 then
			curY = points[1]
			return 'L', { curX, curY }
		elseif type == 'v' and #points == 1 then
			curY = points[1] + curX
			return 'L', { curX, curY }
		else
			-- TODO q s c t a
			error( "Either wrong number of points, or command " .. type .. " is not yet supported for stroking" )
		end
	end
	return it
end

-- Convert line segment into multiple line segments if we are drawing a dashed line
local function doDashes(ctx, pathIt)
	assert( type(ctx) == 'table' and type( pathIt ) == 'function' )
	local patternWidth = 0
	for i,v in ipairs( ctx._lineDash ) do
		patternWidth = patternWidth + v
	end
	if patternWidth == 0 then
		return function()
			return pathIt()
		end
	end
	local curSegmentPoints = nil
	local pathType = nil
	local curX, curY = 0, 0
	local endX, endY = 0, 0
	local dashOffset = ((ctx.lineDashOffset % patternWidth ) + patternWidth) % patternWidth
	local position = 0 - dashOffset
	local index = 0 -- note, array is 1-indexed
	local on = true
	local angle = nil
	local position = 0
	local subpathLen = 0
	local curDash = nil
	it = function ()
		if curSegmentPoints == nil or pathType == 'M' or position >= subpathLen  then
			repeat
				pathType, curSegmentPoints = pathIt()
				if curSegmentPoints == nil then
					-- all done
					return
				end

				if pathType == 'M' then
					-- new subpath
					curX, curY = curSegmentPoints[1], curSegmentPoints[2]
					dashOffset = ((ctx.lineDashOffset % patternWidth ) + patternWidth) % patternWidth
					position = 0 - dashOffset
					index = 0
					curDash = ctx._lineDash[index+1]
					return pathType, curSegmentPoints
				else
					assert( pathType == 'L' )
					assert( #curSegmentPoints == 2, "Expected 2 points. got " .. pathType .. ' with ' .. #curSegmentPoints )
					endX, endY = curSegmentPoints[1], curSegmentPoints[2]
					subpathLen = math.sqrt( (endX-curX)*(endX-curX) + (endY-curY)*(endY-curY) )
					angle = math.atan2((endY-curY),(endX-curX))
					if position > 0 then
						-- We want to reset position if we are drawing a new line in same subpath, but not for new subpath
						position = 0
					end
				end
			until pathType == 'L'
		end
		segmentLen = curDash
		assert( type( segmentLen) == 'number', "lineDash " .. index .. "+1 is not a number"  )

		local effectiveDashLen
		local curOn = on -- We want the change to on variable to apply next round not this round.
		if position < 0 and position+segmentLen > 0 then
			effectiveDashLen = -position
			curDash = curDash-effectiveDashLen
		elseif position+segmentLen <= subpathLen then
			effectiveDashLen = segmentLen
			index = (index+1) % #ctx._lineDash
			curDash = ctx._lineDash[index+1]
			on = not on
		else
			effectiveDashLen = subpathLen-position
			curDash = curDash-effectiveDashLen
		end
		dashOffset = dashOffset + effectiveDashLen

		position = position+effectiveDashLen
		local dashEndXRel = math.cos(angle)*effectiveDashLen
		local dashEndYRel = math.sin(angle)*effectiveDashLen
		-- The dash end before the current subpath ends.
		if position > 0 then
			curX = dashEndXRel+curX
			curY = dashEndYRel+curY
		end

		if curOn and position > 0 then
			return 'L', { curX, curY }
		else
			return 'M', { curX, curY }
		end
	end
	return it
end


-- 	for type, pointsCombined in string.gmatch( ctx._path, "(%a)%s*([0-9. ]+)" ) do repeat
--		points = mw.text.split( mw.text.trim(pointsCombined), "%s+" )

-- Not properly implemented. Only works on straight lines.
methodtable.stroke = function( ctx, path )
	ctx:save()
	if type( path ) == 'table' and type( path._path ) == 'string' then
		ctx._path = path._path
	elseif path ~= nil then
		error( "Invalid second argument to stroke" )
	end

	local newPath = p.Path2D()
	local curX = 0
	local curY = 0
	local startX = 0
	local startY = 0
	local offset = ctx.lineWidth/2
	local deg90 = math.pi/2
	local lastAngle = nil
	local startAngle = nil

	-- When drawing, it is important that we always draw in a clockwise direction
	-- per https://www.w3.org/TR/SVG2/painting.html#WindingRule clockwise and anti-clockwise
	-- cancel each other out.
	local draw = function( path, curX, newX, curY, newY )
		local angle = math.atan2((newY-curY),(newX-curCurY))-deg90
		local ypt = math.sin(angle)*offset
		local xpt = math.cos(angle)*offset
		newPath:moveTo( curX+xpt, curY+ypt )
		newPath:lineTo( newX+xpt, newY+ypt )
		newPath:lineTo( newX-xpt, newY-ypt )
		newPath:lineTo( curX-xpt, curY-ypt )
		newPath:lineTo( curX+xpt, curY+ypt )
		return angle+deg90
	end

	local function drawLineJoin( prevAngle, nextAngle, curX, curY )
		if prevAngle == newAngle then
			return
		end
		local xOffsetPrev = math.cos(prevAngle+deg90)*offset
		local yOffsetPrev = math.sin(prevAngle+deg90)*offset
		local xOffsetNext = math.cos(nextAngle+deg90)*offset
		local yOffsetNext = math.sin(nextAngle+deg90)*offset

		-- is the angle facing inwards or outwards
		local diff = ((nextAngle-prevAngle)+math.pi*2) % (math.pi*2)
		local xNextPoint, yNextPoint, xPrevPoint, yPrevPoint
		if diff > math.pi  then
			xNextPoint, yNextPoint = curX+xOffsetNext, curY+yOffsetNext
			xPrevPoint, yPrevPoint = curX+xOffsetPrev, curY+yOffsetPrev
		else
			xNextPoint, yNextPoint = curX-xOffsetNext, curY-yOffsetNext
			xPrevPoint, yPrevPoint = curX-xOffsetPrev, curY-yOffsetPrev
		end

		newPath:moveTo( curX, curY )
		newPath:lineTo( xNextPoint, yNextPoint )
		newPath:lineTo( xPrevPoint, yPrevPoint )
		newPath:closePath()

		if ctx.lineJoin == 'round' then
			if diff > math.pi then
				newPath:moveTo( xNextPoint, yNextPoint )
				newPath:addPath( p.Path2D( string.format(
					"A %.8g %.8g 0 0 1 %.8g %.8g",
					r0(offset),
					r0(offset),
					r0(xPrevPoint),
					r0(yPrevPoint)
				)))
			else
				newPath:moveTo( xPrevPoint, yPrevPoint )
				newPath:addPath( p.Path2D( string.format(
					"A %.8g %.8g 0 0 1 %.8g %.8g",
					r0(offset),
					r0(offset),
					r0(xNextPoint),
					r0(yNextPoint)
				)))
			end
		elseif ctx.lineJoin == 'miter' then
			-- We have to determine where the intersection point is
			local prevIntercept = yPrevPoint-math.tan(prevAngle)*xPrevPoint
			local nextIntercept = yNextPoint-math.tan(nextAngle)*xNextPoint
			local xIntersect = (nextIntercept-prevIntercept)/(math.tan(prevAngle)-math.tan(nextAngle))
			local yIntersect = math.tan(prevAngle)*xIntersect + prevIntercept
			local maxMiter = ctx.miterLimit*offset
			-- FIXME I'm a bit confused by the definition of miter length in spec, so not
			-- sure if this is right.
			if math.sqrt( (curX-xIntersect)*(curX-xIntersect)+(curY-yIntersect)*(curY-yIntersect) ) < maxMiter then
				if xIntersect > xPrevPoint then
					newPath:moveTo( xPrevPoint, yPrevPoint )
					newPath:lineTo( xNextPoint, yNextPoint )
					newPath:lineTo( xIntersect, yIntersect )
					newPath:closePath()
				else
					newPath:moveTo( xNextPoint, yNextPoint )
					newPath:lineTo( xPrevPoint, yPrevPoint )
					newPath:lineTo( xIntersect, yIntersect )
					newPath:closePath()
				end
			end
		end
	end

	local function drawLineCap( newPath, lineCap, angle, curX, curY )
		if angle == nil then
			mw.log( "nil angle. Possibly a bug in canvas" )
			return
		end

		if lineCap == 'butt' then
			-- do nothing
			return
		elseif lineCap == 'square' then
			local endPointX = curX + math.cos(angle)*offset
			local endPointY = curY + math.sin(angle)*offset
			draw( newPath, curX, endPointX, curY, endPointY )
		elseif lineCap == 'round' then
			local startPtX = curX + math.cos(angle+deg90)*offset
			local startPtY = curY + math.sin(angle+deg90)*offset
			local endPtX = curX - math.cos(angle+deg90)*offset
			local endPtY = curY - math.sin(angle+deg90)*offset

			newPath:moveTo( endPtX, endPtY )
			-- A rx ry x-axis-rotation large-arc-flag sweep-flag(clockwise) x y
			newPath:addPath( p.Path2D( string.format(
				"A %.8g %.8g 0 0 1 %.8g %.8g",
				r0(offset),
				r0(offset),
				r0(startPtX),
				r0(startPtY)
			)))
		else
			error( "Unrecognized lineCap of " .. ctx.lineCap )
		end
	end

	-- Note, its important we always draw clockwise, as CCW can create holes
	for type, points in doDashes( ctx, convertToLines( parsePath( ctx._path ) ) ) do repeat
		assert( #points == 2, "expected 2 points")
		assert( _G.type(points[1]) == 'number' and _G.type(points[2]) == 'number', "Expected points to be numbers" )
		if curX-points[#points-1] == 0 and curY-points[#points] == 0 then
			-- Zero-length line. Skip
			break
		end
		if type == 'L' then
			-- This is probably doing it totally wrong way.

			local prevAngle = lastAngle
			lastAngle = draw( newPath, curX, points[1], curY, points[2] )
			assert(  _G.type( lastAngle ) == 'number', 'expected last angle to be a number' )
			if startAngle == nil then
				startAngle = lastAngle + math.pi
			else
				-- Having a previous startAngle means that we are drawing a line
				-- that connects to a previous line, so we have to join it.
				-- TODO this seems to get incorrectly called on first line of subpath.
				drawLineJoin( prevAngle, lastAngle, curX, curY )
			end
			curX = points[1]
			curY = points[2]
		elseif type == 'M'  then
			if curX ~= startX or curY ~= startY then
				-- We are at the end of a subpath so need to draw a line ending
				drawLineCap( newPath, ctx.lineCap, lastAngle, curX, curY )
				drawLineCap( newPath, ctx.lineCap, startAngle, startX, startY )
			elseif startAngle ~= nil then
				-- Closed path and we drew at least one thing.
				drawLineJoin( startAngle, lastAngle, curX, curY )
			end
			angle = nil
			curX = points[1]
			curY = points[2]
			startX = points[1]
			startY = points[2]
			startAngle = nil
		else
			-- Should be impossible to reach
			error( "Unexpected path command" )
		end
	until true end
	-- draw caps for final line segment.
	if curX ~= startX or curY ~= startY then
		-- We are at the end of a subpath so need to draw a line ending
		drawLineCap( newPath, ctx.lineCap, lastAngle, curX, curY )
		drawLineCap( newPath, ctx.lineCap, startAngle, startX, startY )
	elseif startAngle ~= nil then
		-- Closed path and we drew at least one thing.
		drawLineJoin( startAngle, (lastAngle+math.pi) % (math.pi*2), curX, curY )
	end
	ctx.fillStyle = ctx.strokeStyle
	ctx:fill(newPath)

	ctx:restore()
end

methodtable.createWikitextPattern = function( ctx, args )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	if type( args ) == 'string' then
		args = { background = args }
	end
	local res = {
		background =  args.background or 'transparent',
		class = args.class or nill,
		style = args.style or nill,
		content = args.content or '', -- should this be parsed?
		offsetx = args.offsetx or 0, -- FIXME this should be removed ??
		offsety = args.offsety or 0,
		attr = args.attr or nil
	}
	return res
end

methodtable.drawImage = function( ctx, image, sx, sy, sw, sh, dx, dy, dw, dh )
	-- FIXME this doesn't work properly yet
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	if image == nil then
		return
	end
	if type( image ) == 'string' then
		image = mw.title.new( image, 6 )
	end
	if ( not image:inNamespace( 6 ) ) or (not image.file.exists) then
		return
	end
	if sx == nil then
		sx = 0
	end
	if sy == nil then
		sx = 0
	end
	if sw == nil then
		sw = image.file.width
	end
	if sh == nil then
		sh = image.file.height
	end
	if dx == nil then
		dx = sx
	end
	if dy == nil then
		dy = sy
	end
	if dw == nil then
		dw = sw
	end
	if dh == nil then
		dh = sh
	end

	ctx:save()
	-- FIXME, this is broken and doesn't work right for all arg types
	local img
	if image.file.width > image.file.height then
		img = '[[File:' .. image.text .. '|' .. dw .. 'px' .. '|link=]]'
	else
		img = '[[File:' .. image.text .. '|x' .. dh .. 'px' .. '|link=]]'
	end
	-- FIXME doesn't work with negative values properly
	local clip = 'path("M ' .. sx .. ' ' .. sy .. ' L ' .. (sw+sx) .. ' ' .. sy .. ' L ' .. (sw+sx) .. ' ' .. (sh+sy) .. ' L ' .. sx .. ' ' .. (sh+sy) ..' )'
	-- Note in timeless, if the image is linked, then there are css rules that resize it which we don't want.
	img = '<div style="position:relative;left:' .. (-sx) .. 'px;top:' .. (-sy) .. 'px;clip-path:' .. clip .. '">' .. img .. '</div>'
	ctx.fillStyle = ctx:createWikitextPattern{
		--offsetx = dx,
		offsetx = 0,
		offsety = 0,
		--offsety = dy,
		content = img
	}
	ctx:fillRect( dx, dy, dw, dh )
	ctx:restore()

end

isFinite = function( n )
	return n > -math.huge and n < math.huge	
end


methodtable.fillRect = function( ctx, x, y, w, h )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	if not isFinite( x ) or not isFinite( y ) or not isFinite( w ) or not isFinite( h ) or w == 0 or h == 0 then
		return
	end
	local oldPath = ctx._path
	ctx:beginPath()
	ctx:rect( x, y, w, h )
	ctx:fill()
	ctx:beginPath()
	ctx._path = oldPath
end

methodtable.clearRect = function( ctx, x, y, w, h )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	-- FXIME, this should make it transparent to html below canvas, not just be white.
	ctx:save()
	ctx.fillStyle = "var(--background-color-base, '#fff')"
	ctx:fillRect( x, y, w, h )
	ctx:restore()
end

local doText = function( ctx, text, x, y, maxWidth, stroke )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	assert( maxWidth == nil, "maxWidth parameter to fillText is not supported" )
	ctx:save()
	local oldStyle = stroke and ctx.strokeStyle or ctx.fillStyle
	-- Maybe complex background is possible here with mix-blend-mode: screen. Main issue is it seems like
	-- we couldn't make the non-text part be transparent.
	assert( type(oldStyle) == 'string', 'Complex backgrounds for fillText() not currently supported' )
	text = string.gsub( text, "[\n\r\t\f]", " " ) -- per spec, replace whitespace (Note: we use white-space: pre css)

	local textLayer = mw.html.create( 'div' )
		:css( 'position', 'absolute' )
		:css( 'width', 'max-content' )
		:css( 'font', ctx.font )
		:css( 'text-rendering', ctx.textRendering )
		:css( 'font-kerning', ctx.fontKerning )
		:css( 'font-stretch', ctx.fontStretch )
		:css( 'font-variant-caps', ctx.fontVariantCaps )
		:css( 'letter-spacing', ctx.letterSpacing )
		:css( 'word-spacing', ctx.wordSpacing )
		:css( 'text-align', 'left' )
		:css( 'white-space', 'pre' )
		:wikitext( text ) -- FIXME should we escape

	if ctx.direction ~= 'inherit' then
		textLayer:attr( 'dir', ctx.direction )
	end
	if ctx.textBaseline == 'alphabetic' or ctx.textBaseline == 'bottom' then
		-- This isn't 100% right for alphabetic, but it is the default and this is close
		textLayer:css( 'bottom', 'calc( 100% - ' .. y .. 'px' .. ' )' )
	elseif ctx.textBaseline == 'top' then
		textLayer:css( 'top', y .. 'px' )
	else
		-- We can approximate some values, but better to just give an error.
		error( "Unsupported value for textBaseline: " .. ctx.textBaseline )
	end

	-- not perfect, as its supposed to inherit from containing element
	local realDir = ctx.direction == 'inherit' and mw.getContentLanguage():getDir() or ctx.direction
	local realAlign = 'left'
	if ctx.textAlign == 'start' and realDir == 'rtl' then
		realAlign = 'right'
	elseif ctx.textAlign == 'end' and realDir == 'ltr' then
		realAlign = 'right'
	end
	if ctx.textAlign == 'center' then
		textLayer:css( 'width', ctx._width .. 'px' )
		textLayer:css( 'left', (x-ctx._width)/2 .. 'px' )
		textLayer:css( 'text-align', 'center' )
	elseif realAlign == 'left' then
		textLayer:css( 'left', x .. 'px' )
	else
		textLayer:css( 'right', 'calc( 100% - ' .. x .. 'px )' )
	end

	local style = 'color:' .. oldStyle
	if stroke then
		style = 'color: transparent; -webkit-text-stroke-color: ' .. oldStyle .. ';text-stroke-color:' .. oldStyle ..
			'; -webkit-text-stroke-width:' .. ctx.lineWidth .. 'px; text-stroke-width:' .. ctx.lineWidth .. 'px;'
	end
	ctx.fillStyle = ctx:createWikitextPattern{
		style = style,
		content = textLayer
	}
	local op = newOperation( ctx, 'text' )
	table.insert( ctx.__operations, op )
	ctx:restore()
end


methodtable.fillText = function( ctx, text, x, y, maxWidth )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	ctx:fillTextRaw( mw.text.nowiki( text ), x, y, maxWidth )
end

-- For use if you want to include wikitext. Note you still need to use frame:preprocess before calling this.
methodtable.fillTextRaw = function( ctx, text, x, y, maxWidth )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	doText( ctx, text, x, y, maxWidth, false )
end

-- For use if you want to include wikitext. Note you still need to use frame:preprocess before calling this.
methodtable.strokeTextRaw = function( ctx, text, x, y, maxWidth )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	doText( ctx, text, x, y, maxWidth, true )
end
methodtable.strokeText = function( ctx, text, x, y, maxWidth )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	ctx:strokeTextRaw( mw.text.nowiki( text ), x, y, maxWidth )
end

methodtable.save = function (ctx)
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	-- Note, path is included in operation, however it is not part of save state.
	table.insert( ctx.__stateStack, newOperation( ctx, 'save' ) )
end

methodtable.restore = function(ctx)
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	if #ctx.__stateStack == 0 then
		-- spec says silently ignore if no saved state
		return
	end
	
	op = table.remove( ctx.__stateStack )

	ctx._currentTransform = op._currentTransform
	ctx.lineWidth = op.lineWidth 
	ctx.lineCap = op.lineCap 
	ctx.lineJoin = op.lineJoin 
	ctx.miterLimit = op.miterLimit 
	ctx.lineDashOffset = op.lineDashOffset 
	ctx.font = op.font 
	ctx.textAlign = op.textAlign 
	ctx.textBaseline = op.textBaseline 
	ctx.direction = op.direction 
	ctx.letterSpacing = op.letterSpacing 
	ctx.fontKerning = op.fontKerning 
	ctx.fontStretch = op.fontStretch 
	ctx.fontVariantCaps = op.fontVariantCaps 
	ctx.textRendering = op.textRendering 
	ctx.wordSpacing = op.wordSpacing 
	ctx.fillStyle = op.fillStyle 
	ctx.strokeStyle = op.strokeStyle 
	ctx.shadowBlur = op.shadowBlur 
	ctx.shadowColor = op.shadowColor 
	ctx.shadowOffsetX = op.shadowOffsetX 
	ctx.shadowOffsetY = op.shadowOffsetY 
	ctx.globalAlpha = op.globalAlpha 
	ctx.globalCompositeOperation = op.globalCompositeOperation 
	ctx.imageSmoothingEnabled = op.imageSmoothingEnabled 
	ctx.imageSmoothingQuality = op.imageSmoothingQuality 
	ctx.canvas = op.canvas 
	ctx.filter = op.filter
end

methodtable.reset = function (ctx)
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	setDefaults( ctx )
end


methodtable.isContextLost = function( t )
	return false
end

methodtable.getContextAttributes = function( ctx )
	assert( type(ctx) == 'table' and type(ctx.__stateStack) == 'table', "First argument must be a CanvasRenderingContext2D. Did you use '.' instead of ':'?" )
	return {
		width = ctx._width,
		height = ctx._height,
		containerClass = ctx._containerClass,
		containerStyle = ctx._containerStyle,
		alpha = ctx._alpha
	}
end

local getBlendMode = function ( compositeOp )
	-- css mix-blend-mode only supports blending not composite operators (except plus-darker and plus-lighter)
	-- So we don't support the following values: clear | copy | source-over | destination-over | source-in |    
	-- destination-in | source-out | destination-out | source-atop |    
	-- destination-atop | xor | lighter

	-- Also this doesn't work for images properly as images have isolated blend modes.
	validOps = {
		normal = true,
		multiply = true,
		screen = true,
		overlay = true,
		darken = true,
		lighten = true,
		["color-dodge"] = true,
		["color-burn"] = true,
		["hard-light"] = true,
		["soft-light"] = true,
		difference = true,
		exclusion = true,
		hue = true,
		saturation = true,
		color = true,
		luminosity = true,
		['plus-darker'] = true,
		['plus-lighter'] = true,
	}
	if validOps[compositeOp] then
		return compositeOp
	end
	return "normal"
end

local getTransform = function( t )
	local res = 'matrix(' .. t[1] .. ',' .. t[2] .. ',' .. t[3] .. ',' .. t[4] .. ',' .. t[5] .. ',' .. t[6] .. ')'
	if res == 'matrix(1,0,0,1,0,0)' then
		return 'none'
	end
	return res
end

-- TODO this is a hack that doesn't really work.
local getAdjustedWidth = function( w, h, t )
	--  ( a x + c y + e , b x + d y + f ) 
	-- we should really invert the matrix instead of this hack
	return math.abs(math.ceil(w*w/(w*t[1]+h*t[3])+math.abs(t[5])))
end

local getAdjustedHeight = function( w, h, t )
	--  ( a x + c y + e , b x + d y + f ) 
	-- we should really invert the matrix instead of this hack
	return math.abs(math.ceil(h*h/(w*t[2]+h*t[4])+math.abs(t[6])))
end

local getFilter = function( op )
	if op.shadowColor == 'transparent' then
		if op.filter == 'none' then
			return nil
		end
		return op.filter
	end
	local shadow = " drop-shadow(" .. op.shadowColor .. ' ' .. op.shadowOffsetX .. 'px '
		.. op.shadowOffsetY .. 'px ' .. op.shadowBlur .. 'px)'
	if op.filter == 'none' then
		return shadow
	end
	return op.filter .. shadow
end

-- Lightweight SVG attribute parser / renderer
local function trim(s) return (s and s:gsub("^%s*(.-)%s*$","%1")) end

local function parseAttrs(attrStr)
    local attrs = {}
    if not attrStr then return attrs end
    for k,v in string.gmatch(attrStr, '(%w+)%s*=%s*"([^"]*)"') do attrs[k] = v end
    for k,v in string.gmatch(attrStr, "(%w+)%s*=%s*'([^']*)'") do attrs[k] = v end
    -- inline style
    if attrs.style then
        for prop,val in string.gmatch(attrs.style, "([%w-]+)%s*:%s*([^;]+)") do
            attrs[prop] = trim(val)
        end
    end
    return attrs
end

local function parseNum(s, fallback)
    if s == nil then return fallback end
    -- strip units like px, %
    s = tostring(s):gsub("px","")
    local n = tonumber(s)
    return n == nil and fallback or n
end

local function applyTransformString(ctx, tstr)
    if not tstr then return end
    -- Apply common transforms; caller must ctx:save() / ctx:restore()
    for fn, args in string.gmatch(tstr, '(%a+)%s*%(([^)]*)%)') do
        local parts = {}
        for v in string.gmatch(args, '[-+]?%d*%.?%d+[%a%%]*') do
            local s = v:gsub("px",""):gsub("deg",""):gsub("°",""):gsub("%%","")
            parts[#parts+1] = tonumber(s) or 0
        end
        if fn == 'translate' then
            ctx:translate(parts[1] or 0, parts[2] or 0)
        elseif fn == 'rotate' then
            local a = (parts[1] or 0) * math.pi / 180
            if parts[2] and parts[3] then
                ctx:translate(parts[2], parts[3])
                ctx:rotate(a)
                ctx:translate(-(parts[2]), -(parts[3]))
            else
                ctx:rotate(a)
            end
        elseif fn == 'scale' then
            ctx:scale(parts[1] or 1, parts[2] or parts[1] or 1)
        elseif fn == 'matrix' and #parts >= 6 then
            ctx:transform(parts[1], parts[2], parts[3], parts[4], parts[5], parts[6])
        end
    end
end

-- Accepts svg markup (string) and a ctx (optional). If ctx is nil, a default 300x300 ctx is created.
-- Supports: rect, circle, ellipse, path, line, polyline, polygon. Basic fill/stroke/opacity/transform/style parsing.
p.drawSVG = function(svgString, ctx)
    assert(type(svgString) == 'string', "svgString must be a string")
    if not ctx then ctx = p.getContext('2d', { width=300, height=300 }) end

    -- Optional: read svg width/height/viewBox to size ctx
    local svgAttrs = parseAttrs( string.match(svgString, "<svg([^>]*)>") or "" )
    if svgAttrs.width then ctx._width = parseNum(svgAttrs.width, ctx._width) end
    if svgAttrs.height then ctx._height = parseNum(svgAttrs.height, ctx._height) end
    if svgAttrs.viewBox then
        -- leave as-is; user can scale transforms if needed
    end

    for tag, attrStr in string.gmatch(svgString, "<(%w+)([^>]*)/?>") do
        local a = parseAttrs(attrStr)
        -- common style values
        local fill = a.fill or a["fill"] or a["fill-opacity"] -- sometimes style gives fill directly
        local stroke = a.stroke
        local opacity = tonumber(a.opacity) or tonumber(a["fill-opacity"]) or 1
        -- if style keys present, they have been added to a by parseAttrs

        if tag == "rect" then
            local x = parseNum(a.x, 0)
            local y = parseNum(a.y, 0)
            local w = parseNum(a.width, 0)
            local h = parseNum(a.height, 0)
            ctx:save()
            if a.transform then applyTransformString(ctx, a.transform) end
            if a.opacity then ctx.globalAlpha = ctx.globalAlpha * tonumber(a.opacity) end
            if fill and fill ~= "none" then
                ctx:beginPath()
                if a.rx and tonumber(a.rx)>0 then
                    -- roundRect is implemented on Path2D; simulate by path commands
                    local r = tonumber(a.rx)
                    ctx:rect(x,y,w,h) -- fallback; fine for many cases
                else
                    ctx:rect(x,y,w,h)
                end
                ctx.fillStyle = fill or ctx.fillStyle
                ctx:fill()
            end
            if stroke and stroke ~= "none" then
                ctx.strokeStyle = stroke
                ctx:stroke()
            end
            ctx:restore()
        elseif tag == "circle" then
            local cx = parseNum(a.cx,0)
            local cy = parseNum(a.cy,0)
            local r = parseNum(a.r,0)
            ctx:save()
            if a.transform then applyTransformString(ctx, a.transform) end
            ctx:beginPath()
            ctx:arc(cx, cy, r, 0, math.pi*2)
            if fill and fill ~= "none" then
                ctx.fillStyle = fill
                ctx:fill()
            end
            if stroke and stroke ~= "none" then
                ctx.strokeStyle = stroke
                ctx:stroke()
            end
            ctx:restore()
        elseif tag == "ellipse" then
            local cx = parseNum(a.cx,0)
            local cy = parseNum(a.cy,0)
            local rx = parseNum(a.rx,0)
            local ry = parseNum(a.ry,0)
            ctx:save()
            if a.transform then applyTransformString(ctx, a.transform) end
            -- fallback: scale circle
            ctx:beginPath()
            if ctx.ellipse then
                -- If ellipse method exists, use it
                ctx:ellipse(cx, cy, rx, ry, 0, 0, math.pi*2)
            else
                -- approximate via path arc with scale transform
                ctx:translate(cx, cy)
                ctx:scale(1, ry/rx)
                ctx:arc(0, 0, rx, 0, math.pi*2)
                ctx:resetTransform()
            end
            if fill and fill ~= "none" then ctx.fillStyle = fill; ctx:fill() end
            if stroke and stroke ~= "none" then ctx.strokeStyle = stroke; ctx:stroke() end
            ctx:restore()
        elseif tag == "path" then
            local d = a.d or ""
            ctx:save()
            if a.transform then applyTransformString(ctx, a.transform) end
            if a.opacity then ctx.globalAlpha = ctx.globalAlpha * tonumber(a.opacity) end
            if fill and fill ~= "none" then
                ctx.fillStyle = fill
                ctx:fill( p.Path2D(d) )
            end
            if stroke and stroke ~= "none" then
                ctx.strokeStyle = stroke
                ctx:stroke( p.Path2D(d) )
            end
            ctx:restore()
        elseif tag == "line" then
            local x1 = parseNum(a.x1,0)
            local y1 = parseNum(a.y1,0)
            local x2 = parseNum(a.x2,0)
            local y2 = parseNum(a.y2,0)
            ctx:save()
            if a.transform then applyTransformString(ctx, a.transform) end
            ctx:beginPath()
            ctx:moveTo(x1,y1)
            ctx:lineTo(x2,y2)
            if stroke and stroke ~= "none" then ctx.strokeStyle = stroke; ctx:stroke() end
            ctx:restore()
        elseif tag == "polyline" or tag == "polygon" then
            local points = a.points or ""
            local pts = {}
            for x,y in string.gmatch(points, "([%-%d%.]+)[, ]+([%-%d%.]+)") do
                pts[#pts+1] = tonumber(x); pts[#pts+1] = tonumber(y)
            end
            if #pts >= 2 then
                ctx:save()
                if a.transform then applyTransformString(ctx, a.transform) end
                ctx:beginPath()
                ctx:moveTo(pts[1], pts[2])
                for i=3,#pts,2 do ctx:lineTo(pts[i], pts[i+1]) end
                if tag == "polygon" then ctx:closePath() end
                if fill and fill ~= "none" then ctx.fillStyle = fill; ctx:fill() end
                if stroke and stroke ~= "none" then ctx.strokeStyle = stroke; ctx:stroke() end
                ctx:restore()
            end
        else
            -- unknown tag: ignore (svg groups, gradients etc. are unsupported in this lightweight parser)
        end
    end

    return ctx

    
end

methodtable.getWikitext = function(ctx)
    assert(type(ctx) == 'table' and type(ctx.__operations) == 'table', "First argument must be a CanvasRenderingContext2D")
    local container = mw.html.create('div')
        :css('position', 'relative')
        :css('width', (ctx._width or 300) .. 'px')
        :css('height', (ctx._height or 300) .. 'px')
    if ctx._containerClass then container:addClass(ctx._containerClass) end
    if ctx._containerStyle then container:attr('style', ctx._containerStyle) end

    -- create a layer for each operation (simple model: fills/text)
    for _, op in ipairs(ctx.__operations) do
        -- only handle fill and text for now
        if op.name == 'fill' or op.name == 'text' then
            local fillPattern = op.fillStyle
            local layer = mw.html.create('div')
                :css('position', 'absolute')
                :css('left', '0px')
                :css('top', '0px')
                :css('width', (ctx._width or 300) .. 'px')
                :css('height', (ctx._height or 300) .. 'px')
                :css('filter', getFilter(op) or '')
                :css('mix-blend-mode', getBlendMode(op.globalCompositeOperation))
                :css('opacity', op.globalAlpha)

            -- apply transform if present
            if op._currentTransform then
                local tf = getTransform(op._currentTransform)
                if tf ~= 'none' then
                    layer:css('transform', tf)
                    layer:css('transform-origin', 'top left')
                end
            end

            -- clipping by path if available
            if op._path and op._path ~= '' then
                -- CSS clip-path path() expects an SVG path string. Wrap in path("").
                -- Escape double quotes in path
                local safePath = tostring(op._path):gsub('"','\\"')
                layer:css('clip-path', 'path("' .. safePath .. '")')
                layer:css('pointer-events', 'none')
            end

            -- If fillStyle is a pattern table (createWikitextPattern), use its content
            if type(fillPattern) == 'table' and fillPattern.content then
                -- inner container to hold content, using provided style/background/class
                local inner = mw.html.create('div')
                    :cssText(fillPattern.style or '')
                    :addClass(fillPattern.class or '')
                    :attr(fillPattern.attr or {})
                    :css('width', '100%')
                    :css('height', '100%')
                    :css('background-color', fillPattern.background or 'transparent')

                if type(fillPattern.content) == 'string' then
                    inner:wikitext(fillPattern.content)
                else
                    inner:node(fillPattern.content)
                end
                layer:node(inner)
            elseif type(fillPattern) == 'string' then
                -- simple solid color
                layer:css('background-color', fillPattern)
            else
                -- nothing to render for this op
            end

            container:node(layer)
        else
            -- ignore unsupported operations for now
        end
    end

    return tostring(container)
end

return p