feat(phone-image): 添加字号倍数控制功能

- CSS: 新增 --pi-font-scale 变量,全量 font-size 支持 calc 缩放
- JS: config.fontScale 影响分页计算,applyFontScale() 同步CSS变量
- HTML: 渲染预览区 Slider 控件(0.5x~2.0x),拖动即时预览,松手完整渲染
- 后端: PhoneImage.php 新增 fontScale 配置字段和校验
- 所有保存路径(autoSave/save/generate)包含 fontScale 持久化
This commit is contained in:
augushong
2026-05-15 00:50:57 +08:00
parent 8ad90a28c0
commit 5a81385448
4 changed files with 104 additions and 38 deletions

View File

@@ -19,6 +19,7 @@ class PhoneImage implements PostOutputManagerInterface
'fontSize' => ['type' => 'number', 'default' => 14, 'min' => 10, 'max' => 24], 'fontSize' => ['type' => 'number', 'default' => 14, 'min' => 10, 'max' => 24],
'watermark' => ['type' => 'text', 'default' => ''], 'watermark' => ['type' => 'text', 'default' => ''],
'pageAlignments' => ['type' => 'json', 'default' => '{}'], 'pageAlignments' => ['type' => 'json', 'default' => '{}'],
'fontScale' => ['type' => 'number', 'default' => 1, 'min' => 0.5, 'max' => 2.0],
]; ];
} }
@@ -38,6 +39,12 @@ class PhoneImage implements PostOutputManagerInterface
return false; return false;
} }
} }
if (isset($config['fontScale'])) {
$scale = floatval($config['fontScale']);
if ($scale < 0.5 || $scale > 2.0) {
return false;
}
}
// watermark 是文本类型,无需特殊验证 // watermark 是文本类型,无需特殊验证
// pageAlignments 是 JSON 类型,存储时由框架自动序列化 // pageAlignments 是 JSON 类型,存储时由框架自动序列化

View File

@@ -11,11 +11,13 @@
/* --- CSS Custom Properties (小红书经典风格) --- */ /* --- CSS Custom Properties (小红书经典风格) --- */
:root { :root {
/* 字号缩放 */
--pi-font-scale: 1;
/* 字号 */ /* 字号 */
--pi-font-size-base: 14px; --pi-font-size-base: calc(14px * var(--pi-font-scale, 1));
--pi-font-size-title: 26px; --pi-font-size-title: calc(26px * var(--pi-font-scale, 1));
--pi-font-size-subtitle: 16px; --pi-font-size-subtitle: calc(16px * var(--pi-font-scale, 1));
--pi-font-size-small: 12px; --pi-font-size-small: calc(12px * var(--pi-font-scale, 1));
/* 行高 */ /* 行高 */
--pi-line-height: 1.8; --pi-line-height: 1.8;
/* 间距 */ /* 间距 */
@@ -66,7 +68,7 @@
position: absolute; position: absolute;
bottom: 60px; bottom: 60px;
right: 20px; right: 20px;
font-size: 12px; font-size: calc(12px * var(--pi-font-scale, 1));
color: rgba(0, 0, 0, 0.3); color: rgba(0, 0, 0, 0.3);
pointer-events: none; pointer-events: none;
white-space: nowrap; white-space: nowrap;
@@ -105,7 +107,7 @@
} }
.page-cover .cover-title { .page-cover .cover-title {
font-size: 28px; font-size: calc(28px * var(--pi-font-scale, 1));
font-weight: normal; font-weight: normal;
letter-spacing: 2px; letter-spacing: 2px;
line-height: 1.5; line-height: 1.5;
@@ -163,7 +165,7 @@
} }
.page-cover.no-cover-image .cover-no-img-title { .page-cover.no-cover-image .cover-no-img-title {
font-size: 36px; font-size: calc(36px * var(--pi-font-scale, 1));
font-weight: bold; font-weight: bold;
line-height: 1.4; line-height: 1.4;
color: var(--pi-color-text); color: var(--pi-color-text);
@@ -220,7 +222,7 @@
} }
.page-header .page-title { .page-header .page-title {
font-size: 20px; font-size: calc(20px * var(--pi-font-scale, 1));
font-weight: normal; font-weight: normal;
letter-spacing: 1px; letter-spacing: 1px;
color: #666; color: #666;
@@ -250,7 +252,7 @@
.page-content h2 { .page-content h2 {
font-weight: normal; font-weight: normal;
font-size: 18px; font-size: calc(18px * var(--pi-font-scale, 1));
letter-spacing: 1px; letter-spacing: 1px;
margin-top: 24px; margin-top: 24px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -259,28 +261,28 @@
.page-content h3 { .page-content h3 {
font-weight: normal; font-weight: normal;
font-size: 16px; font-size: calc(16px * var(--pi-font-scale, 1));
margin-top: 20px; margin-top: 20px;
margin-bottom: 10px; margin-bottom: 10px;
color: #555; color: #555;
} }
.page-content h4 { .page-content h4 {
font-size: 14px; font-size: calc(14px * var(--pi-font-scale, 1));
font-weight: bold; font-weight: bold;
margin-top: var(--pi-spacing-sm); margin-top: var(--pi-spacing-sm);
margin-bottom: 5px; margin-bottom: 5px;
} }
.page-content h5 { .page-content h5 {
font-size: 13px; font-size: calc(13px * var(--pi-font-scale, 1));
font-weight: bold; font-weight: bold;
margin-top: var(--pi-spacing-sm); margin-top: var(--pi-spacing-sm);
margin-bottom: 5px; margin-bottom: 5px;
} }
.page-content h6 { .page-content h6 {
font-size: 12px; font-size: calc(12px * var(--pi-font-scale, 1));
font-weight: bold; font-weight: bold;
margin-top: var(--pi-spacing-sm); margin-top: var(--pi-spacing-sm);
margin-bottom: 5px; margin-bottom: 5px;
@@ -310,7 +312,7 @@
border-top: none; border-top: none;
padding-top: 0; padding-top: 0;
margin-top: var(--pi-spacing-sm); margin-top: var(--pi-spacing-sm);
font-size: 11px; font-size: calc(11px * var(--pi-font-scale, 1));
color: #bbb; color: #bbb;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -330,7 +332,7 @@
.page-summary .summary-title { .page-summary .summary-title {
font-weight: normal; font-weight: normal;
letter-spacing: 2px; letter-spacing: 2px;
font-size: 22px; font-size: calc(22px * var(--pi-font-scale, 1));
margin-bottom: var(--pi-spacing); margin-bottom: var(--pi-spacing);
color: var(--pi-color-text); color: var(--pi-color-text);
} }
@@ -447,7 +449,7 @@
border-radius: 50%; border-radius: 50%;
background: var(--pi-color-accent); background: var(--pi-color-accent);
color: #fff; color: #fff;
font-size: 14px; font-size: calc(14px * var(--pi-font-scale, 1));
line-height: 20px; line-height: 20px;
text-align: center; text-align: center;
border: none; border: none;
@@ -473,7 +475,7 @@
} }
.page-break-marker .break-marker-label { .page-break-marker .break-marker-label {
font-size: 12px; font-size: calc(12px * var(--pi-font-scale, 1));
color: #ff4d4f; color: #ff4d4f;
user-select: none; user-select: none;
} }
@@ -488,7 +490,7 @@
border-radius: 50%; border-radius: 50%;
background: #ff4d4f; background: #ff4d4f;
color: #fff; color: #fff;
font-size: 14px; font-size: calc(14px * var(--pi-font-scale, 1));
line-height: 20px; line-height: 20px;
text-align: center; text-align: center;
border: none; border: none;
@@ -529,7 +531,7 @@
.preview-thumb-page-num { .preview-thumb-page-num {
display: block; display: block;
text-align: center; text-align: center;
font-size: 12px; font-size: calc(12px * var(--pi-font-scale, 1));
color: #999; color: #999;
margin-top: 6px; margin-top: 6px;
} }
@@ -547,7 +549,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: calc(14px * var(--pi-font-scale, 1));
color: #666; color: #666;
user-select: none; user-select: none;
border: none; border: none;
@@ -576,7 +578,7 @@
============================================ */ ============================================ */
/* 表格美化样式html2canvas兼容 */ /* 表格美化样式html2canvas兼容 */
.page-content table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13px; } .page-content table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: calc(13px * var(--pi-font-scale, 1)); }
.page-content th { background-color: #f0f0f0; font-weight: bold; padding: 8px 10px; border: 1px solid #ddd; text-align: left; } .page-content th { background-color: #f0f0f0; font-weight: bold; padding: 8px 10px; border: 1px solid #ddd; text-align: left; }
.page-content td { padding: 8px 10px; border: 1px solid #ddd; } .page-content td { padding: 8px 10px; border: 1px solid #ddd; }
.page-content tr:nth-child(even) td { background-color: #f9f9f9; } .page-content tr:nth-child(even) td { background-color: #f9f9f9; }
@@ -587,14 +589,14 @@
border-radius: 4px; border-radius: 4px;
padding: 12px; padding: 12px;
overflow-x: auto; overflow-x: auto;
font-size: 13px; font-size: calc(13px * var(--pi-font-scale, 1));
line-height: 1.6; line-height: 1.6;
margin: 10px 0; margin: 10px 0;
} }
.content-flow code { .content-flow code {
font-family: 'Courier New', Consolas, monospace; font-family: 'Courier New', Consolas, monospace;
font-size: 13px; font-size: calc(13px * var(--pi-font-scale, 1));
} }
.content-flow pre code { .content-flow pre code {
@@ -604,7 +606,7 @@
} }
/* 中间栏表格样式 */ /* 中间栏表格样式 */
.content-flow-block table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13px; } .content-flow-block table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: calc(13px * var(--pi-font-scale, 1)); }
.content-flow-block th { background-color: #f0f0f0; font-weight: bold; padding: 8px 10px; border: 1px solid #ddd; text-align: left; } .content-flow-block th { background-color: #f0f0f0; font-weight: bold; padding: 8px 10px; border: 1px solid #ddd; text-align: left; }
.content-flow-block td { padding: 8px 10px; border: 1px solid #ddd; } .content-flow-block td { padding: 8px 10px; border: 1px solid #ddd; }
.content-flow-block tr:nth-child(even) td { background-color: #f9f9f9; } .content-flow-block tr:nth-child(even) td { background-color: #f9f9f9; }
@@ -785,7 +787,7 @@ body > .page-header-right .layui-btn:not(.layui-btn-primary):not(.layui-btn-norm
/* 预览区标题字号 */ /* 预览区标题字号 */
#render-preview h1 { #render-preview h1 {
font-size: 22px; font-size: calc(22px * var(--pi-font-scale, 1));
font-weight: bold; font-weight: bold;
letter-spacing: 1px; letter-spacing: 1px;
margin-top: 24px; margin-top: 24px;
@@ -795,7 +797,7 @@ body > .page-header-right .layui-btn:not(.layui-btn-primary):not(.layui-btn-norm
#render-preview h2 { #render-preview h2 {
font-weight: normal; font-weight: normal;
font-size: 18px; font-size: calc(18px * var(--pi-font-scale, 1));
letter-spacing: 1px; letter-spacing: 1px;
margin-top: 24px; margin-top: 24px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -803,27 +805,27 @@ body > .page-header-right .layui-btn:not(.layui-btn-primary):not(.layui-btn-norm
#render-preview h3 { #render-preview h3 {
font-weight: normal; font-weight: normal;
font-size: 16px; font-size: calc(16px * var(--pi-font-scale, 1));
margin-top: 20px; margin-top: 20px;
margin-bottom: 10px; margin-bottom: 10px;
} }
#render-preview h4 { #render-preview h4 {
font-size: 14px; font-size: calc(14px * var(--pi-font-scale, 1));
font-weight: bold; font-weight: bold;
margin-top: 10px; margin-top: 10px;
margin-bottom: 5px; margin-bottom: 5px;
} }
#render-preview h5 { #render-preview h5 {
font-size: 13px; font-size: calc(13px * var(--pi-font-scale, 1));
font-weight: bold; font-weight: bold;
margin-top: 10px; margin-top: 10px;
margin-bottom: 5px; margin-bottom: 5px;
} }
#render-preview h6 { #render-preview h6 {
font-size: 12px; font-size: calc(12px * var(--pi-font-scale, 1));
font-weight: bold; font-weight: bold;
margin-top: 10px; margin-top: 10px;
margin-bottom: 5px; margin-bottom: 5px;
@@ -850,7 +852,7 @@ body > .page-header-right .layui-btn:not(.layui-btn-primary):not(.layui-btn-norm
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 10px 0; margin: 10px 0;
font-size: 13px; font-size: calc(13px * var(--pi-font-scale, 1));
} }
#render-preview th { #render-preview th {
@@ -877,14 +879,14 @@ body > .page-header-right .layui-btn:not(.layui-btn-primary):not(.layui-btn-norm
border-radius: 4px; border-radius: 4px;
padding: 12px; padding: 12px;
overflow-x: auto; overflow-x: auto;
font-size: 13px; font-size: calc(13px * var(--pi-font-scale, 1));
line-height: 1.6; line-height: 1.6;
margin: 10px 0; margin: 10px 0;
} }
#render-preview code { #render-preview code {
font-family: 'Courier New', Consolas, monospace; font-family: 'Courier New', Consolas, monospace;
font-size: 13px; font-size: calc(13px * var(--pi-font-scale, 1));
} }
#render-preview pre code { #render-preview pre code {

View File

@@ -19,7 +19,8 @@ var PhoneImageEngine = (function () {
xiaohongshu: { width: 540, height: 720 }, xiaohongshu: { width: 540, height: 720 },
douyin: { width: 540, height: 960 } douyin: { width: 540, height: 960 }
}, },
contentPadding: 20 contentPadding: 20,
fontScale: 1
}; };
// ===== 文章数据 ===== // ===== 文章数据 =====
@@ -71,6 +72,15 @@ var PhoneImageEngine = (function () {
if (userConfig) { if (userConfig) {
$.extend(config, userConfig); $.extend(config, userConfig);
} }
applyFontScale();
}
/**
* 应用字号缩放到CSS变量
*/
function applyFontScale() {
var scale = config.fontScale || 1;
document.documentElement.style.setProperty('--pi-font-scale', scale);
} }
/** /**
@@ -656,7 +666,7 @@ var PhoneImageEngine = (function () {
} }
// 按句子拆分:句号、问号、叹号、换行(兼容不支持 lookbehind 的浏览器) // 按句子拆分:句号、问号、叹号、换行(兼容不支持 lookbehind 的浏览器)
var effectiveFontSize = 14; var effectiveFontSize = 14 * (config.fontScale || 1);
var parts = text.split(/([。!?\n])/); var parts = text.split(/([。!?\n])/);
var sentences = []; var sentences = [];
var current = ''; var current = '';
@@ -1269,6 +1279,7 @@ var PhoneImageEngine = (function () {
config: { config: {
size: saveConfig.size || config.size, size: saveConfig.size || config.size,
watermark: saveConfig.watermark || config.watermark, watermark: saveConfig.watermark || config.watermark,
fontScale: config.fontScale || 1,
pageAlignments: config.pageAlignments || {} pageAlignments: config.pageAlignments || {}
}, },
content_html: saveConfig.content_html || postData.content_html content_html: saveConfig.content_html || postData.content_html
@@ -1488,6 +1499,7 @@ var PhoneImageEngine = (function () {
if (newConfig) { if (newConfig) {
$.extend(config, newConfig); $.extend(config, newConfig);
} }
applyFontScale();
} }
}; };
})(); })();

View File

@@ -165,7 +165,15 @@
<!-- 中间:渲染预览区 --> <!-- 中间:渲染预览区 -->
<div class="render-preview-area"> <div class="render-preview-area">
<div class="preview-header">渲染预览</div> <div class="preview-header">
<span>渲染预览</span>
<div style="display:inline-flex;align-items:center;gap:8px;float:right;">
<span style="font-size:12px;color:#666;">字号</span>
<input type="range" id="font-scale-slider" min="0.5" max="2.0" step="0.1" value="1.0"
style="width:100px;vertical-align:middle;">
<span id="font-scale-value" style="font-size:12px;color:#1890ff;min-width:32px;">1.0x</span>
</div>
</div>
<div id="render-preview"></div> <div id="render-preview"></div>
</div> </div>
@@ -195,7 +203,8 @@
// 当前排版配置(从设置弹框维护) // 当前排版配置(从设置弹框维护)
var currentConfig = { var currentConfig = {
size: 'xiaohongshu', size: 'xiaohongshu',
watermark: '' watermark: '',
fontScale: 1
}; };
var postData = { var postData = {
@@ -215,12 +224,14 @@
var initConfig = { var initConfig = {
size: savedConfig.size || 'xiaohongshu', size: savedConfig.size || 'xiaohongshu',
watermark: savedConfig.watermark || '', watermark: savedConfig.watermark || '',
pageAlignments: savedConfig.pageAlignments || {} pageAlignments: savedConfig.pageAlignments || {},
fontScale: savedConfig.fontScale || 1
}; };
// 同步当前配置 // 同步当前配置
currentConfig.size = initConfig.size; currentConfig.size = initConfig.size;
currentConfig.watermark = initConfig.watermark; currentConfig.watermark = initConfig.watermark;
currentConfig.fontScale = initConfig.fontScale;
// ========== wangeditor 初始化 ========== // ========== wangeditor 初始化 ==========
var E = window.wangEditor; var E = window.wangEditor;
@@ -331,6 +342,31 @@
// 初始同步预览区 // 初始同步预览区
PhoneImageEngine.syncPreview(); PhoneImageEngine.syncPreview();
// 字号倍数 Slider
var $slider = $('#font-scale-slider');
var $scaleValue = $('#font-scale-value');
// 拖动中: 仅更新CSS变量和显示值,不触发html2canvas
$slider.on('input', function() {
var val = parseFloat($(this).val());
$scaleValue.text(val.toFixed(1) + 'x');
currentConfig.fontScale = val;
document.documentElement.style.setProperty('--pi-font-scale', val);
PhoneImageEngine.updateConfig({ fontScale: val });
});
// 松手后: 触发完整渲染(重新分页 + html2canvas截图)
$slider.on('change', function() {
var val = parseFloat($(this).val());
doRender({ fontScale: val });
});
// 初始化时更新 Slider 显示值
if (initConfig.fontScale && initConfig.fontScale !== 1) {
$slider.val(initConfig.fontScale);
$scaleValue.text(parseFloat(initConfig.fontScale).toFixed(1) + 'x');
}
// ========== 设置弹框 ========== // ========== 设置弹框 ==========
$('#btn-settings').click(function () { $('#btn-settings').click(function () {
var settingsHtml = '<div class="settings-form" style="padding:20px 20px 0;">'; var settingsHtml = '<div class="settings-form" style="padding:20px 20px 0;">';
@@ -451,6 +487,7 @@
PhoneImageEngine.saveConfig(postData.postId, { PhoneImageEngine.saveConfig(postData.postId, {
size: currentConfig.size, size: currentConfig.size,
watermark: currentConfig.watermark, watermark: currentConfig.watermark,
fontScale: currentConfig.fontScale || 1,
content_html: PhoneImageEngine.getContentHtml() content_html: PhoneImageEngine.getContentHtml()
}, saveConfigUrl).then(function (data) { }, saveConfigUrl).then(function (data) {
if (data.output_id) lastOutputId = data.output_id; if (data.output_id) lastOutputId = data.output_id;
@@ -476,6 +513,7 @@
PhoneImageEngine.saveConfig(postData.postId, { PhoneImageEngine.saveConfig(postData.postId, {
size: currentConfig.size, size: currentConfig.size,
watermark: currentConfig.watermark, watermark: currentConfig.watermark,
fontScale: currentConfig.fontScale || 1,
content_html: PhoneImageEngine.getContentHtml() content_html: PhoneImageEngine.getContentHtml()
}, saveConfigUrl).then(function (data) { }, saveConfigUrl).then(function (data) {
if (data.output_id) lastOutputId = data.output_id; if (data.output_id) lastOutputId = data.output_id;
@@ -499,6 +537,7 @@
PhoneImageEngine.saveImages(postData.postId, { PhoneImageEngine.saveImages(postData.postId, {
size: currentConfig.size, size: currentConfig.size,
watermark: currentConfig.watermark, watermark: currentConfig.watermark,
fontScale: currentConfig.fontScale || 1,
content_html: PhoneImageEngine.getContentHtml() content_html: PhoneImageEngine.getContentHtml()
}).then(function (data) { }).then(function (data) {
if (data.output_id) { if (data.output_id) {
@@ -584,10 +623,16 @@
if (cfg.size) historyConfig.size = cfg.size; if (cfg.size) historyConfig.size = cfg.size;
if (cfg.watermark !== undefined) historyConfig.watermark = cfg.watermark; if (cfg.watermark !== undefined) historyConfig.watermark = cfg.watermark;
if (cfg.pageAlignments) historyConfig.pageAlignments = cfg.pageAlignments; if (cfg.pageAlignments) historyConfig.pageAlignments = cfg.pageAlignments;
if (cfg.fontScale !== undefined) historyConfig.fontScale = cfg.fontScale;
// 同步当前配置 // 同步当前配置
if (cfg.size) currentConfig.size = cfg.size; if (cfg.size) currentConfig.size = cfg.size;
if (cfg.watermark !== undefined) currentConfig.watermark = cfg.watermark; if (cfg.watermark !== undefined) currentConfig.watermark = cfg.watermark;
if (cfg.fontScale !== undefined) {
currentConfig.fontScale = cfg.fontScale;
$slider.val(cfg.fontScale);
$scaleValue.text(parseFloat(cfg.fontScale).toFixed(1) + 'x');
}
lastOutputId = outputId; lastOutputId = outputId;
layer.closeAll(); layer.closeAll();