Files
ulthon_information/docs/phone-image-architecture.md
augushong 30291a9dca docs(phone-image): 全面更新架构文档反映wangeditor方案
重写渲染管线、新增wangeditor集成章节、更新数据流/存储模型/配置体系、
新增UI布局/已删除函数/已知限制章节(373行新增,171行删除)
2026-05-11 23:40:20 +08:00

25 KiB
Raw Blame History

手机图片排版功能 - 架构文档

基于源代码实际阅读编写,记录排版引擎的设计原理与数据流。

1. 渲染管线

排版引擎的核心是 PhoneImageEngineIIFE 闭包,public/static/js/phone-image.js),纯前端运行,后端不参与渲染计算。

1.1 管线总览

用户触发 doRender() 或编辑器 onChange 自动触发
  |
  v
PhoneImageEngine.render()                      -- L82: 管线入口,带并发锁
  |
  +---> editorHtml = window.phoneImageEditor.getHtml()  -- L99: 从 wangeditor 读取HTML
  +---> preprocessContent(editorHtml)                    -- L154: 清洗HTML去script/style/iframe规范化img
  +---> parseHtmlToBlocks(cleanHtml)                     -- L201: HTML -> 块级元素数组,<hr> 识别为 page-break
  |
  +---> generateCoverPage(sizeConfig)                    -- L619: 生成封面页HTML
  |
  +---> captureEditorBlocks(editorHtml, blocks, areaHeight, sizeConfig)  -- L345: DOM测高核心
  |       |
  |       +---> 按 page-break(<hr>) 将 blocks 分组         -- L349-365
  |       +---> getEditorChildren()                       -- L323: 穿透 div 包装层获取编辑器DOM子元素
  |       +---> 创建 500px 测量容器fixed, visibility:hidden  -- L376-387
  |       +---> 串行测高每组DOM克隆到测量容器 -> getBoundingClientRect  -- L392-433
  |       +---> 按比例分配实际高度给组内各 block              -- L421-428
  |       +---> paginateContent(blocks, contentAreaHeight, sizeConfig)  -- L445: 按高度累加分页
  |
  +---> generateSummaryPage(sizeConfig, totalPages)      -- L731: 生成尾页HTML
  +---> 页码补全 N/M                                      -- L122-129: 二次遍历替换占位页码
  +---> renderThumbnails(sizeConfig)                     -- L968: 截图 -> 显示右侧缩略图
         |
         +---> doCapturePages({scale:1})                 -- L841: staging隐藏区域渲染 + html2canvas串行截图
                 |
                 +---> 纯图片页优化: 直接用 Image + Canvas跳过 html2canvas  -- L805-830

1.2 并发控制

render() 函数使用 _locked 标志防止并发渲染(phone-image.js:86-91)。渲染期间如果有新请求,设置 _pending = true,当前渲染完成后自动重试(phone-image.js:134-137)。

1.3 DOM 测高机制

captureEditorBlocksphone-image.js:345-437)是管线中替代旧版 renderContentFlow + measureBlockHeights 的核心函数。工作流程:

  1. page-break 类型将 blocks 分为若干组
  2. 通过 getEditorChildren() 获取 wangeditor 编辑区的真实 DOM 子元素
  3. 创建 500px 宽的隐藏测量容器fixed 定位,visibility:hidden与内容区宽度540 - 20*2 padding一致
  4. 对每组:将对应的 DOM 子元素克隆到测量容器,通过 requestAnimationFrame 后读取 getBoundingClientRect().height 获取实际高度
  5. 按各 block 估算高度的比例,将测量的实际高度分配给组内各 block
  6. 所有组测高完成后,调用 paginateContent 进行分页

1.4 截图机制

截图在 #render-staging 隐藏区域进行(phone-image.html:597)。关键步骤:

  1. 将所有页面HTML append到 staging 区域的 .phone-image-container 容器中
  2. 临时将 visibility:hidden 切为 visiblehtml2canvas 会跳过隐藏元素)
  3. 串行逐页调用 html2canvas 截图(runCaptureLoop, L881
  4. 纯图片页优化:直接用 Image + Canvas 绘制,跳过 html2canvasphone-image.js:805-830
  5. 截图完成后恢复 visibility:hidden

缩略图用 scale:1,保存用 scale:2输出1080px宽度

1.5 getEditorChildren 穿透逻辑

getEditorChildren()phone-image.js:323-334)处理 wangeditor v5 的 DOM 包装层。wangeditor 的 [data-slate-editor] 下可能有一层 <div> 包装,该函数检测到这种情况时穿透到内层子元素,返回实际的内容节点数组。这对 captureEditorBlocks 的 DOM 克隆测高和 exportLongImage 的长图导出都至关重要。

2. wangeditor 集成

2.1 编辑器初始化

编辑器在 phone_image.html 模板脚本中初始化L192-288

var E = window.wangEditor;
var phoneImageEditor = E.createEditor({
    selector: '#editor-text-area',
    html: postData.contentHtml,    // 初始内容,来自服务端渲染
    config: editorConfig
});
var toolbar = E.createToolbar({
    editor: phoneImageEditor,
    selector: '#editor-toolbar',
    config: {
        excludeKeys: ['fullScreen'],
        insertKeys: {
            index: 24,
            keys: ['divider']      // 分割线按钮,插入位置 index 24
        }
    }
});
window.phoneImageEditor = phoneImageEditor;  // 全局引用

2.2 工具栏配置

  • 排除 fullScreen 按钮
  • 在 index 24 位置插入 divider(分割线)按钮
  • 其余使用 wangeditor 默认按钮集

2.3 分割线与分页的关系

wangeditor 的 divider 按钮在编辑器中插入 <hr> 元素(带 data-w-e-type="divider" 属性)。渲染管线中 parseHtmlToBlocks<hr> 识别为 page-break 类型的块(phone-image.js:259-263paginateContent 遇到 page-break 时强制换页(phone-image.js:455-466)。captureEditorBlocks 也按 page-break 对 blocks 分组测高。

2.4 粘贴处理

editorConfig.customPastephone_image.html:220-268)拦截粘贴操作,将外部图片(http 开头的URL图片和 data: 开头的 base64 图片上传到服务器后替换为本地URL确保编辑器中的图片资源可控。

2.5 DOM 结构

div.content-flow-area (540px, overflow-y:auto)
  +-- div#editor-toolbar     (wangeditor 工具栏)
  +-- div#editor-text-area   (wangeditor 编辑区)
        +-- div[data-slate-editor]    (wangeditor 内部)
              +-- div (可能的包装层)
                    +-- p / h1 / img / hr(divider) / ...  (实际内容节点)

3. 数据流

3.1 数据源模型

重构后wangeditor 编辑器成为运行时的权威内容源。引擎闭包变量 postData.content_html 仅作为初始化回退。

权威数据源window.phoneImageEditor.getHtml(),通过 getContentHtml() API 统一访问(phone-image.js:1280-1285

getContentHtml: function () {
    if (window.phoneImageEditor) {
        return window.phoneImageEditor.getHtml();
    }
    return postData.content_html;  // 回退
}

引擎公开 API

API 位置 用途
getContentHtml() phone-image.js:1280-1285 优先从 wangeditor 读取HTML回退到闭包变量
updateConfig(newConfig) phone-image.js:1286-1290 使用 $.extend(config, newConfig) 更新配置,不重置内容
saveConfig(postId, saveConfig, url) phone-image.js:1138-1170 保存配置到服务端(不生成图片),返回 {output_id}
saveImages(postId, saveConfig) phone-image.js:1079-1129 截图并保存所有页面图片到服务端
exportLongImage() phone-image.js:1192-1257 从编辑器DOM导出完整长图

DOM #post-content-html 的角色:仅作为初始化数据源(phone_image.html:105)和保存后的显示同步层(phone_image.html:434,459),保存操作不从中读取内容。

3.2 各操作的数据流

保存配置 (#btn-save):
  content_html 取自 PhoneImageEngine.getContentHtml()     -- L431
  调用 PhoneImageEngine.saveConfig() API                   -- L428-432
  保存后同步: $('#post-content-html').html(getContentHtml())  -- L434

生成并保存 (#btn-generate):
  content_html 取自 PhoneImageEngine.getContentHtml()     -- L454
  调用 PhoneImageEngine.saveImages() API                   -- L451-455
  保存后同步: $('#post-content-html').html(getContentHtml())  -- L459

重新排版 (doRender):
  用 PhoneImageEngine.updateConfig(newConfig)              -- L344
  再调 PhoneImageEngine.render()                           -- L346

自动保存 (onChange):
  wangeditor onChange -> 2.6s 防抖 -> doAutoSave()         -- phone_image.html:209-216
  doAutoSave 调用 PhoneImageEngine.saveConfig()            -- phone_image.html:403-413

加载历史 (loadFromHistory):
  window.phoneImageEditor.setHtml(res.data.content_html)   -- phone_image.html:561 (加载到编辑器)
  PhoneImageEngine.updateConfig(historyConfig)              -- phone_image.html:578
  PhoneImageEngine.render()                                 -- phone_image.html:580

导出长图 (更多菜单 -> exportLong):
  PhoneImageEngine.exportLongImage()                        -- phone_image.html:484
  内部: getEditorChildren() -> 克隆非divider元素 -> html2canvas scale=2 -> 下载PNG

3.3 自动保存机制

编辑器内容变更时触发自动保存(phone_image.html:206-413

  1. editorConfig.onChange 触发,设置 autoSaveLock = true,显示"等待保存..."状态
  2. 清除之前的防抖定时器,设置新的 2.6 秒定时器
  3. 定时器到期后调用 doAutoSave()
  4. doAutoSave() 检查 autoSaveLock,通过 PhoneImageEngine.saveConfig() 发送保存请求
  5. 保存状态通过 #save-state 指示器反馈waiting/saving/saved/errorphone_image.html:388-397

手动保存按钮点击时会清除自动保存定时器,避免冲突。

3.4 完整数据流图

[数据库 Post.content_html]
        |
        v
Post.php::phoneImage() (L355)
  查询 PostOutput 取已保存的 content_html 副本
        |
        v
phone_image.html 模板渲染
  - $layoutContentHtml (优先用已保存副本)
  - $layoutConfig (已保存的配置)
        |
        v
前端初始化 (phone_image.html:152-294)
  1. 构造 postData 局部对象contentHtml 取自 #post-content-html  (L173)
  2. 恢复 savedConfig (L181-186)
  3. wangeditor 初始化html=postData.contentHtml                 (L270-274)
  4. window.phoneImageEditor = phoneImageEditor                   (L288)
  5. PhoneImageEngine.init(postData, initConfig)                  (L290)
  6. doRender() 触发初始渲染                                      (L594)
        |
        v
渲染管线 (phone-image.js render())
  - 读取 window.phoneImageEditor.getHtml() (唯一权威源)
  - parseHtmlToBlocks + captureEditorBlocks(测高) + paginateContent(分页)
  - staging 渲染 + html2canvas 截图
  - 显示缩略图
        |
        v
用户交互
  - 编辑内容 -> wangeditor -> onChange 自动保存(2.6s防抖)
  - 分割线 -> wangeditor divider -> <hr> -> page-break 分页
  - 保存配置 -> PhoneImageEngine.saveConfig() (content_html 取自 getContentHtml())
  - 生成保存 -> PhoneImageEngine.saveImages() (content_html 取自 getContentHtml())
  - 重新排版 -> updateConfig() + render()
  - 加载历史 -> editor.setHtml() + updateConfig() + render()
  - 导出长图 -> exportLongImage() (从编辑器DOM直接截图)
        |
        v
后端存储
  - PostOutput.config (JSON字段含 content_html 副本)
  - PostOutputFile (每页图片文件记录)

4. 存储模型

4.1 数据库表

ul_post_output (模型: app/model/PostOutput.php)

字段 说明
id 主键
post_id 关联文章ID
output_type 输出类型,固定为 phone_image
config JSON字段存储完整配置 + content_html 副本
status 0=生成中, 1=已完成, 2=失败 (常量定义 L20-22)
page_count 页数
admin_id 操作管理员ID
create_time 创建时间 (int)
delete_time 软删除标记 (默认0trait: SoftDelete)

模型使用 $json = ['config'] 自动序列化/反序列化配置(PostOutput.php:18)。

ul_post_output_file (模型: app/model/PostOutputFile.php)

字段 说明
id 主键
output_id 关联 post_output.id
page 页码 (从1开始)
file_path 相对路径,如 /upload/post_output/20260511/5_1.jpg
file_url 访问URL (同 file_path)
file_size 文件字节数
width 图片宽度 (px)
height 图片高度 (px)

关系: PostOutput hasMany PostOutputFilePostOutput belongsTo Post

4.2 config JSON 结构

{
  "size": "xiaohongshu",
  "watermark": "",
  "pageAlignments": { "1": "top", "2": "center" },
  "content_html": "<p>文章HTML副本</p><hr><p>第二页内容</p>"
}

content_html 作为配置的一部分保存,使每次排版记录都携带当时的内容快照。含 <hr> 分页标记的 HTML 被完整保存,parseHtmlToBlocks 可以正确解析。

向后兼容:旧版 PostOutput 记录中保存的 <hr> 分页标记仍可被 parseHtmlToBlocks 正确识别为 page-breakphone-image.js:259-263),不影响历史记录加载和重新排版。

4.3 loadFromHistory 加载机制

loadFromHistory(outputId)
  -> GET loadPostOutputConfig?id=XXX
  -> window.phoneImageEditor.setHtml(res.data.content_html)   -- 加载到编辑器
  -> PhoneImageEngine.updateConfig(historyConfig)              -- 更新配置
  -> PhoneImageEngine.render()                                 -- 重新渲染

不再使用 init() 全量重置,而是通过 editor.setHtml() 直接将历史内容加载到 wangeditor然后 updateConfig() 更新配置后重新渲染。

4.4 文件存储

图片保存到 public/upload/post_output/{Ymd}/{outputId}_{page}.jpgPhoneImage.php:87-92。ZIP 临时文件生成到 runtime/temp/ 目录,请求结束后通过 register_shutdown_function 清理(Post.php:570-575)。

5. wangeditor 配置与工具栏

5.1 编辑器配置

editorConfig = {
    MENU_CONF: {},
    placeholder: '请输入排版内容,使用分割线标记分页位置',
    scroll: false,
    MENU_CONF: {
        'uploadImage': {
            server: '{:url("File/wangEditorSave")}',
            fieldName: 'file',
            meta: { type: 'editor' }
        }
    },
    onChange: function(editor) { /* 自动保存 2.6s 防抖 */ },
    customPaste: function(editor, event) { /* 外部图片上传处理 */ }
}

5.2 工具栏配置

toolbar config = {
    excludeKeys: ['fullScreen'],
    insertKeys: {
        index: 24,          // 在第24个按钮位置后插入
        keys: ['divider']   // 分割线按钮
    }
}

保留 wangeditor 所有默认按钮(粗体、斜体、标题、列表、引用、图片、链接等),额外添加分割线按钮用于手动标记分页位置。

6. 配置体系

6.1 PHP 端配置字段定义

PhoneImage::getConfigFields() 定义了4个字段

字段 类型 选项/范围 默认值
size select xiaohongshu/douyin xiaohongshu
fontSize number 10-24 14
watermark text - ''
pageAlignments json - '{}'

6.2 JS 端实际使用的配置

phone-image.js 闭包内的 config 对象 (L14-23)

config = {
    size: 'xiaohongshu',
    watermark: '',
    pageAlignments: {},  // 逐页对齐
    sizes: { ... },      // 尺寸预设
    contentPadding: 20
}

6.3 设置弹框

通过 layui layer 弹框实现(phone_image.html:293-330),提供:

  • 尺寸选择:小红书 (1080x1440) / 抖音 (1080x1920)
  • 水印输入:可选水印文字

确认后自动触发 doRender() 重新排版。

6.4 自动保存状态指示器

<span id="save-state"> 元素(phone_image.html:417)动态显示保存状态:

  • waiting:黄色,"等待保存..."
  • saving:蓝色,"保存中..."
  • saved:绿色,"已保存"
  • error:红色,"保存失败"

7. UI 布局

7.1 整体结构

两栏布局,适配宽屏操作:

+----------------------------------------------------------+
| page-header (顶部操作栏)                                   |
| [返回列表] 标题     [设置] [保存] [生成并保存] [更多v] 已保存 |
+------------------------------+---------------------------+
| content-flow-area (540px)    | paginated-preview-area    |
| +--------------------------+ | (flex:1, 横向滚动)         |
| | #editor-toolbar          | |                           |
| | (wangeditor工具栏)        | | [缩略图1] [缩略图2] ...  |
| +--------------------------+ | 每张带页码和对齐按钮       |
| | #editor-text-area        | |                           |
| | (wangeditor编辑区)        | |                           |
| | min-height:300px         | |                           |
| +--------------------------+ |                           |
+------------------------------+---------------------------+

7.2 顶部操作栏

按钮直接显示:设置、保存、生成并保存。"更多"下拉菜单(纯 JS 实现,phone_image.html:358-385)包含:历史记录、重新生成、打包下载、导出长图。

layui 2.x 没有 dropdown 模块,更多菜单用纯 JS 实现的 toggle + stopPropagation + 外部点击关闭。

7.3 缩略图区域

右侧缩略图横向排列,每个缩略图带页码标签和对齐切换按钮(仅内容页显示,phone-image.js:1017-1025)。对齐按钮切换 top/center 垂直对齐。

8. 已删除的函数/组件

wangeditor 重构中移除的旧版函数和组件:

8.1 渲染管线相关

已删除函数 说明
renderContentFlow 旧版将块渲染到中间栏 #content-flow,已由 wangeditor 编辑器替代
convertFlowBlocksToImages 旧版表格/代码块预转换html2canvas截图替换为img新版不再需要
measureBlockHeights 旧版从中间栏DOM读取实测高度已由 captureEditorBlocks 的测量容器方案替代
insertPageBreak 旧版中间栏"+"按钮插入分页标记,已由 wangeditor divider 替代
removePageBreak 旧版中间栏"-"按钮移除分页标记,已由编辑器直接删除 divider 替代

8.2 高度估算相关

已删除函数 说明
estimateTextHeight 旧版纯计算估算段落高度,已由 DOM 测量替代
estimateHeadingHeight 旧版估算标题高度
estimateListHeight 旧版估算列表高度
estimateBlockquoteHeight 旧版估算引用块高度

8.3 工具函数

已删除函数 说明
simpleHash 旧版 djb2 哈希,用于 convertedBlockCache 缓存key
getContentWidth 旧版获取内容区宽度

8.4 UI 组件

已删除组件 说明
#content-flow 中间栏 旧版内容渲染和交互区域,已由 wangeditor 编辑器替代
左侧工具栏 旧版编辑操作面板(分页标记按钮等)
convertedBlockCache 旧版表格/代码块截图缓存,新版不再预转换

9. 已知限制

9.1 html2canvas 兼容性

html2canvas 截图不支持以下 CSS 特性:

  • transform
  • filter
  • clip-path
  • backdrop-filter
  • CSS grid
  • object-fit

在 wangeditor 编辑器和自定义样式中应避免使用这些属性,否则截图中可能显示异常。

9.2 wangeditor divider 特性

wangeditor 的 divider 是 void 元素,::after 伪元素可能不生效。编辑器中 divider 的视觉样式依赖 wangeditor 内部 CSS。

9.3 layui dropdown 缺失

layui 2.x 没有 dropdown 模块,"更多"菜单用纯 JS 实现(phone_image.html:358-385),包括 toggle 显示/隐藏、stopPropagation 阻止冒泡、全局 click 事件关闭。

9.4 保存数据量限制

saveImages() 有16MB的客户端检查phone-image.js:1096-1100但服务端没有对应的分块接收机制。大文章超过约30页的base64数据可能超出 PHP post_max_size 配置。

9.5 目录权限 0777

PhoneImage.php:95:140mkdir 使用 0777 权限,与项目其他位置的 anti-pattern 一致。

9.6 接口 PostOutputManagerInterface 未被充分利用

PhoneImage 实现了 process()getPreview() 方法,但两者都返回空值(PhoneImage.php:56-67)。实际业务完全在控制器中直接调用 PhoneImage 的其他方法,绕过了接口定义的流程。接口仅作为类型约束存在。

9.7 saveConfigOnly 是静态方法但 createOutput 是实例方法

PhoneImage::saveConfigOnly() 是 static 方法L223而同类的 createOutput() 是实例方法L193。两者的实现几乎相同都是 PostOutput::create()),但调用方式不同。控制器中 savePostOutput 通过实例调用 createOutputsavePostOutputConfig 通过静态调用 saveConfigOnly,风格不统一。

10. API 端点清单

所有端点位于 app/admin/controller/Post.php,通过 ThinkPHP 自动路由访问URL前缀为 /index.php/admin/post/

端点 方法 说明 前端调用方
phoneImage/{id} GET 排版操作页(渲染模板) 页面跳转
savePostOutput POST (JSON) 保存输出记录+图片 PhoneImageEngine.saveImages() (phone-image.js:1103)
savePostOutputConfig POST (JSON) 仅保存配置(不生成图片) PhoneImageEngine.saveConfig() (phone-image.js:1152)
loadPostOutputConfig GET 加载历史记录配置 loadFromHistory() (phone_image.html:550)
getOutputListJson GET 获取输出记录列表 历史弹窗 (phone_image.html:504)
deletePostOutput POST/GET 删除输出记录及文件 未在前端模板中直接调用
regeneratePostOutput POST/GET 删除旧记录,返回配置 未在前端模板中直接调用
downloadPostOutputZip/{id} GET 打包下载ZIP 更多菜单 -> 打包下载
postOutputList/{id} GET 输出管理列表页 独立页面入口

请求/响应格式

savePostOutput 请求体:

{
  "post_id": 123,
  "output_type": "phone_image",
  "config": { "size": "...", "watermark": "...", ... },
  "content_html": "<p>...</p>",
  "pages": ["data:image/jpeg;base64,..."]
}

savePostOutputConfig 请求体:

{
  "post_id": 123,
  "output_type": "phone_image",
  "config": { "size": "...", "watermark": "...", "pageAlignments": {} },
  "content_html": "<p>...</p>"
}

通用响应格式:

{ "code": 0, "msg": "", "data": { "output_id": 456 } }

失败时 code 为 500。

11. 修复记录

11.1 数据源统一修复wangeditor 重构前)

修复日期2026-05-11

核心原理将三个独立的数据源DOM、闭包变量、模板局部变量统一为单一权威数据源通过新增的引擎 API 对外暴露。

变更清单

变更 文件 说明
新增 getContentHtml() phone-image.js:1280-1285 优先从 wangeditor 读取,回退到闭包变量
新增 updateConfig(newConfig) phone-image.js:1286-1290 使用 $.extend 更新配置,不重置内容
新增 saveConfig() API phone-image.js:1138-1170 引擎级保存配置方法
新增 exportLongImage() phone-image.js:1192-1257 从编辑器DOM导出长图
保存按钮改用 getContentHtml() phone_image.html:431 content_html 取自引擎 API
生成按钮改用 getContentHtml() phone_image.html:454 content_html 取自引擎 API
自动保存机制 phone_image.html:206-413 onChange 2.6s 防抖自动保存
doRender 改用 updateConfig() phone_image.html:344 替代 init() 调用
loadFromHistoryeditor.setHtml() phone_image.html:561 加载到编辑器而非重置引擎

11.2 wangeditor 重构

重构日期2026-05-11

核心变更:将内容编辑从旧版中间栏(#content-flow + 手动分页标记)迁移到 wangeditor 富文本编辑器。

主要变更

变更 说明
引入 wangeditor v5 替代旧版中间栏内容展示和编辑
新增 captureEditorBlocks DOM 测量方案替代旧版高度估算
新增 getEditorChildren 穿透 wangeditor DOM 包装层
新增 exportLongImage 从编辑器DOM导出完整长图
分割线替代分页标记按钮 wangeditor divider (<hr>) = page-break
自动保存 编辑器 onChange 触发 2.6s 防抖保存
更多菜单纯 JS 实现 替代 layui dropdown不存在
删除旧版渲染管线 renderContentFlow / convertFlowBlocksToImages / measureBlockHeights 等
删除旧版高度估算 estimateTextHeight 等,由 DOM 测量替代
删除旧版 UI 左侧工具栏、中间栏交互

重构效果

  • 内容编辑体验从"预览+标记"模式变为"所见即所得"编辑模式
  • 分页标记通过分割线直观可见和可编辑
  • 自动保存减少手动操作
  • DOM 测量替代估算,分页精度提升

文档基于以下源文件的完整阅读:

  • public/static/js/phone-image.js (1292行)
  • view/admin/post/phone_image.html (600行)
  • app/admin/controller/Post.php (L350-579)
  • app/common/tools/PhoneImage.php (229行)
  • app/common/tools/PostOutputManagerInterface.php (28行)
  • app/model/PostOutput.php (62行)
  • app/model/PostOutputFile.php (26行)