不同屏幕分辨率和宽高比下的Load From JSON
我需要能够将loadFromJSON
与生成JSON数据时使用的画布分辨率/纵横比一起使用,同时保持图形元素的关系并使画布中的所有内容居中。
我已经尝试了我所见或想出的每一种解决方案。在这一点上,我不知道是我的逻辑不好,是我的算术还是我的编码。
此处包含功能代码(从我失败的尝试中剔除)。只有第一个函数很重要,其余的是UI样板和JSON数据。这里有一个JS小提琴,如果这更容易的话:https://jsfiddle.net/sunny001/a8thqd0z/24/
详细信息:我使用自定义width
和height
属性保存JSON数据,以便我知道创建数据时的分辨率/纵横比。然后,我使用这些属性来确定如何缩放对象。canvas
始终设置为窗口的大小,大小可以有所不同。我见过一些使用Canvas";Zoom";属性的解决方案,但我不能这样做,因为该应用程序允许用户放大他们正在批注的文档。
背景这是针对桌面electron
应用程序的,用户可以在该应用程序中为文本文档添加批注,因此准确定位非常重要。用户可以在窗口模式或全屏模式下创建和显示批注。
'use strict';
let canvasA
let canvasB
// these are the canvas dimensions
let A = {
width: 320,
height: 190
}
let B = {
width: 225,
height: 150
}
function loadAnnotation(theCanvas, theData, id) {
theCanvas.clear()
var containerWidth = theCanvas.getWidth()
var containerHeight = theCanvas.getHeight()
var originalWidth = theData.width
var originalHeight = theData.height
var scaleFactor
theCanvas.loadFromJSON(theData, function() {
/**
* the canvas seems to change size "on its own" based on the JSON data
* width & height properties so this hack resets it
*
* setDimensions() seems buggy – screen redraw artifacts so using setWidth() & setheight()
*
**/
if (id == "A") {
// theCanvas.setDimensions(A.width, A.height)
theCanvas.setWidth(A.width)
theCanvas.setHeight(A.height)
} else {
// theCanvas.setDimensions(B.width, B.height)
theCanvas.setWidth(B.width)
theCanvas.setHeight(B.height)
}
// just logging code
if (id == "A") {
logA(`data w/h: ${originalWidth} x ${originalHeight}`)
logA(`canvas w/h: ${theCanvas.getWidth()} x ${theCanvas.getHeight()}`)
logA(`canvas zoom: ${theCanvas.getZoom()}`)
logA(`scaleFactor: ${scaleFactor}`)
} else {
logB(`data w/h: ${originalWidth} x ${originalHeight}`)
logB(`canvas w/h: ${theCanvas.getWidth()} x ${theCanvas.getHeight()}`)
logB(`canvas zoom: ${theCanvas.getZoom()}`)
logB(`scaleFactor: ${scaleFactor}`)
}
}, function(o, object) {
var widthRatio = containerWidth / originalWidth
var heightRatio = containerHeight / originalHeight
// if (widthRatio <= heightRatio) {
if (widthRatio > heightRatio) {
scaleFactor = widthRatio
} else {
scaleFactor = heightRatio
}
object.scaleX = object.scaleX * scaleFactor
object.scaleY = object.scaleY * scaleFactor
object.left = object.left * scaleFactor
object.top = object.top * scaleFactor
object.setCoords()
})
theCanvas.renderAll();
theCanvas.calcOffset();
}
// Everything below here is UI code & JSON data
document.addEventListener("DOMContentLoaded", (event) => {
var today = new Date();
var time = today.getHours() + ":" + today.getMinutes() + ":" + today.getSeconds();
console.log(time)
var canvasAloadA = document.querySelector("#canA-loadA")
canvasAloadA.addEventListener("click", function(event) {
logA()
loadAnnotation(canvasA, canvasA_data, "A")
})
var canvasAloadB = document.querySelector("#canA-loadB")
canvasAloadB.addEventListener("click", function(event) {
logA()
loadAnnotation(canvasA, canvasB_data, "A")
});
var clearA = document.querySelector("#canA-clear")
clearA.addEventListener("click", function(event) {
canvasA.clear()
logA()
});
var saveA = document.querySelector("#canA-save")
saveA.addEventListener("click", function(event) {
canvasA_data = canvasA.toObject(["width", "height"])
// canvasA_data = JSON.stringify(canvasA.toObject(["width", "height"]));
// console.log('Save. A', JSON.stringify(canvasA_data))
});
var canvasBloadA = document.querySelector("#canB-loadA")
canvasBloadA.addEventListener("click", function(event) {
logB()
loadAnnotation(canvasB, canvasA_data, "B")
})
var canvasBloadB = document.querySelector("#canB-loadB")
canvasBloadB.addEventListener("click", function(event) {
logB()
loadAnnotation(canvasB, canvasB_data, "B")
});
var clearB = document.querySelector("#canB-clear")
clearB.addEventListener("click", function(event) {
canvasB.clear()
logB()
});
var saveB = document.querySelector("#canB-save")
saveB.addEventListener("click", function(event) {
canvasB_data = canvasB.toObject(["width", "height"])
// canvasB_data = JSON.stringify(canvasB.toObject(["width", "height"]));
console.log('Save. B', JSON.stringify(canvasB_data))
})
setUpFabric()
})
function setUpFabric() {
canvasA = new fabric.Canvas('canvas-A', {
backgroundColor: '#FFFFFF',
width: 320,
height: 190
})
loadAnnotation(canvasA, canvasA_data, "A")
// canvasA_LoadData() // only used to generate initial JSON data
canvasA.renderAll();
canvasB = new fabric.Canvas('canvas-B', {
backgroundColor: '#FFFFFF',
width: 225,
height: 150
});
loadAnnotation(canvasB, canvasB_data, "B")
// canvasB_LoadData() // only used to generate initial JSON data
canvasB.renderAll();
}
/**
* fitInBox
* Constrains a box (width x height) to fit in a containing box (maxWidth x maxHeight), preserving the aspect ratio
* @param width width of the box to be resized
* @param height height of the box to be resized
* @param maxWidth width of the containing box
* @param maxHeight height of the containing box
* @param expandable (Bool) if output size is bigger than input size, output is left unchanged (false) or expanded (true)
* @return {width, height} of the resized box
*/
function fitInBox(width, height, maxWidth, maxHeight, expandable) {
"use strict";
var aspect = width / height,
initWidth = width,
initHeight = height;
if (width > maxWidth || height < maxHeight) {
width = maxWidth;
height = Math.floor(width / aspect);
}
if (height > maxHeight || width < maxWidth) {
height = maxHeight;
width = Math.floor(height * aspect);
}
if (!!expandable === false && (width >= initWidth || height >= initHeight)) {
width = initWidth;
height = initHeight;
}
return {
width: width,
height: height
};
}
function logA(txt) {
if (txt == undefined) {
document.getElementById('canA').value = ""
} else {
document.getElementById('canA').value += `
${txt}`
}
}
function logB(txt) {
if (txt == undefined) {
document.getElementById('canB').value = ""
} else {
document.getElementById('canB').value += `
${txt}`
}
}
// this is for initial JSON data setup only - not used in demo
function canvasA_LoadData() {
var elA = document.getElementById('test-imageA');
var imgA = new fabric.Image(elA, {
left: 0,
top: 0,
selectable: true
})
canvasA.add(imgA);
var containerWidth = canvasA.getWidth()
var containerHeight = canvasA.getHeight()
var result = fitInBox(imgA.width, imgA.height, containerWidth, containerHeight, true)
imgA.scaleToWidth(result.width)
var xOffset = (containerWidth - imgA.getScaledWidth()) / 2
var yOffset = (containerHeight - imgA.getScaledHeight()) / 2
imgA.set({
left: xOffset,
top: yOffset
})
imgA.setCoords()
var rect = new fabric.Rect({
left: 100,
top: 0,
fill: 'red',
width: 20,
height: 20
});
var circle = new fabric.Circle({
radius: 20,
stroke: 'green',
strokeWidth: 12,
fill: null,
left: 200,
top: 130
});
var triangle = new fabric.Triangle({
width: 40,
height: 40,
fill: 'blue',
left: 50,
top: 140
});
var txt = new fabric.Text("Canvas A 320 x 190", {
fontSize: 24,
left: 50,
top: 50,
fill: 'white'
})
canvasA.add(rect, circle, triangle, txt);
canvasA.calcOffset();
canvasA.renderAll();
}
// this is for initial JSON data setup only - not used in demo
function canvasB_LoadData() {
var elB = document.getElementById('test-imageB');
var imgB = new fabric.Image(elB, {
left: 0,
top: 0,
selectable: true
})
canvasB.add(imgB)
var containerWidth = canvasB.getWidth()
var containerHeight = canvasB.getHeight()
var result = fitInBox(imgB.width, imgB.height, containerWidth, containerHeight, true)
imgB.scaleToWidth(result.width)
var xOffset = (containerWidth - imgB.getScaledWidth()) / 2
var yOffset = (containerHeight - imgB.getScaledHeight()) / 2
imgB.set({
left: xOffset,
top: yOffset
})
imgB.setCoords()
var rect = new fabric.Rect({
left: 0,
top: 0,
fill: 'orange',
width: 60,
height: 60
})
var circle = new fabric.Circle({
radius: 40,
stroke: 'red',
strokeWidth: 12,
fill: null,
left: 120,
top: 40
})
var triangle = new fabric.Triangle({
width: 40,
height: 40,
fill: 'black',
left: 50,
top: 100
});
var txt = new fabric.Text("Canvas B 225 x 150", {
fontSize: 20,
left: 40,
top: 40,
fill: 'blue'
})
canvasB.add(rect, circle, triangle, txt);
canvasB.calcOffset();
canvasB.renderAll();
}
let canvasB_data = {
"version": "4.3.0",
"objects": [{
"type": "image",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 62.5,
"top": 0,
"width": 400,
"height": 600,
"fill": "rgb(0,0,0)",
"stroke": null,
"strokeWidth": 0,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 0.25,
"scaleY": 0.25,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0,
"cropX": 0,
"cropY": 0,
"src": "https://placekitten.com/400/600",
"crossOrigin": null,
"filters": []
},
{
"type": "rect",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 0,
"top": 0,
"width": 60,
"height": 60,
"fill": "orange",
"stroke": null,
"strokeWidth": 1,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 1,
"scaleY": 1,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0,
"rx": 0,
"ry": 0
},
{
"type": "circle",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 61.52,
"top": 24.21,
"width": 80,
"height": 80,
"fill": null,
"stroke": "red",
"strokeWidth": 12,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 1.1,
"scaleY": 1.1,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0,
"radius": 40,
"startAngle": 0,
"endAngle": 6.283185307179586
},
{
"type": "triangle",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 163,
"top": 107.89,
"width": 40,
"height": 40,
"fill": "black",
"stroke": null,
"strokeWidth": 1,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 1,
"scaleY": 1,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0
},
{
"type": "text",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 2.33,
"top": 39.01,
"width": 162.216796875,
"height": 22.599999999999998,
"fill": "blue",
"stroke": null,
"strokeWidth": 1,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 1,
"scaleY": 1,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0,
"text": "Canvas B 225 x 150",
"fontSize": 20,
"fontWeight": "normal",
"fontFamily": "Times New Roman",
"fontStyle": "normal",
"lineHeight": 1.16,
"underline": false,
"overline": false,
"linethrough": false,
"textAlign": "left",
"textBackgroundColor": "",
"charSpacing": 0,
"styles": {}
}
],
"background": "#FFFFFF",
"width": 225,
"height": 150
}
let canvasA_data = {
"version": "4.3.0",
"objects": [{
"type": "image",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 17.5,
"top": 0,
"width": 600,
"height": 400,
"fill": "rgb(0,0,0)",
"stroke": null,
"strokeWidth": 0,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 0.47,
"scaleY": 0.47,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0,
"cropX": 0,
"cropY": 0,
"src": "https://placekitten.com/600/400",
"crossOrigin": null,
"filters": []
},
{
"type": "rect",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 277.89,
"top": 73.23,
"width": 20,
"height": 20,
"fill": "red",
"stroke": null,
"strokeWidth": 1,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 1,
"scaleY": 1,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0,
"rx": 0,
"ry": 0
},
{
"type": "circle",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 92.67,
"top": -5.57,
"width": 40,
"height": 40,
"fill": null,
"stroke": "green",
"strokeWidth": 12,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 1.73,
"scaleY": 1.73,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0,
"radius": 20,
"startAngle": 0,
"endAngle": 6.283185307179586
},
{
"type": "triangle",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 17.2,
"top": 146.93,
"width": 40,
"height": 40,
"fill": "blue",
"stroke": null,
"strokeWidth": 1,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 1,
"scaleY": 1,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0
},
{
"type": "text",
"version": "4.3.0",
"originX": "left",
"originY": "top",
"left": 51.99,
"top": 96.51,
"width": 195.984375,
"height": 27.119999999999994,
"fill": "white",
"stroke": null,
"strokeWidth": 1,
"strokeDashArray": null,
"strokeLineCap": "butt",
"strokeDashOffset": 0,
"strokeLineJoin": "miter",
"strokeMiterLimit": 4,
"scaleX": 1,
"scaleY": 1,
"angle": 0,
"flipX": false,
"flipY": false,
"opacity": 1,
"shadow": null,
"visible": true,
"backgroundColor": "",
"fillRule": "nonzero",
"paintFirst": "fill",
"globalCompositeOperation": "source-over",
"skewX": 0,
"skewY": 0,
"text": "Canvas A 320 x 190",
"fontSize": 24,
"fontWeight": "normal",
"fontFamily": "Times New Roman",
"fontStyle": "normal",
"lineHeight": 1.16,
"underline": false,
"overline": false,
"linethrough": false,
"textAlign": "left",
"textBackgroundColor": "",
"charSpacing": 0,
"styles": {}
}
],
"background": "#FFFFFF",
"width": 320,
"height": 190
}
#container {
display: grid;
grid-template-columns: 330px 235px;
gap: 20px;
grid-template-rows: 300px, 20px, 20px, 100px;
}
.canvas-wrapper {
margin-left: 10px;
grid-column: 1;
justify-self: center;
}
.panel {
grid-column: 2;
margin-top: 20px;
}
.buttons {
font-family: sans-serif;
font-size: 10pt;
width: 100px;
margin: 3px 0;
}
.labels {
font-family: sans-serif;
font-size: 12pt;
}
<!DOCTYPE html>
<html>
<head>
<title></title>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/fabric@4.3.0/dist/fabric.js"></script>
<script type="text/javascript" src="js/fabric-resizing.js" defer></script>
<link rel="stylesheet" href="css/fabric-resize.css">
</head>
<body>
<div id="container" width="100%" height="100%">
<div class="canvas-wrapper">
<div class="labels">Canvas A - 320 x 190 px</div>
<div>
<canvas id="canvas-A" width="320" height="200" style="border:1px solid #000000;"></canvas>
</div>
</div>
<div class="panel">
<textarea name="canA" id="canA" cols="23" rows="8"></textarea>
<div>
<button id="canA-loadB" class="buttons">Load B Json</button>
<button id="canA-loadA" class="buttons">Load A Json</button>
</div>
<div>
<button id="canA-clear" class="buttons">Clear A</button>
<button id="canA-save" class="buttons">Save A Json</button>
</div>
</div>
<div class="canvas-wrapper">
<div class="labels">Canvas B - 225 x 150 px</div>
<canvas id="canvas-B" width="225" height="150" style="border:1px solid #000000;"></canvas>
</div>
<div class="panel">
<textarea name="canB" id="canB" cols="23" rows="8"></textarea>
<div>
<button id="canB-loadA" class="buttons">Load A Json</button>
<button id="canB-loadB" class="buttons">Load B Json</button>
</div>
<div>
<button id="canB-clear" class="buttons">Clear B</button>
<button id="canB-save" class="buttons">Save B Json</button>
</div>
</div>
<div class="labels" style="position:fixed; left:50px; top:455px;">
600 x 400 px
</div>
<div>
<img id="test-imageA" src="https://placekitten.com/600/400" style="position:fixed; left:50px; top:475px; width:25%; border:none;" />
</div>
<div class="labels" style="position:fixed; left:250px; top:455px;">
400 x 600 px
</div>
<div>
<img id="test-imageB" src="https://placekitten.com/400/600" style="position:fixed; left:250px; top:475px; height:25%; border:none;" />
</div>
</div>
</body>
</html>
解决方案
在尝试了许多不同的方法(也非常痛苦)后,终于解决了这个问题。
要点是选择所有对象,然后缩放和居中。一个棘手的问题是,我的数据中的第一个对象是一个图像,所有绘制的元素都需要与其保持注册。因此,我首先更正图像路径,然后,在缩放所选内容后,将所选内容切换为使图像在屏幕上保持居中。现在看一下代码,我可以看到优化它的地方(例如,因为我的数据中只有一个图像,所以在找到图像后不需要遍历所有剩余的数据)
loadData(data) {
var jsonObj = JSON.parse(data);
jsonObj.objects.forEach(element => {
if (element.type == "image") {
var imgPath = element.src.split("assets")
if (imgPath.length > 1) {
element.src = upath.joinSafe(projectDirectory, "assets", imgPath[1])
}
}
});
var self = this;
this.canvas.loadFromJSON(jsonObj, function () {
var selection = new fabric.ActiveSelection(self.canvas.getObjects(), { canvas: self.canvas });
// var selectionWidth = (selection.width > self.canvas.getWidth()) ? self.canvas.getWidth() : selection.width
// var selectionHeight = (selection.height > self.canvas.getHeight()) ? self.canvas.getHeight() : selection.height
var sizeObj = self.resizer(
{ width: self.canvas.getWidth(), height: self.canvas.getHeight() },
{ width: selection.width, height: selection.height });
// console.log('sizeObj', sizeObj);
// selection.scaleToWidth(sizeObj.width, false)
// selection.scaleToWidth(sizeObj.width, true)
selection.scaleToHeight(sizeObj.height)
// selection.scaleToHeight(sizeObj.height, true)
selection.center();
/**
* ------------------------------------------
* This keeps the IMAGE centered on the canvas instead of
* just centering the selection – otherwise the image will shift
*/
var selectionObjs = selection.getObjects();
var imgObj = selectionObjs[0]
var matrix = selection.calcTransformMatrix();
var finalPosition = fabric.util.transformPoint({ x: imgObj.left, y: imgObj.top }, matrix);
sizeObj = self.resizer(
{ width: self.canvas.getWidth(), height: self.canvas.getHeight() },
{ width: imgObj.getScaledWidth(), height: imgObj.getScaledHeight() });
selection.left += sizeObj.x - finalPosition.x;
selection.top += sizeObj.y - finalPosition.y;
// ------------------------------------------
selection.setCoords()
selection.destroy()
self.canvas.renderAll();
self.canvas.calcOffset()
}, function (o, object) {
})
self.setObjectsSelectable(self.toolbarIsVisible)
self.toolActive = false
}
resizer(canvas, imageObj) {
var imageAspectRatio = imageObj.width / imageObj.height;
var canvasAspectRatio = canvas.width / canvas.height;
var renderableHeight, renderableWidth, xStart, yStart;
// If image's aspect ratio is less than canvas's we fit on height
// and place the image centrally along width
if (imageAspectRatio < canvasAspectRatio) {
renderableHeight = canvas.height;
renderableWidth = imageObj.width * (renderableHeight / imageObj.height);
xStart = (canvas.width - renderableWidth) / 2;
yStart = 0;
}
// If image's aspect ratio is greater than canvas's we fit on width
// and place the image centrally along height
else if (imageAspectRatio > canvasAspectRatio) {
renderableWidth = canvas.width
renderableHeight = imageObj.height * (renderableWidth / imageObj.width);
xStart = 0;
yStart = (canvas.height - renderableHeight) / 2;
}
// Happy path - keep aspect ratio
else {
renderableHeight = canvas.height;
renderableWidth = canvas.width;
xStart = 0;
yStart = 0;
}
return { x: xStart, y: yStart, width: renderableWidth, height: renderableHeight }
}
相关文章