Add support for images

This commit is contained in:
2025-12-02 11:43:25 +01:00
parent 15493da657
commit 980192253f
15 changed files with 481 additions and 6 deletions

View File

@@ -16,6 +16,12 @@ module Ezdoc
@current_target.add_run(Nodes::Run.new(value, **current_formatting))
end
def image(path, width: nil, height: nil)
validate_image_path!(path)
img = register_image(path, width: width, height: height)
@current_target.add_run(img)
end
def b(&block)
with_format(:bold, &block)
end
@@ -110,5 +116,14 @@ module Ezdoc
underline: @format_stack.include?(:underline)
}
end
def validate_image_path!(path)
raise ArgumentError, "Image file not found: #{path}" unless File.exist?(path)
ext = File.extname(path).downcase
return if %w[.png .jpg .jpeg].include?(ext)
raise ArgumentError, "Unsupported image format: #{ext}. Use PNG or JPEG."
end
end
end

View File

@@ -21,6 +21,7 @@ module Ezdoc
@current_table = nil
@current_row = nil
@num_id_counter = 0
@images = {}
end
def save(path)
@@ -30,5 +31,26 @@ module Ezdoc
def lists
@nodes.select { |n| n.is_a?(Nodes::List) }
end
def images
@images.values
end
def register_image(path, width: nil, height: nil)
return @images[path] if @images[path]
rid = next_image_rid
width_emu, height_emu = ImageDimensions.calculate_emus(path, width: width, height: height)
image = Nodes::Image.new(path, rid: rid, width_emu: width_emu, height_emu: height_emu)
@images[path] = image
image
end
private
def next_image_rid
base = lists.any? ? 2 : 1
"rId#{base + @images.size}"
end
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
require "fastimage"
module Ezdoc
class ImageDimensions
EMUS_PER_INCH = 914_400
DEFAULT_DPI = 96
def self.read(path)
FastImage.size(path) || raise(ArgumentError, "Could not read image dimensions: #{path}")
end
def self.calculate_emus(path, width: nil, height: nil)
native_width, native_height = read(path)
calculate_dimensions_emu(native_width, native_height, width, height)
end
def self.calculate_dimensions_emu(native_width, native_height, width, height)
if width && height
[parse_dimension(width), parse_dimension(height)]
elsif width
w = parse_dimension(width)
ratio = native_height.to_f / native_width
[w, (w * ratio).to_i]
elsif height
h = parse_dimension(height)
ratio = native_width.to_f / native_height
[(h * ratio).to_i, h]
else
pixels_to_emus(native_width, native_height)
end
end
def self.parse_dimension(value)
case value
when Integer
pixels_to_emus(value, 0).first
when /\A(\d+(?:\.\d+)?)\s*in\z/i
(::Regexp.last_match(1).to_f * EMUS_PER_INCH).to_i
when /\A(\d+(?:\.\d+)?)\s*cm\z/i
(::Regexp.last_match(1).to_f * 360_000).to_i
when /\A(\d+(?:\.\d+)?)\s*px\z/i, /\A(\d+)\z/
pixels_to_emus(::Regexp.last_match(1).to_i, 0).first
else
raise ArgumentError, "Invalid dimension format: #{value}. Use '2in', '5cm', '100px', or integer pixels."
end
end
def self.pixels_to_emus(width_px, height_px)
emu_per_pixel = EMUS_PER_INCH / DEFAULT_DPI
[(width_px * emu_per_pixel).to_i, (height_px * emu_per_pixel).to_i]
end
end
end

38
lib/ezdoc/nodes/image.rb Normal file
View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Ezdoc
module Nodes
class Image < Base
attr_reader :path, :width_emu, :height_emu, :rid, :filename
EMUS_PER_INCH = 914_400
EMUS_PER_CM = 360_000
DEFAULT_DPI = 96
def initialize(path, rid:, width_emu:, height_emu:)
super()
@path = path
@rid = rid
@width_emu = width_emu
@height_emu = height_emu
@filename = "image#{rid.delete_prefix("rId")}.#{extension}"
end
def extension
case File.extname(@path).downcase
when ".png" then "png"
when ".jpg", ".jpeg" then "jpeg"
else raise ArgumentError, "Unsupported image format: #{File.extname(@path)}"
end
end
def content_type
extension == "png" ? "image/png" : "image/jpeg"
end
def doc_pr_id
rid.delete_prefix("rId").to_i
end
end
end
end

View File

@@ -16,6 +16,12 @@ module Ezdoc
zipfile.get_output_stream("word/document.xml") { |f| f.write(document_xml) }
zipfile.get_output_stream("word/numbering.xml") { |f| f.write(numbering_xml) } if lists?
images.each do |image|
zipfile.get_output_stream("word/media/#{image.filename}") do |f|
f.write(File.binread(image.path))
end
end
end
end
@@ -25,8 +31,12 @@ module Ezdoc
@document.lists.any?
end
def images
@document.images
end
def content_types_xml
Xml::ContentTypes.new(has_numbering: lists?).to_xml
Xml::ContentTypes.new(has_numbering: lists?, images: images).to_xml
end
def relationships_xml
@@ -34,7 +44,7 @@ module Ezdoc
end
def document_relationships_xml
Xml::DocumentRelationships.new(has_numbering: lists?).to_xml
Xml::DocumentRelationships.new(has_numbering: lists?, images: images).to_xml
end
def document_xml

View File

@@ -5,8 +5,9 @@ module Ezdoc
class ContentTypes
NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types"
def initialize(has_numbering: false)
def initialize(has_numbering: false, images: [])
@has_numbering = has_numbering
@images = images
end
def to_xml
@@ -14,6 +15,11 @@ module Ezdoc
xml.Types(xmlns: NAMESPACE) do
xml.Default(Extension: "rels", ContentType: "application/vnd.openxmlformats-package.relationships+xml")
xml.Default(Extension: "xml", ContentType: "application/xml")
image_extensions.each do |ext, content_type|
xml.Default(Extension: ext, ContentType: content_type)
end
xml.Override(
PartName: "/word/document.xml",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
@@ -28,6 +34,16 @@ module Ezdoc
end
builder.to_xml
end
private
def image_extensions
extensions = {}
@images.each do |image|
extensions[image.extension] ||= image.content_type
end
extensions
end
end
end
end

View File

@@ -5,7 +5,10 @@ module Ezdoc
class DocumentXml
NAMESPACES = {
"xmlns:w" => "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"xmlns:r" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
"xmlns:r" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"xmlns:wp" => "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"xmlns:a" => "http://schemas.openxmlformats.org/drawingml/2006/main",
"xmlns:pic" => "http://schemas.openxmlformats.org/drawingml/2006/picture"
}.freeze
def initialize(nodes)
@@ -60,6 +63,15 @@ module Ezdoc
end
def render_run(xml, run)
case run
when Nodes::Image
render_image(xml, run)
when Nodes::Run
render_text_run(xml, run)
end
end
def render_text_run(xml, run)
xml["w"].r do
if run.bold || run.italic || run.underline
xml["w"].rPr do
@@ -72,6 +84,45 @@ module Ezdoc
end
end
def render_image(xml, image)
xml["w"].r do
xml["w"].drawing do
xml["wp"].inline(distT: "0", distB: "0", distL: "0", distR: "0") do
xml["wp"].extent(cx: image.width_emu.to_s, cy: image.height_emu.to_s)
xml["wp"].docPr(id: image.doc_pr_id.to_s, name: image.filename)
xml["wp"].cNvGraphicFramePr do
xml["a"].graphicFrameLocks(noChangeAspect: "1")
end
xml["a"].graphic do
xml["a"].graphicData(uri: "http://schemas.openxmlformats.org/drawingml/2006/picture") do
xml["pic"].pic do
xml["pic"].nvPicPr do
xml["pic"].cNvPr(id: "0", name: image.filename)
xml["pic"].cNvPicPr
end
xml["pic"].blipFill do
xml["a"].blip("r:embed" => image.rid)
xml["a"].stretch do
xml["a"].fillRect
end
end
xml["pic"].spPr do
xml["a"].xfrm do
xml["a"].off(x: "0", y: "0")
xml["a"].ext(cx: image.width_emu.to_s, cy: image.height_emu.to_s)
end
xml["a"].prstGeom(prst: "rect") do
xml["a"].avLst
end
end
end
end
end
end
end
end
end
def render_table(xml, table)
xml["w"].tbl do
xml["w"].tblPr do

View File

@@ -21,9 +21,11 @@ module Ezdoc
class DocumentRelationships
NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships"
IMAGE_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
def initialize(has_numbering: false)
def initialize(has_numbering: false, images: [])
@has_numbering = has_numbering
@images = images
end
def to_xml
@@ -36,6 +38,14 @@ module Ezdoc
Target: "numbering.xml"
)
end
@images.each do |image|
xml.Relationship(
Id: image.rid,
Type: IMAGE_TYPE,
Target: "media/#{image.filename}"
)
end
end
end
builder.to_xml