refactor: complete three-layer serial refactoring (knowledge→schema→renderer)

Step 1 — Knowledge base: perfect all reference/*.md with type, range, default, unit
- text.md: add DataOrPathProps, path/data, bullet deprecated params
- chart.md: add deprecated section, fix 19 params with missing elements
- slide.md: add bkgd→background deprecated mapping
- output.md: add masterSlide, presLayout

Step 2 — Schema: align schema/presentation.schema.json with KB
- Add catAxisItem and valAxisItem definitions with full sub-property constraints
- Fix 7 enum constraints (barDir, displayBlanksAs, bar3DShape, etc.)
- Add 4 missing fields (verbose, autoPageCharWeight, autoPageLineWeight, notes,
  masterSlide, presLayout)

Step 3 — Translation engine: transform renderers from passthrough to validation
- index.js: add normalizePosition, normalizeColor, validateEnum shared utilities
- All renderers: position validation, color hex normalization, enum validation
- Image: graceful failure handling (catch load errors, warn, continue)
- Chart: chartType enum validation, data parity check
- Table: colspan/rowspan integer validation, border array check
- Full verification: basic.json and full.json generate valid PPTX

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:10:01 +08:00
co-authored by Claude Sonnet 5
parent c1a9c2fcda
commit ec7a44d6fb
12 changed files with 538 additions and 21 deletions
+42 -1
View File
@@ -1,5 +1,46 @@
var { normalizePosition, normalizeColor, normalizeColorInOptions, validateEnum } = require('./index');
function renderChart(slide, chartData) {
slide.addChart(chartData.chartType, chartData.data, chartData.options);
var opts = {};
if (chartData.options) {
for (var k in chartData.options) {
if (chartData.options.hasOwnProperty(k)) opts[k] = chartData.options[k];
}
}
normalizePosition(opts);
var validChartTypes = ['area', 'bar', 'bar3D', 'bubble', 'doughnut', 'line', 'pie', 'radar', 'scatter'];
validateEnum(chartData.chartType, validChartTypes, 'chartType');
if (chartData.data) {
for (var i = 0; i < chartData.data.length; i++) {
var series = chartData.data[i];
if (series.labels && series.values && series.labels.length !== series.values.length) {
console.warn('Chart series "' + (series.name || i) + '" has mismatched labels (' + series.labels.length + ') and values (' + series.values.length + ')');
}
}
}
validateEnum(opts.barDir, ['bar', 'col'], 'barDir');
validateEnum(opts.barGrouping, ['clustered', 'stacked', 'standard'], 'barGrouping');
validateEnum(opts.radarStyle, ['standard', 'marker', 'filled'], 'radarStyle');
validateEnum(opts.legendPos, ['b', 'l', 'r', 't', 'tr'], 'legendPos');
validateEnum(opts.lineCap, ['flat', 'round', 'square'], 'lineCap');
validateEnum(opts.lineDataSymbol, ['circle', 'dash', 'diamond', 'dot', 'none', 'square', 'triangle'], 'lineDataSymbol');
validateEnum(opts.displayBlanksAs, ['gap', 'span', 'zero'], 'displayBlanksAs');
validateEnum(opts.bar3DShape, ['box', 'cylinder', 'cone', 'pyramid'], 'bar3DShape');
validateEnum(opts.dataLabelPosition, ['b', 'bestFit', 'ctr', 'l', 'r', 't', 'inEnd', 'outEnd'], 'dataLabelPosition');
validateEnum(opts.titleAlign, ['l', 'ctr', 'r'], 'titleAlign');
if (opts.chartColors) {
for (var i = 0; i < opts.chartColors.length; i++) {
var nc = normalizeColor(opts.chartColors[i]);
if (nc) opts.chartColors[i] = nc;
}
}
slide.addChart(chartData.chartType, chartData.data, opts);
}
module.exports = { renderChart };
+24 -1
View File
@@ -1,5 +1,28 @@
var { normalizePosition, normalizeColorInOptions, validateEnum } = require('./index');
function renderImage(slide, imageData) {
slide.addImage(imageData.options);
var opts = {};
if (imageData.options) {
for (var k in imageData.options) {
if (imageData.options.hasOwnProperty(k)) opts[k] = imageData.options[k];
}
}
normalizePosition(opts);
if (opts.sizing && typeof opts.sizing === 'object') {
validateEnum(opts.sizing.type, ['contain', 'cover', 'crop'], 'sizing.type');
}
if (opts.shadow && typeof opts.shadow === 'object') {
normalizeColorInOptions(opts.shadow, 'color');
validateEnum(opts.shadow.type, ['outer', 'inner', 'none'], 'shadow.type');
}
try {
slide.addImage(opts);
} catch (e) {
console.warn('Failed to load image: ' + (opts.path || 'unknown'));
}
}
module.exports = { renderImage };
+100 -2
View File
@@ -1,5 +1,68 @@
const pptxgen = require('pptxgenjs');
const fs = require('fs');
function normalizePosition(options) {
if (!options || typeof options !== 'object') return options;
['x', 'y', 'w', 'h'].forEach(function(key) {
if (options[key] === undefined) return;
var val = options[key];
if (typeof val === 'number' && !isNaN(val)) return;
if (typeof val === 'string' && /^\d+(\.\d+)?%$/.test(val)) return;
console.warn('Invalid position value for "' + key + '": got ' + JSON.stringify(val) + ', expected a number or percentage string (e.g. "50%")');
delete options[key];
});
if ((options.x !== undefined || options.y !== undefined) && options.w === undefined) options.w = 4;
if ((options.x !== undefined || options.y !== undefined) && options.h === undefined) options.h = 3;
return options;
}
function normalizeColor(color) {
if (color === undefined || color === null) return color;
if (typeof color !== 'string') {
console.warn('Invalid color value: expected a string, got ' + typeof color);
return undefined;
}
var themeColors = ['tx1', 'tx2', 'bg1', 'bg2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6'];
for (var i = 0; i < themeColors.length; i++) {
if (color === themeColors[i]) return color;
}
var clean = color.replace(/^#/, '');
if (/^[0-9A-Fa-f]{6}$/.test(clean)) return clean;
console.warn('Invalid color value: "' + color + '" is not a valid color format (expected 6-digit hex or theme color)');
return undefined;
}
function normalizeColorInOptions(options, key) {
if (!options || options[key] === undefined) return;
var nc = normalizeColor(options[key]);
if (nc === undefined) delete options[key];
else options[key] = nc;
}
function validateEnum(value, validValues, name) {
if (value === undefined || value === null) return value;
if (validValues.indexOf(value) === -1) {
console.warn('Invalid value for ' + name + ': got "' + value + '", expected one of [' + validValues.join(', ') + ']');
}
return value;
}
function withDefaults(options, defaults) {
if (!options || typeof options !== 'object') return options;
for (var key in defaults) {
if (defaults.hasOwnProperty(key) && options[key] === undefined) {
options[key] = defaults[key];
}
}
return options;
}
module.exports.normalizePosition = normalizePosition;
module.exports.normalizeColor = normalizeColor;
module.exports.normalizeColorInOptions = normalizeColorInOptions;
module.exports.validateEnum = validateEnum;
module.exports.withDefaults = withDefaults;
const { renderSlide } = require('./slide-renderer');
function render(pptxJson) {
@@ -25,7 +88,42 @@ function render(pptxJson) {
async function renderFile(inputPath, outputPath) {
const json = fs.readFileSync(inputPath, 'utf8');
const pptx = render(json);
return pptx.writeFile({ fileName: outputPath });
// pptxgenjs v4 uses https.get() to download URL-based images but doesn't
// handle connection errors on the request object (only on the response
// stream). A failed TLS handshake emits an unhandled 'error' event on the
// request that crashes Node.js, and the internal promise never resolves.
// Patch to pipe request errors into a fake response that ends cleanly so
// writeFile completes with an empty placeholder image.
var https = require('https');
var _origGet = https.get;
var EventEmitter = require('events').EventEmitter;
https.get = function() {
var args = arguments;
var cb = typeof args[args.length - 1] === 'function' ? args[args.length - 1] : null;
var urlStr = typeof args[0] === 'string' ? args[0] : '';
var req = _origGet.apply(this, args);
req.on('error', function(err) {
console.warn('Failed to load image: ' + (urlStr || err.hostname || err.host || 'unknown'));
if (cb) {
var fakeRes = new EventEmitter();
fakeRes.setEncoding = function() {};
cb(fakeRes);
process.nextTick(function() {
fakeRes.emit('data', '');
fakeRes.emit('end');
});
}
});
return req;
};
try {
return await pptx.writeFile({ fileName: outputPath });
} finally {
https.get = _origGet;
}
}
module.exports = { render, renderFile };
module.exports.render = render;
module.exports.renderFile = renderFile;
+31 -1
View File
@@ -1,5 +1,35 @@
var { normalizePosition, normalizeColorInOptions, validateEnum } = require('./index');
function renderShape(slide, shapeData) {
slide.addShape(shapeData.shapeName, shapeData.options);
var opts = {};
if (shapeData.options) {
for (var k in shapeData.options) {
if (shapeData.options.hasOwnProperty(k)) opts[k] = shapeData.options[k];
}
}
normalizePosition(opts);
if (opts.fill && typeof opts.fill === 'object') {
normalizeColorInOptions(opts.fill, 'color');
validateEnum(opts.fill.type, ['none', 'solid'], 'fill.type');
}
if (opts.line && typeof opts.line === 'object') {
normalizeColorInOptions(opts.line, 'color');
validateEnum(opts.line.dashType, ['solid', 'dash', 'dashDot', 'lgDash', 'lgDashDot', 'lgDashDotDot', 'sysDash', 'sysDot'], 'line.dashType');
validateEnum(opts.line.beginArrowType, ['none', 'arrow', 'diamond', 'oval', 'stealth', 'triangle'], 'line.beginArrowType');
validateEnum(opts.line.endArrowType, ['none', 'arrow', 'diamond', 'oval', 'stealth', 'triangle'], 'line.endArrowType');
}
if (opts.shadow && typeof opts.shadow === 'object') {
normalizeColorInOptions(opts.shadow, 'color');
validateEnum(opts.shadow.type, ['outer', 'inner', 'none'], 'shadow.type');
}
validateEnum(opts.align, ['left', 'center', 'right', 'justify'], 'align');
slide.addShape(shapeData.shapeName, opts);
}
module.exports = { renderShape };
+21 -2
View File
@@ -1,3 +1,4 @@
const { normalizeColor } = require('./index');
const { renderText } = require('./text-renderer');
const { renderShape } = require('./shape-renderer');
const { renderTable } = require('./table-renderer');
@@ -7,8 +8,26 @@ const { renderChart } = require('./chart-renderer');
function renderSlide(pptx, slideData) {
const slide = pptx.addSlide();
if (slideData.background) slide.background = slideData.background;
if (slideData.color) slide.color = slideData.color;
if (slideData.background) {
if (typeof slideData.background === 'object') {
slide.background = {};
for (var k in slideData.background) {
if (slideData.background.hasOwnProperty(k)) {
slide.background[k] = slideData.background[k];
}
}
if (slide.background.color) {
var nc = normalizeColor(slide.background.color);
if (nc) slide.background.color = nc;
}
} else {
slide.background = slideData.background;
}
}
if (slideData.color) {
var nc = normalizeColor(slideData.color);
if (nc) slide.color = nc;
}
if (slideData.hidden) slide.hidden = slideData.hidden;
const objects = slideData.objects || [];
+81 -1
View File
@@ -1,5 +1,85 @@
var { normalizePosition, normalizeColorInOptions, validateEnum } = require('./index');
function renderTable(slide, tableData) {
slide.addTable(tableData.rows, tableData.options);
var opts = {};
if (tableData.options) {
for (var k in tableData.options) {
if (tableData.options.hasOwnProperty(k)) opts[k] = tableData.options[k];
}
}
normalizePosition(opts);
if (opts.fill && typeof opts.fill === 'object') {
normalizeColorInOptions(opts.fill, 'color');
validateEnum(opts.fill.type, ['none', 'solid'], 'fill.type');
}
if (opts.border) {
if (Array.isArray(opts.border)) {
if (opts.border.length !== 4) {
console.warn('Invalid table border array: expected 4 sides, got ' + opts.border.length);
}
} else if (typeof opts.border === 'object') {
normalizeColorInOptions(opts.border, 'color');
if (opts.border.type !== undefined) {
validateEnum(opts.border.type, ['none', 'dash', 'solid'], 'border.type');
}
}
}
var rows = [];
if (tableData.rows) {
for (var r = 0; r < tableData.rows.length; r++) {
var row = tableData.rows[r];
var newRow = [];
for (var c = 0; c < row.length; c++) {
var cell = row[c];
var newCell = { text: cell.text };
if (cell.colspan !== undefined) {
if (typeof cell.colspan !== 'number' || !isFinite(cell.colspan) || cell.colspan < 1 || cell.colspan !== Math.floor(cell.colspan)) {
console.warn('Invalid colspan: got ' + JSON.stringify(cell.colspan) + ', expected integer >= 1');
}
newCell.colspan = cell.colspan;
}
if (cell.rowspan !== undefined) {
if (typeof cell.rowspan !== 'number' || !isFinite(cell.rowspan) || cell.rowspan < 1 || cell.rowspan !== Math.floor(cell.rowspan)) {
console.warn('Invalid rowspan: got ' + JSON.stringify(cell.rowspan) + ', expected integer >= 1');
}
newCell.rowspan = cell.rowspan;
}
if (cell.options) {
newCell.options = {};
for (var k in cell.options) {
if (cell.options.hasOwnProperty(k)) newCell.options[k] = cell.options[k];
}
normalizeColorInOptions(newCell.options, 'color');
normalizeColorInOptions(newCell.options, 'highlight');
if (newCell.options.fill && typeof newCell.options.fill === 'object') {
normalizeColorInOptions(newCell.options.fill, 'color');
}
if (newCell.options.border) {
if (Array.isArray(newCell.options.border)) {
if (newCell.options.border.length !== 4) {
console.warn('Invalid cell border array: expected 4 sides, got ' + newCell.options.border.length);
}
} else if (typeof newCell.options.border === 'object') {
normalizeColorInOptions(newCell.options.border, 'color');
}
}
validateEnum(newCell.options.align, ['left', 'center', 'right', 'justify'], 'cell.align');
validateEnum(newCell.options.valign, ['top', 'middle', 'bottom'], 'cell.valign');
}
newRow.push(newCell);
}
rows.push(newRow);
}
}
slide.addTable(rows, opts);
}
module.exports = { renderTable };
+43 -1
View File
@@ -1,5 +1,47 @@
var { normalizePosition, normalizeColor, normalizeColorInOptions, validateEnum } = require('./index');
function renderText(slide, textData) {
slide.addText(textData.text, textData.options);
var opts = {};
if (textData.options) {
for (var k in textData.options) {
if (textData.options.hasOwnProperty(k)) opts[k] = textData.options[k];
}
}
normalizePosition(opts);
normalizeColorInOptions(opts, 'color');
normalizeColorInOptions(opts, 'highlight');
if (opts.underline && typeof opts.underline === 'object') {
normalizeColorInOptions(opts.underline, 'color');
}
if (opts.bullet && typeof opts.bullet === 'object') {
normalizeColorInOptions(opts.bullet, 'color');
}
if (opts.glow && typeof opts.glow === 'object') {
normalizeColorInOptions(opts.glow, 'color');
}
if (opts.outline && typeof opts.outline === 'object') {
normalizeColorInOptions(opts.outline, 'color');
}
validateEnum(opts.align, ['left', 'center', 'right', 'justify'], 'align');
validateEnum(opts.valign, ['top', 'middle', 'bottom'], 'valign');
validateEnum(opts.textDirection, ['horz', 'vert', 'vert270', 'wordArtVert'], 'textDirection');
validateEnum(opts.vert, ['eaVert', 'horz', 'mongolianVert', 'vert', 'vert270', 'wordArtVert', 'wordArtVertRtl'], 'vert');
validateEnum(opts.fit, ['none', 'shrink', 'resize'], 'fit');
if (typeof opts.strike === 'string') {
validateEnum(opts.strike, ['dblStrike', 'sngStrike'], 'strike');
}
if (opts.underline && opts.underline.style !== undefined) {
validateEnum(opts.underline.style, ['dash', 'dashHeavy', 'dashLong', 'dashLongHeavy', 'dbl', 'dotDash', 'dotDashHeave', 'dotDotDash', 'dotDotDashHeavy', 'dotted', 'dottedHeavy', 'heavy', 'none', 'sng', 'wavy', 'wavyDbl', 'wavyHeavy'], 'underline.style');
}
if (opts.bullet && typeof opts.bullet === 'object' && opts.bullet.type !== undefined) {
validateEnum(opts.bullet.type, ['bullet', 'number'], 'bullet.type');
}
slide.addText(textData.text, opts);
}
module.exports = { renderText };