-- dichromacy.lua -- Color Vision Deficiency simulation for LuaLaTeX local M = {} -- Machado matrices (Machado, Oliveira & Fernandes 2009) -- Physiologically accurate, supports severity levels 0.0-1.0 M.machado_matrices_rgb = { protanopia = { { { 1.000000, -0.000000, 0.000000 }, { -0.000000, 1.000000, 0.000000 }, { 0.000000, -0.000000, 1.000000 } }, { { 0.911599, 0.056681, 0.031720 }, { 0.020794, 0.986365, -0.007159 }, { -0.000861, 0.000744, 1.000117 } }, { { 0.823455, 0.113194, 0.063350 }, { 0.041477, 0.972802, -0.014279 }, { -0.001717, 0.001485, 1.000232 } }, { { 0.735568, 0.169541, 0.094890 }, { 0.062049, 0.959311, -0.021360 }, { -0.002567, 0.002221, 1.000346 } }, { { 0.647935, 0.225723, 0.126341 }, { 0.082510, 0.945892, -0.028403 }, { -0.003411, 0.002954, 1.000457 } }, { { 0.560556, 0.281741, 0.157704 }, { 0.102862, 0.932544, -0.035406 }, { -0.004250, 0.003683, 1.000567 } }, { { 0.473427, 0.337595, 0.188978 }, { 0.123105, 0.919267, -0.042372 }, { -0.005083, 0.004408, 1.000675 } }, { { 0.386549, 0.393287, 0.220164 }, { 0.143240, 0.906060, -0.049299 }, { -0.005911, 0.005129, 1.000782 } }, { { 0.299919, 0.448817, 0.251264 }, { 0.163267, 0.892922, -0.056189 }, { -0.006733, 0.005847, 1.000886 } }, { { 0.213537, 0.504187, 0.282277 }, { 0.183186, 0.879855, -0.063041 }, { -0.007550, 0.006561, 1.000989 } }, { { 0.127399, 0.559397, 0.313203 }, { 0.203000, 0.866856, -0.069856 }, { -0.008362, 0.007272, 1.001090 } }, }, deuteranopia = { { { 1.000000, -0.000000, 0.000000 }, { -0.000000, 1.000000, 0.000000 }, { 0.000000, -0.000000, 1.000000 } }, { { 0.924223, 0.049660, 0.026117 }, { 0.043016, 0.972174, -0.015189 }, { -0.002848, 0.001797, 1.001051 } }, { { 0.848967, 0.098982, 0.052051 }, { 0.085811, 0.944490, -0.030301 }, { -0.005678, 0.003582, 1.002096 } }, { { 0.774227, 0.147968, 0.077805 }, { 0.128388, 0.916948, -0.045336 }, { -0.008490, 0.005356, 1.003134 } }, { { 0.699999, 0.196622, 0.103379 }, { 0.170749, 0.889547, -0.060296 }, { -0.011284, 0.007117, 1.004167 } }, { { 0.626278, 0.244947, 0.128775 }, { 0.212895, 0.862285, -0.075180 }, { -0.014060, 0.008867, 1.005193 } }, { { 0.553060, 0.292945, 0.153995 }, { 0.254829, 0.835161, -0.089990 }, { -0.016818, 0.010605, 1.006212 } }, { { 0.480341, 0.340619, 0.179040 }, { 0.296551, 0.808174, -0.104725 }, { -0.019558, 0.012332, 1.007226 } }, { { 0.408117, 0.387971, 0.203912 }, { 0.338065, 0.781323, -0.119388 }, { -0.022281, 0.014048, 1.008233 } }, { { 0.336383, 0.435006, 0.228612 }, { 0.379371, 0.754606, -0.133977 }, { -0.024987, 0.015752, 1.009235 } }, { { 0.265135, 0.481724, 0.253141 }, { 0.420471, 0.728023, -0.148494 }, { -0.027676, 0.017445, 1.010231 } }, }, tritanopia = { { { 1.000000, -0.000000, 0.000000 }, { -0.000000, 1.000000, 0.000000 }, { 0.000000, -0.000000, 1.000000 } }, { { 1.078539, -0.349929, 0.271389 }, { -0.036423, 1.137499, -0.101076 }, { 0.025785, 0.046459, 0.927756 } }, { { 1.172861, -0.694350, 0.521489 }, { -0.077972, 1.275336, -0.197365 }, { 0.042469, 0.075899, 0.881632 } }, { { 1.234520, -0.882024, 0.647504 }, { -0.104691, 1.349180, -0.244490 }, { 0.050502, 0.100165, 0.849334 } }, { { 1.214354, -0.773039, 0.558685 }, { -0.096090, 1.299758, -0.203668 }, { 0.048774, 0.128650, 0.822576 } }, { { 1.155018, -0.533293, 0.378275 }, { -0.070450, 1.195354, -0.124905 }, { 0.041512, 0.163224, 0.795264 } }, { { 1.106345, -0.332051, 0.225706 }, { -0.049158, 1.105398, -0.056240 }, { 0.033870, 0.207348, 0.758782 } }, { { 1.065113, -0.161143, 0.096030 }, { -0.030844, 1.026371, 0.004473 }, { 0.025594, 0.261943, 0.712462 } }, { { 1.025007, -0.003698, -0.021309 }, { -0.012593, 0.951133, 0.061460 }, { 0.014697, 0.328098, 0.657205 } }, { { 1.004782, 0.083944, -0.088726 }, { -0.003131, 0.900773, 0.102358 }, { 0.007515, 0.420126, 0.572359 } }, { { 1.018830, 0.084755, -0.103585 }, { -0.009972, 0.882426, 0.127546 }, { 0.014249, 0.537394, 0.448358 } }, }, } M.machado_matrices_cmy = { protanopia = { { { 1.000000, 0.000000, 0.000000 }, { -0.000000, 1.000000, -0.000000 }, { -0.000000, -0.000000, 1.000000 } }, { { 0.976501, 0.075529, -0.052030 }, { 0.056760, 0.762797, 0.180443 }, { 0.002785, 0.029908, 0.967307 } }, { { 0.957714, 0.126055, -0.083769 }, { 0.098769, 0.605268, 0.295963 }, { 0.007807, 0.047856, 0.944337 } }, { { 0.941589, 0.162628, -0.104217 }, { 0.132563, 0.492155, 0.375282 }, { 0.014087, 0.059127, 0.926787 } }, { { 0.927131, 0.190618, -0.117749 }, { 0.161320, 0.406323, 0.432357 }, { 0.021149, 0.066287, 0.912563 } }, { { 0.913795, 0.212950, -0.126745 }, { 0.186770, 0.338443, 0.474787 }, { 0.028737, 0.070735, 0.900528 } }, { { 0.901262, 0.231350, -0.132612 }, { 0.209937, 0.283001, 0.507062 }, { 0.036696, 0.073295, 0.890008 } }, { { 0.889328, 0.246907, -0.136235 }, { 0.231464, 0.236529, 0.532008 }, { 0.044933, 0.074487, 0.880580 } }, { { 0.877859, 0.260339, -0.138198 }, { 0.251777, 0.196735, 0.551488 }, { 0.053383, 0.074654, 0.871963 } }, { { 0.866762, 0.272139, -0.138901 }, { 0.271169, 0.162045, 0.566785 }, { 0.062003, 0.074031, 0.863965 } }, { { 0.855973, 0.282657, -0.138631 }, { 0.289849, 0.131343, 0.578809 }, { 0.070765, 0.072786, 0.856450 } }, }, deuteranopia = { { { 1.000000, 0.000000, 0.000000 }, { -0.000000, 1.000000, -0.000000 }, { -0.000000, -0.000000, 1.000000 } }, { { 0.987330, 0.092576, -0.079906 }, { 0.046933, 0.703985, 0.249082 }, { -0.014206, 0.048068, 0.966138 } }, { { 0.982160, 0.144782, -0.126942 }, { 0.070014, 0.536751, 0.393235 }, { -0.024817, 0.076827, 0.947990 } }, { { 0.980528, 0.177991, -0.158519 }, { 0.081883, 0.430101, 0.488016 }, { -0.033729, 0.096496, 0.937233 } }, { { 0.980850, 0.200751, -0.181601 }, { 0.087597, 0.356761, 0.555642 }, { -0.041697, 0.111161, 0.930536 } }, { { 0.982369, 0.217148, -0.199517 }, { 0.089571, 0.303691, 0.606738 }, { -0.049086, 0.122776, 0.926310 } }, { { 0.984676, 0.229383, -0.214059 }, { 0.089109, 0.263867, 0.647024 }, { -0.056089, 0.132393, 0.923696 } }, { { 0.987532, 0.238747, -0.226279 }, { 0.086971, 0.233175, 0.679854 }, { -0.062822, 0.140630, 0.922193 } }, { { 0.990789, 0.246044, -0.236834 }, { 0.083636, 0.209042, 0.707322 }, { -0.069356, 0.147871, 0.921486 } }, { { 0.994348, 0.251806, -0.246154 }, { 0.079416, 0.189777, 0.730807 }, { -0.075739, 0.154370, 0.921369 } }, { { 0.998143, 0.256394, -0.254536 }, { 0.074525, 0.174226, 0.751250 }, { -0.082002, 0.160300, 0.921702 } }, }, tritanopia = { { { 1.000000, 0.000000, 0.000000 }, { -0.000000, 1.000000, -0.000000 }, { -0.000000, -0.000000, 1.000000 } }, { { 0.463938, -1.536453, 2.072515 }, { 1.622093, 5.804055, -6.426149 }, { -0.232077, -0.707570, 1.939647 } }, { { 1.191361, 0.675709, -0.867070 }, { -0.765701, -1.339155, 3.104856 }, { 0.133953, 0.373170, 0.492878 } }, { { 1.087855, 0.405787, -0.493643 }, { -0.491753, -0.553315, 2.045068 }, { 0.099890, 0.264781, 0.635329 } }, { { 1.042385, 0.312914, -0.355300 }, { -0.401325, -0.326727, 1.728052 }, { 0.093118, 0.239480, 0.667402 } }, { { 1.005746, 0.258921, -0.264666 }, { -0.344034, -0.220207, 1.564241 }, { 0.091926, 0.231674, 0.676401 } }, { { 0.967716, 0.221168, -0.188884 }, { -0.292111, -0.161813, 1.453923 }, { 0.092753, 0.230619, 0.676627 } }, { { 0.922826, 0.192513, -0.115339 }, { -0.232810, -0.128342, 1.361152 }, { 0.094275, 0.232791, 0.672934 } }, { { 0.867190, 0.169982, -0.037172 }, { -0.157260, -0.109553, 1.266813 }, { 0.095598, 0.236559, 0.667843 } }, { { 0.798584, 0.152421, 0.048995 }, { -0.059105, -0.100846, 1.159952 }, { 0.095863, 0.241122, 0.663015 } }, { { 0.715843, 0.140349, 0.143808 }, { 0.067528, -0.103242, 1.035714 }, { 0.093922, 0.246554, 0.659524 } }, }, } -- Current simulation state M.current_type = nil M.current_severity = 1.0 M.enabled = false M.graphics_hook_enabled = true -- Hook into PDF graphics by default M.graphics_convert_enabled = false -- Don't auto-convert raster graphics by default -- Clamp value to [0,1] range local function clamp(value) if value < 0 then return 0 end if value > 1 then return 1 end return value end -- Get interpolated matrix for Machado algorithm local function get_machado_matrix(color_model, cvd_type, severity) local matrices = nil if color_model == "rgb" then matrices = M.machado_matrices_rgb[cvd_type] elseif color_model == "cmy" then matrices = M.machado_matrices_cmy[cvd_type] end if not matrices then return nil end -- Scale severity to 0-10 range local scaled = severity * 10 local fl = math.floor(scaled) local ce = math.ceil(scaled) -- Clamp to valid range fl = math.max(0, math.min(10, fl)) ce = math.max(0, math.min(10, ce)) local mat_lo = matrices[fl + 1] -- Lua arrays are 1-indexed local mat_hi = matrices[ce + 1] -- Interpolate between matrices local t = scaled - fl local result = {} for i = 1, 3 do result[i] = {} for j = 1, 3 do result[i][j] = mat_lo[i][j] + (mat_hi[i][j] - mat_lo[i][j]) * t end end return result end -- Apply CVD transformation matrix to RGB or CMY triple function M.transform(color_model, c1, c2, c3) if not M.enabled or M.current_type == nil then return c1, c2, c3 end -- Get interpolated Machado matrix local matrix = get_machado_matrix(color_model, M.current_type, M.current_severity) if not matrix then texio.write_nl("CVD Warning: Unknown deficiency type '" .. tostring(M.current_type) .. "'") return c1, c2, c3 end -- Apply matrix transformation local c1_new = matrix[1][1] * c1 + matrix[1][2] * c2 + matrix[1][3] * c3 local c2_new = matrix[2][1] * c1 + matrix[2][2] * c2 + matrix[2][3] * c3 local c3_new = matrix[3][1] * c1 + matrix[3][2] * c2 + matrix[3][3] * c3 return clamp(c1_new), clamp(c2_new), clamp(c3_new) end -- Set deficiency type function M.set_type(deficiency_type) if M.machado_matrices_rgb[deficiency_type] then M.current_type = deficiency_type M.enabled = true else local error_msg = string.format("Unknown CVD type '%s'. Valid types: protanopia, deuteranopia, tritanopia", deficiency_type) tex.error(error_msg) end end -- Set severity level (0.0 = normal, 1.0 = full simulation) function M.set_severity(severity) severity = tonumber(severity) if severity and severity >= 0 and severity <= 1 then M.current_severity = severity else tex.error(string.format("Invalid severity '%s'. Must be between 0.0 and 1.0", tostring(severity))) end end -- Enable simulation function M.enable() M.enabled = true end -- Disable simulation function M.disable() M.enabled = false end -- Enable graphics hook (PDF transformation via callback) function M.enable_graphics_hook() M.graphics_hook_enabled = true end -- Disable graphics hook (PDF transformation via callback) function M.disable_graphics_hook() M.graphics_hook_enabled = false end -- Enable graphics convert (raster image external conversion) function M.enable_graphics_convert() M.graphics_convert_enabled = true end -- Disable graphics convert (raster image external conversion) function M.disable_graphics_convert() M.graphics_convert_enabled = false end -- Apply CVD transformation to current color function M.transform_current_color(color_str) -- \current@color contains PDF color operators like -- "1 0 0 rg 1 0 0 RG" in RGB or "1 0 0 0 k 1 0 0 0 K" in CMYK -- Extract just the color model ("rgb" or "cmy") and the corresponding -- channel values R G B or C M Y K (denoted c1, c2, c3, c4 for CMYK where c4=K is kept unchanged) local color_model = ( string.match(color_str, "rg") or string.match(color_str, "RG") or string.match(color_str, "k") or string.match(color_str, "K") ) if color_model == "rg" or color_model == "RG" then color_model = "rgb" elseif color_model == "k" or color_model == "K" then color_model = "cmy" end local transformed = color_str -- For CMYK (k/K operators), we need 4 values (C M Y K), for RGB (rg/RG operators) we need 3 values (R G B) if color_model == "cmy" then -- transform the first values (usually fill) - CMYK has 4 values local f1, f2, f3, f4 = string.match(color_str, "^(%d*%.?%d+)%s+(%d*%.?%d+)%s+(%d*%.?%d+)%s+(%d*%.?%d+)") if f1 and f2 and f3 and f4 and M.enabled and M.current_type then f1, f2, f3, f4 = tonumber(f1), tonumber(f2), tonumber(f3), tonumber(f4) if not f1 or not f2 or not f3 or not f4 then return color_str end -- Transform only C, M, Y components (first 3), keep K component (4th) unchanged local f1_new, f2_new, f3_new = M.transform(color_model, f1, f2, f3) -- Replace the CMYK values in the original string, preserving K transformed = string.gsub( color_str, "^%d*%.?%d+ +%d*%.?%d+ +%d*%.?%d+ +%d*%.?%d+", string.format("%.6f %.6f %.6f %s", f1_new, f2_new, f3_new, f4), 1 ) end -- check if there are more values (usually draw) and transform them as well local d1, d2, d3, d4 = string.match(transformed, " +[a-zA-Z]+%s+(%d*%.?%d+)%s+(%d*%.?%d+)%s+(%d*%.?%d+)%s+(%d*%.?%d+)") if d1 and d2 and d3 and d4 and M.enabled and M.current_type then d1, d2, d3, d4 = tonumber(d1), tonumber(d2), tonumber(d3), tonumber(d4) if not d1 or not d2 or not d3 or not d4 then return color_str end -- Transform only C, M, Y components (first 3), keep K component (4th) unchanged local d1_new, d2_new, d3_new = M.transform(color_model, d1, d2, d3) -- Replace the CMYK values in the original string, preserving K transformed = string.gsub( transformed, " +([a-zA-Z]+) +%d*%.?%d+ +%d*%.?%d+ +%d*%.?%d+ +%d*%.?%d+", function(op) return string.format(" %s %.6f %.6f %.6f %s", op, d1_new, d2_new, d3_new, d4) end, 1 ) end else -- RGB color model - 3 values -- transform the first values (usually fill) local f1, f2, f3 = string.match(color_str, "^(%d*%.?%d+)%s+(%d*%.?%d+)%s+(%d*%.?%d+)") if f1 and f2 and f3 and M.enabled and M.current_type then f1, f2, f3 = tonumber(f1), tonumber(f2), tonumber(f3) if not f1 or not f2 or not f3 then return color_str end local f1_new, f2_new, f3_new = M.transform(color_model, f1, f2, f3) -- Replace the RGB values in the original string transformed = string.gsub( color_str, "^%d*%.?%d+ +%d*%.?%d+ +%d*%.?%d+", string.format("%.6f %.6f %.6f", f1_new, f2_new, f3_new), 1 ) end -- check if there are more values (usually draw) and transform them as well local d1, d2, d3 = string.match(transformed, " +[a-zA-Z]+%s+(%d*%.?%d+)%s+(%d*%.?%d+)%s+(%d*%.?%d+)") if d1 and d2 and d3 and M.enabled and M.current_type then d1, d2, d3 = tonumber(d1), tonumber(d2), tonumber(d3) if not d1 or not d2 or not d3 then return color_str end local d1_new, d2_new, d3_new = M.transform(color_model, d1, d2, d3) -- Replace the RGB values in the original string transformed = string.gsub(transformed, " +([a-zA-Z]+) +%d*%.?%d+ +%d*%.?%d+ +%d*%.?%d+", function(op) return string.format(" %s %.6f %.6f %.6f", op, d1_new, d2_new, d3_new) end, 1) end end return transformed end -- Transform an RGB tuple emitted by pgf for shading /C0 /C1 arrays. -- Inputs are the three component strings as passed to \pgf@getrgb@@. -- Returns a space-separated PDF tuple suitable for embedding in a Function -- dictionary. When dichromacy is disabled, the original strings are returned -- unchanged. function M.transform_pgf_rgb(r, g, b) local nr, ng, nb = tonumber(r), tonumber(g), tonumber(b) -- Pass the original strings through unchanged when dichromacy is disabled or -- when a component is not a parseable number (rather than silently -- coercing it to 0, which would emit a wrong color). if not (M.enabled and M.current_type) or not (nr and ng and nb) then return string.format("%s %s %s", r, g, b) end nr, ng, nb = M.transform("rgb", nr, ng, nb) return string.format("%.6f %.6f %.6f", nr, ng, nb) end -- Transform a CMYK tuple emitted by pgf for shading /C0 /C1 arrays. -- The K component is preserved unchanged, matching transform_current_color. function M.transform_pgf_cmyk(c, m, y, k) local nc, nm, ny = tonumber(c), tonumber(m), tonumber(y) -- Pass the original strings through unchanged when dichromacy is disabled or -- when a component is not a parseable number. The K component is left -- as-is regardless, matching transform_current_color. if not (M.enabled and M.current_type) or not (nc and nm and ny) then return string.format("%s %s %s %s", c, m, y, k) end nc, nm, ny = M.transform("cmy", nc, nm, ny) return string.format("%.6f %.6f %.6f %s", nc, nm, ny, k) end -- Wrap a space-separated tuple ("a b c") in the brace-grouped form pgf uses -- for its system-layer colour records ("{a}{b}{c}"). local function brace_tuple(tuple) return "{" .. tuple:gsub(" ", "}{") .. "}" end -- Set both pgf macros for an RGB shading tuple from a single transform, so -- they can never disagree: -- \pgf@rgb (space-separated) feeds the PDF /Function arrays /C0 /C1 -- consumed by the pdf/luatex driver. -- \pgf@sys@rgb (brace-grouped) feeds the system-layer colour records -- (\pgf@sys@shading@start@rgb etc.) consumed by the dvisvgm -- driver. The luatex PDF driver ignores these, but keeping -- them transformed avoids an inconsistency under dvilualatex. function M.set_pgf_rgb(r, g, b) local tuple = M.transform_pgf_rgb(r, g, b) token.set_macro("pgf@rgb", tuple) token.set_macro("pgf@sys@rgb", brace_tuple(tuple)) end -- CMYK counterpart of set_pgf_rgb, setting \pgf@cmyk and \pgf@sys@cmyk. function M.set_pgf_cmyk(c, m, y, k) local tuple = M.transform_pgf_cmyk(c, m, y, k) token.set_macro("pgf@cmyk", tuple) token.set_macro("pgf@sys@cmyk", brace_tuple(tuple)) end -- Transform RGB color operators in PDF page content streams -- NOTE: This function modifies the uncompressed PDF stream content. Due to limitations -- in LuaTeX's process_pdf_image_content callback, the stream length may not always be -- correctly updated, which can cause truncation if the stream grows significantly. -- To mitigate this, we: -- 1. Preserve original number format when colors don't change -- 2. Use minimal precision (4 decimal places) and strip trailing zeros -- 3. Warn if stream grows by more than 100 bytes -- For PDFs with many color transformations, consider using \cvdincludegraphics with -- raster image formats (PNG/JPG) instead, which are processed externally. function M.process_pdf_image_content(stream) if not M.enabled or not M.current_type or not M.graphics_hook_enabled then return stream end local original_length = #stream -- Helper function to format numbers with minimal digits local function format_short(v) local s = string.format("%.4f", v) s = s:gsub("0+$", ""):gsub("%.$", "") return s end -- Transform colors for any of the supported operators -- Match after space or line start, require non-letter after operator to avoid matching to text -- TODO: handle the separable color operators scn/SCN. Only the device-color -- operators rg/RG (RGB) and k/K (CMYK) are matched below; spot/separation, -- ICCBased, and pattern colors set via scn/SCN pass through untransformed. -- This is non-trivial: scn/SCN operand counts are variable and depend on the -- color space selected earlier via cs/CS (and an scn may carry a trailing -- pattern name), so it needs color-space tracking rather than a fixed-arity -- pattern. Typical xcolor/TikZ output uses rg/k, so this is a lower-impact gap. -- Handle CMYK with 4 values (C M Y K) first. -- The leading delimiter is captured and re-emitted, but the trailing -- boundary after the operator is matched with a zero-width frontier pattern -- (%f) rather than consumed. This leaves the delimiter in place so an -- immediately following stroke operator (e.g. "... k 0 1 0 0.3 K", as pgf -- emits) still has a leading delimiter to match against. Operand separators -- use %s+ so multiple spaces/newlines between values are handled. stream = string.gsub( stream, "([^a-zA-Z%d.])(%d*%.?%d+)%s+(%d*%.?%d+)%s+(%d*%.?%d+)%s+(%d*%.?%d+)%s+([kK])%f[^a-zA-Z%d.]", function(prefix, c1, c2, c3, c4, op) local color_model = "cmy" local c1_str, c2_str, c3_str, c4_str = c1, c2, c3, c4 c1, c2, c3 = tonumber(c1), tonumber(c2), tonumber(c3) if c1 and c2 and c3 and c1 >= 0 and c1 <= 1 and c2 >= 0 and c2 <= 1 and c3 >= 0 and c3 <= 1 then local c1_new, c2_new, c3_new = M.transform(color_model, c1, c2, c3) if math.abs(c1_new - c1) < 0.000001 and math.abs(c2_new - c2) < 0.000001 and math.abs(c3_new - c3) < 0.000001 then return prefix .. c1_str .. " " .. c2_str .. " " .. c3_str .. " " .. c4_str .. " " .. op end return string.format( "%s%s %s %s %s %s", prefix, format_short(c1_new), format_short(c2_new), format_short(c3_new), c4_str, op ) end return prefix .. c1_str .. " " .. c2_str .. " " .. c3_str .. " " .. c4_str .. " " .. op end ) -- Handle RGB with 3 values. Same frontier/whitespace handling as the CMYK -- branch above so adjacent "... rg 1 0 0 RG" pairs and multi-space operand -- separators are both transformed. stream = string.gsub( stream, "([^a-zA-Z%d.])(%d*%.?%d+)%s+(%d*%.?%d+)%s+(%d*%.?%d+)%s+([rR][gG])%f[^a-zA-Z%d.]", function(prefix, c1, c2, c3, op) local color_model = "rgb" local c1_str, c2_str, c3_str = c1, c2, c3 c1, c2, c3 = tonumber(c1), tonumber(c2), tonumber(c3) if c1 and c2 and c3 and c1 >= 0 and c1 <= 1 and c2 >= 0 and c2 <= 1 and c3 >= 0 and c3 <= 1 then local c1_new, c2_new, c3_new = M.transform(color_model, c1, c2, c3) if math.abs(c1_new - c1) < 0.000001 and math.abs(c2_new - c2) < 0.000001 and math.abs(c3_new - c3) < 0.000001 then return prefix .. c1_str .. " " .. c2_str .. " " .. c3_str .. " " .. op end return string.format( "%s%s %s %s %s", prefix, format_short(c1_new), format_short(c2_new), format_short(c3_new), op ) end return prefix .. c1_str .. " " .. c2_str .. " " .. c3_str .. " " .. op end ) local new_length = #stream local growth = new_length - original_length -- Warn if stream grew significantly (may cause issues with some PDF readers) if growth > 100 then texio.write_nl( string.format( "CVD Warning: PDF stream grew by %d bytes. This may cause rendering issues in some viewers.", growth ) ) end return stream end -- Install the PDF image content hook function M.install_pdf_image_hook() -- Enable PDF stream recompression (required for the callback to work) pdf.setrecompress(1) -- Register the callback using luatexbase for LaTeX compatibility luatexbase.add_to_callback("process_pdf_image_content", M.process_pdf_image_content, "dichromacy_pdf_transform") end -- Check if ImageMagick is available M.imagemagick_available = nil M.imagemagick_warning_shown = false function M.check_imagemagick() if M.imagemagick_available ~= nil then return M.imagemagick_available end -- Check for shell escape local shell_escape = status.shell_escape if shell_escape == 0 then if not M.imagemagick_warning_shown then texio.write_nl("CVD Warning: Shell escape disabled. Raster image transformation unavailable.") texio.write_nl(" Compile with: lualatex --shell-escape") M.imagemagick_warning_shown = true end M.imagemagick_available = false return false end -- Try to run ImageMagick local check_cmd = "magick -version 2>&1 || convert -version 2>&1" local handle = io.popen(check_cmd) if not handle then M.imagemagick_available = false return false end local result = handle:read("*a") handle:close() M.imagemagick_available = (result and result:match("ImageMagick")) ~= nil if not M.imagemagick_available and not M.imagemagick_warning_shown then texio.write_nl("CVD Warning: ImageMagick not found. Raster image transformation unavailable.") texio.write_nl(" Install with: apt install imagemagick (or brew install imagemagick)") M.imagemagick_warning_shown = true end return M.imagemagick_available end -- Get the CVD transformation matrix as ImageMagick format function M.get_imagemagick_matrix() if not M.enabled or not M.current_type then return nil end local matrix = get_machado_matrix("rgb", M.current_type, M.current_severity) if not matrix then return nil end -- ImageMagick color-matrix format: R1,G1,B1,R2,G2,B2,R3,G3,B3 return string.format( "%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f", matrix[1][1], matrix[1][2], matrix[1][3], matrix[2][1], matrix[2][2], matrix[2][3], matrix[3][1], matrix[3][2], matrix[3][3] ) end -- Transform a raster image file function M.transform_raster_image(input_file, output_file) if not M.check_imagemagick() then return false end local matrix = M.get_imagemagick_matrix() if not matrix then return false end -- Escape filenames for shell local input_esc = input_file:gsub('"', '\\"') local output_esc = output_file:gsub('"', '\\"') -- Try magick command first (IMv7), then convert (IMv6) local cmd = string.format( 'magick "%s" -color-matrix "%s" "%s" 2>&1 || convert "%s" -color-matrix "%s" "%s" 2>&1', input_esc, matrix, output_esc, input_esc, matrix, output_esc ) texio.write_nl("CVD: Transforming " .. input_file .. " -> " .. output_file) local handle = io.popen(cmd) if not handle then return false end local result = handle:read("*a") local success = handle:close() if not success and result and result ~= "" then texio.write_nl("CVD Warning: ImageMagick error: " .. result) return false end return true end -- Get the (possibly transformed) image path for includegraphics function M.get_image_path(img_path) -- Check if it's a raster image that needs transformation local ext = img_path:match("%.([^.]+)$") or "" ext = ext:lower() local is_raster = (ext == "png" or ext == "jpg" or ext == "jpeg") if not (is_raster and M.enabled and M.current_type) then return img_path end -- Find the actual file with kpse local full_path = kpse.find_file(img_path) if not full_path then full_path = img_path end -- Respect -output-directory if set local output_dir = status.output_directory local base_dir = output_dir or "." -- Create cache directory if it doesn't exist local cache_dir = base_dir .. "/.dichromacy-cache" local cache_stat = lfs.attributes(cache_dir) if not cache_stat then -- Create output_dir first if it doesn't exist if output_dir and not lfs.attributes(output_dir) then lfs.mkdir(output_dir) end lfs.mkdir(cache_dir) end -- Generate transformed filename with severity in cache directory local base = img_path:match("([^/\\]+)$") or img_path -- extract just the filename local name_only = base:match("(.+)%.[^.]+$") or base -- remove extension local severity_str = string.format("%.1f", M.current_severity) local transformed = cache_dir .. "/" .. name_only .. "-dichromacy-" .. M.current_type .. "-" .. severity_str .. "." .. ext -- Check if we need to transform (file doesn't exist or is older) local need_transform = true local orig_stat = lfs.attributes(full_path) local trans_stat = lfs.attributes(transformed) if trans_stat and orig_stat then need_transform = trans_stat.modification < orig_stat.modification end if need_transform then if M.transform_raster_image(full_path, transformed) then return transformed else return img_path end else return transformed end end -- Get interpolated Machado matrix formatted for ImageMagick -- Returns a comma-separated string suitable for ImageMagick's -color-matrix function M.get_machado_matrix_for_imagemagick(cvd_type, severity) local matrix = get_machado_matrix("rgb", cvd_type, severity) if not matrix then return nil end -- Format as comma-separated row-major order local parts = {} for i = 1, 3 do for j = 1, 3 do table.insert(parts, string.format("%.6f", matrix[i][j])) end end return table.concat(parts, ",") end return M