mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 16:44:23 +08:00
This commit is contained in:
1 parent
bf27afea13
commit
f924f008a3
9 files changed
+105
-29
No files matched your search
@@ -273,12 +273,14 @@ a:focus-visible {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
overflow-wrap: anywhere;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-listbox [role="option"] > span:first-child {
|
.font-listbox [role="option"] > span:first-child {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-option-source {
|
.font-option-source {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CanvasEditor, canEditObject, objectEditReason } from "./ofdgo_edit.js";
|
import { CanvasEditor, canEditObject, missingGlyphMessage, objectEditReason } from "./ofdgo_edit.js";
|
||||||
import { FontManager, FontPicker } from "./ofdgo_font.js";
|
import { FontManager, FontPicker } from "./ofdgo_font.js";
|
||||||
|
|
||||||
const MM_TO_PX = 96 / 25.4;
|
const MM_TO_PX = 96 / 25.4;
|
||||||
@@ -1727,7 +1727,7 @@ async function loadWASM() {
|
|||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
request.resolve(data.data);
|
request.resolve(data.data);
|
||||||
} else {
|
} else {
|
||||||
const err = new Error(data.error);
|
const err = new Error(missingGlyphMessage(data.missingGlyphs) || data.error);
|
||||||
if (data.canceled) {
|
if (data.canceled) {
|
||||||
err.name = "AbortError";
|
err.name = "AbortError";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,13 +122,21 @@ export function canEditObject(item, capability) {
|
|||||||
return Boolean(item) && (item.items || [item]).every(object => Boolean(object.capabilities?.[capability]));
|
return Boolean(item) && (item.items || [item]).every(object => Boolean(object.capabilities?.[capability]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function missingGlyphMessage(diagnostic) {
|
||||||
|
if (!diagnostic) return "";
|
||||||
|
const characters = Array.from(diagnostic.characters);
|
||||||
|
const sample = characters.slice(0, 8).map(char => /[\p{C}\p{Z}\p{M}]/u.test(char)
|
||||||
|
? `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}` : char).join(" ");
|
||||||
|
return `字体缺字:${sample}${characters.length > 8 ? ` 等 ${characters.length} 字` : ""},改选字体`;
|
||||||
|
}
|
||||||
|
|
||||||
export function objectEditReason(item) {
|
export function objectEditReason(item) {
|
||||||
const reasons = (item?.items || (item ? [item] : [])).map(object => {
|
const reasons = (item?.items || (item ? [item] : [])).map(object => {
|
||||||
const reason = object.capabilities?.reason || "";
|
const reason = object.capabilities?.reason || "";
|
||||||
if (!reason) return "";
|
if (!reason) return "";
|
||||||
let message = "对象特性暂不支持";
|
let message = "对象特性暂不支持";
|
||||||
if (reason.includes("embedded font")) message = "缺少内嵌字体";
|
if (object.capabilities.missingGlyphs) message = missingGlyphMessage(object.capabilities.missingGlyphs);
|
||||||
else if (reason.includes("does not contain")) message = "字体缺字";
|
else if (reason.includes("embedded font")) message = "缺少内嵌字体";
|
||||||
else if (reason.includes("color") || reason.includes("RGB")) message = "颜色样式暂不支持";
|
else if (reason.includes("color") || reason.includes("RGB")) message = "颜色样式暂不支持";
|
||||||
else if (reason.includes("draw parameter")) message = "绘制参数暂不支持";
|
else if (reason.includes("draw parameter")) message = "绘制参数暂不支持";
|
||||||
else if (reason.includes("layer")) message = "图层特性暂不支持";
|
else if (reason.includes("layer")) message = "图层特性暂不支持";
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class FontPicker {
|
|||||||
label.textContent = source;
|
label.textContent = source;
|
||||||
option.append(label);
|
option.append(label);
|
||||||
}
|
}
|
||||||
option.title = font.name || font.fullName;
|
option.title = [...new Set([font.fullName, font.name].filter(Boolean))].join(" · ");
|
||||||
this.list.append(option);
|
this.list.append(option);
|
||||||
}
|
}
|
||||||
if (!this.matches.length) {
|
if (!this.matches.length) {
|
||||||
@@ -100,9 +100,9 @@ export class FontPicker {
|
|||||||
|
|
||||||
position() {
|
position() {
|
||||||
if (!this.open) return;
|
if (!this.open) return;
|
||||||
const rect = this.input.getBoundingClientRect();
|
const rect = this.input.parentElement.getBoundingClientRect();
|
||||||
if (rect.right <= 0 || rect.left >= window.innerWidth) { this.close(); return; }
|
if (rect.right <= 0 || rect.left >= window.innerWidth) { this.close(); return; }
|
||||||
const width = Math.min(Math.max(rect.width, 260), window.innerWidth - 16);
|
const width = Math.min(rect.width, window.innerWidth - 16);
|
||||||
Object.assign(this.list.style, { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`,
|
Object.assign(this.list.style, { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`,
|
||||||
top: `${rect.bottom + 4}px`, width: `${width}px`, maxHeight: `${Math.max(40, Math.min(280, window.innerHeight - rect.bottom - 12))}px` });
|
top: `${rect.bottom + 4}px`, width: `${width}px`, maxHeight: `${Math.max(40, Math.min(280, window.innerHeight - rect.bottom - 12))}px` });
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-4
@@ -22,6 +22,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"io"
|
"io"
|
||||||
@@ -77,9 +78,10 @@ var currentSession *Session
|
|||||||
|
|
||||||
// apiResult 浏览器接口返回结果
|
// apiResult 浏览器接口返回结果
|
||||||
type apiResult struct {
|
type apiResult struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Data any `json:"data,omitempty"`
|
Data any `json:"data,omitempty"`
|
||||||
|
MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunWASM 注册浏览器WASM接口并阻塞运行
|
// RunWASM 注册浏览器WASM接口并阻塞运行
|
||||||
@@ -167,7 +169,9 @@ func registerExportCallback(name string, fn func([]js.Value) (any, error)) {
|
|||||||
func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any {
|
func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any {
|
||||||
data, err := safeCall(fn, args)
|
data, err := safeCall(fn, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return encodeResult(apiResult{OK: false, Error: err.Error()})
|
result := apiResult{Error: err.Error()}
|
||||||
|
errors.As(err, &result.MissingGlyphs)
|
||||||
|
return encodeResult(result)
|
||||||
}
|
}
|
||||||
if result, ok := data.(js.Value); ok {
|
if result, ok := data.(js.Value); ok {
|
||||||
return result
|
return result
|
||||||
@@ -698,6 +702,9 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
}
|
}
|
||||||
item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container,
|
item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container,
|
||||||
"capabilities": map[string]any{"update": capability.Update, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason}}
|
"capabilities": map[string]any{"update": capability.Update, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason}}
|
||||||
|
if missing := capability.MissingGlyphs; missing != nil {
|
||||||
|
item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters}
|
||||||
|
}
|
||||||
if object.Type == "ImageObject" && capability.Update {
|
if object.Type == "ImageObject" && capability.Update {
|
||||||
full, err := object.ImageObject.ImageBounds()
|
full, err := object.ImageObject.ImageBounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+12
-3
@@ -858,16 +858,21 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
lineHeight = math.Max(obj.Size, float64(int(ascender)-int(descender)+int(gap))*unit)
|
lineHeight = math.Max(obj.Size, float64(int(ascender)-int(descender)+int(gap))*unit)
|
||||||
}
|
}
|
||||||
var codes []TextCode
|
var codes []TextCode
|
||||||
|
var missing []rune
|
||||||
for _, paragraph := range strings.Split(value, "\n") {
|
for _, paragraph := range strings.Split(value, "\n") {
|
||||||
runes := []rune(paragraph)
|
runes := []rune(paragraph)
|
||||||
advances := make([]float64, len(runes))
|
advances := make([]float64, len(runes))
|
||||||
for i, char := range runes {
|
for i, char := range runes {
|
||||||
glyph := sfnt.GlyphIndex(char)
|
glyph := sfnt.GlyphIndex(char)
|
||||||
if glyph == 0 {
|
if glyph == 0 {
|
||||||
return fmt.Errorf("font does not contain U+%04X", char)
|
missing = append(missing, char)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
advances[i] = float64(sfnt.GlyphAdvance(glyph)) * unit * hScale
|
advances[i] = float64(sfnt.GlyphAdvance(glyph)) * unit * hScale
|
||||||
}
|
}
|
||||||
|
if len(missing) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
spaceTextAdvances(runes, advances, options.LetterSpacing)
|
spaceTextAdvances(runes, advances, options.LetterSpacing)
|
||||||
for _, advance := range advances {
|
for _, advance := range advances {
|
||||||
if !finite(advance) {
|
if !finite(advance) {
|
||||||
@@ -884,6 +889,9 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
codes = append(codes, TextCode{X: ofdNumber(x), Y: ofdNumber(y), DeltaX: deltas, Value: escapeOFDText(string(runes[line[0]:line[1]]))})
|
codes = append(codes, TextCode{X: ofdNumber(x), Y: ofdNumber(y), DeltaX: deltas, Value: escapeOFDText(string(runes[line[0]:line[1]]))})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := missingGlyphError(obj.Font, missing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
obj.TextCode = codes
|
obj.TextCode = codes
|
||||||
obj.layout = nil
|
obj.layout = nil
|
||||||
if options != (TextLayout{}) || e.objectOrigin(obj.ID) != nil {
|
if options != (TextLayout{}) || e.objectOrigin(obj.ID) != nil {
|
||||||
@@ -918,6 +926,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
obj.TextCode = append([]TextCode(nil), obj.TextCode...)
|
obj.TextCode = append([]TextCode(nil), obj.TextCode...)
|
||||||
|
var missing []rune
|
||||||
x, y := "", ""
|
x, y := "", ""
|
||||||
for i := range obj.TextCode {
|
for i := range obj.TextCode {
|
||||||
code := &obj.TextCode[i]
|
code := &obj.TextCode[i]
|
||||||
@@ -939,7 +948,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
|
|||||||
runes := textCodeRunes(code.Value)
|
runes := textCodeRunes(code.Value)
|
||||||
for _, char := range runes {
|
for _, char := range runes {
|
||||||
if sfnt.GlyphIndex(char) == 0 {
|
if sfnt.GlyphIndex(char) == 0 {
|
||||||
return fmt.Errorf("font does not contain U+%04X", char)
|
missing = append(missing, char)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, delta := range []string{code.DeltaX, code.DeltaY} {
|
for _, delta := range []string{code.DeltaX, code.DeltaY} {
|
||||||
@@ -957,7 +966,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
|
|||||||
if strings.Trim(obj.Text(), "\n") == "" {
|
if strings.Trim(obj.Text(), "\n") == "" {
|
||||||
return fmt.Errorf("text codes are empty")
|
return fmt.Errorf("text codes are empty")
|
||||||
}
|
}
|
||||||
return nil
|
return missingGlyphError(obj.Font, missing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// creationDeltas 校验文字位移数组及g重复编码
|
// creationDeltas 校验文字位移数组及g重复编码
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !capability.Copy {
|
if !capability.Copy {
|
||||||
return nil, fmt.Errorf("object %q cannot be copied: %s", editorObjectID(object), capability.Reason)
|
return nil, fmt.Errorf("object %q cannot be copied: %w", editorObjectID(object), capability.editError())
|
||||||
}
|
}
|
||||||
objects[i], err = cloneEditorObject(object)
|
objects[i], err = cloneEditorObject(object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -362,7 +362,7 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !capability.Transform || !geometry && !e.sourceRGB() {
|
if !capability.Transform || !geometry && !e.sourceRGB() {
|
||||||
return fmt.Errorf("object %q is read-only for this operation: %s", ids[i], capability.Reason)
|
return fmt.Errorf("object %q is read-only for this operation: %w", ids[i], capability.editError())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if geometry && e.originalPage(page) {
|
if geometry && e.originalPage(page) {
|
||||||
@@ -498,7 +498,7 @@ func (e *Editor) DeleteObjects(page int, ids []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !capability.Delete {
|
if !capability.Delete {
|
||||||
return fmt.Errorf("object %q cannot be deleted: %s", id, capability.Reason)
|
return fmt.Errorf("object %q cannot be deleted: %w", id, capability.editError())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
layers := copyEditorPage(e.pages[page]).Content.Layer
|
layers := copyEditorPage(e.pages[page]).Content.Layer
|
||||||
@@ -568,7 +568,7 @@ func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !capability.Arrange {
|
if !capability.Arrange {
|
||||||
return nil, fmt.Errorf("object %q cannot be arranged: %s", editorObjectID(object), capability.Reason)
|
return nil, fmt.Errorf("object %q cannot be arranged: %w", editorObjectID(object), capability.editError())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader, err := e.Reader()
|
reader, err := e.Reader()
|
||||||
|
|||||||
+24
-10
@@ -17,6 +17,7 @@ package ofdgo
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"io"
|
"io"
|
||||||
@@ -59,17 +60,28 @@ type editorSourcePage struct {
|
|||||||
// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因
|
// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因
|
||||||
// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块
|
// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块
|
||||||
// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复
|
// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复
|
||||||
|
// MissingGlyphs提供缺字导致操作受限时的结构化诊断
|
||||||
type ObjectCapabilities struct {
|
type ObjectCapabilities struct {
|
||||||
Update bool
|
Update bool
|
||||||
ReplaceFont bool
|
ReplaceFont bool
|
||||||
Reflow bool
|
Reflow bool
|
||||||
LayoutKnown bool
|
LayoutKnown bool
|
||||||
Transform bool
|
Transform bool
|
||||||
Arrange bool
|
Arrange bool
|
||||||
Copy bool
|
Copy bool
|
||||||
Delete bool
|
Delete bool
|
||||||
Order bool
|
Order bool
|
||||||
Reason string
|
Reason string
|
||||||
|
MissingGlyphs *MissingGlyphError
|
||||||
|
}
|
||||||
|
|
||||||
|
// editError 将操作受限原因转为错误,保留可供调用方识别的缺字诊断
|
||||||
|
// 返回: error 受限原因
|
||||||
|
func (c ObjectCapabilities) editError() error {
|
||||||
|
if c.MissingGlyphs != nil {
|
||||||
|
return c.MissingGlyphs
|
||||||
|
}
|
||||||
|
return errors.New(c.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageCapabilities 页面可执行的操作,Insert表示可以添加RGB对象;新增空白页面始终可用
|
// PageCapabilities 页面可执行的操作,Insert表示可以添加RGB对象;新增空白页面始终可用
|
||||||
@@ -351,10 +363,12 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
|
|||||||
all.Order = editorContainerOrderable(node.parent)
|
all.Order = editorContainerOrderable(node.parent)
|
||||||
if _, err := e.prepareObject(id, object); err != nil {
|
if _, err := e.prepareObject(id, object); err != nil {
|
||||||
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error()
|
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error()
|
||||||
|
errors.As(err, &all.MissingGlyphs)
|
||||||
}
|
}
|
||||||
if !e.sourceRGB() {
|
if !e.sourceRGB() {
|
||||||
all.ReplaceFont = false
|
all.ReplaceFont = false
|
||||||
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, "document uses a non-RGB default color space"
|
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, "document uses a non-RGB default color space"
|
||||||
|
all.MissingGlyphs = nil
|
||||||
}
|
}
|
||||||
if object.Type == "TextObject" {
|
if object.Type == "TextObject" {
|
||||||
all.Arrange = all.Update
|
all.Arrange = all.Update
|
||||||
|
|||||||
@@ -15,11 +15,47 @@
|
|||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-text/typesetting/segmenter"
|
"github.com/go-text/typesetting/segmenter"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MissingGlyphError 字体缺字错误,FontID为OFD字体资源标识,Characters按首次出现顺序保留不重复的缺失字符
|
||||||
|
// 可通过errors.As从编辑错误中取得,不表示字体缺失或文件损坏
|
||||||
|
type MissingGlyphError struct {
|
||||||
|
FontID string `json:"fontID"`
|
||||||
|
Characters string `json:"characters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 返回缺失字符的Unicode码点
|
||||||
|
// 返回: string 错误描述
|
||||||
|
func (e *MissingGlyphError) Error() string {
|
||||||
|
var codes []string
|
||||||
|
for _, char := range e.Characters {
|
||||||
|
codes = append(codes, fmt.Sprintf("U+%04X", char))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("font %q does not contain %s", e.FontID, strings.Join(codes, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingGlyphError 汇总缺失字符,无缺字时返回nil
|
||||||
|
// 入参: id 字体资源标识, characters 缺失字符
|
||||||
|
// 返回: error 缺字诊断
|
||||||
|
func missingGlyphError(id string, characters []rune) error {
|
||||||
|
if len(characters) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[rune]bool)
|
||||||
|
var value strings.Builder
|
||||||
|
for _, char := range characters {
|
||||||
|
if !seen[char] {
|
||||||
|
seen[char] = true
|
||||||
|
value.WriteRune(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &MissingGlyphError{FontID: id, Characters: value.String()}
|
||||||
|
}
|
||||||
|
|
||||||
// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
|
// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
|
||||||
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
|
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
|
||||||
// 零值保持显式换行和左对齐
|
// 零值保持显式换行和左对齐
|
||||||
|
|||||||
Reference in new issue
Block a user