Repository: naturalcrit/homebrewery (js)

Issue:
Wrapping text around an image
First, thanks for the awesome tool. 

I have been inserting images into my docs and positioning them in various ways. However, I haven't figured out how to wrap text around an image.  

I have cut out my images so they have transparent backgrounds, leaving the border irregular as oppose to rectangular, but now I want the text to "fill in the gaps" (see pg. 125 in PHB). 

Any code help would be appreciated. Thanks.



### Additional Details

**Share Link** :

or

**Brew code to reproduce** : <details><summary>Click to expand</summary><code><pre>

PASTE BREW CODE HERE

</pre></code></details>

Pull request:
Image wrapping
Fixes #380, supersedes #3126

Deployed at https://homebrewery-pr-3660.herokuapp.com/

Adds `.wrapLeft` and `.wrapRight` CSS classes that can be applied to any image to cause image wrapping. This is done modifying the Image renderer of Marked, to emit the image `src` attribute as a CSS variable via `style="--HB_src: url(src)"` so the URL can be accessed from our stylesheets  (HTML attributes are not normally accessible from CSS).

Note, floating, and hence wrapping does not work for images with `position:absolute`. Not sure if we should have these classes automatically set `position:unset` at risk of confusing users because the image moves around, or not, at risk of confusing users because wrapping isn't working.

Eventually, if browsers implement `attr()`, we could undo the variable exposure workaround and just access `src` directly; users would not see any change in their brews.

![image](https://github.com/user-attachments/assets/0eb3fc60-8ce5-406f-b0d3-ec205573f962)

I have verified all of this working myself, but want at least one more eye to confirm

#### Reviewers, please copy this checklist and mark what you have verified:
*feel free to add sub-bullets for specific cases; I will copy them up here*
- [ ] New features work as expected
  - [x] `wrapLeft` wraps text on left side
  - [x] `wrapRight` wraps text on right side
  - [ ] Wrapping works when injected into images
  - [ ] Wrapping works when set via the `style` tab (e.g., use `--HB_src` to make *all* images wrap)
  - [ ] Images can be positioned via `margin` on the image
  - [x] New snippets are functional
  - [x] Snippet icons are clear
- [x] Old features still work
  - [x] Not breaking existing curly injection on images
  - [x] Not breaking normal image display
- [ ] Edge case / stress testing
- [x] Opportunities for simplification / refactoring
- [x] Legibility and appropriate comments

<details><summary>Copy this list</summary>

```
- [ ] New features work as expected
  - [ ] `wrapLeft` wraps text on left side
  - [ ] `wrapRight` wraps text on right side
  - [ ] Works for injected images
  - [ ] Works for images via `style` tab
  - [ ] Images can be positioned via `margin`
  - [ ] New snippets are functional
  - [ ] Snippet icons are clear
- [ ] Old features still work
  - [ ] Not breaking existing curly injection on images
  - [ ] Not breaking normal image display
- [ ] Edge case / stress testing
- [ ] Opportunities for simplification / refactoring
- [ ] Legibility and appropriate comments
```
</details> 

Patch:
diff --git a/client/icons/customIcons.less b/client/icons/customIcons.less
index c288e6eb..7525d50a 100644
--- a/client/icons/customIcons.less
+++ b/client/icons/customIcons.less
@@ -52,6 +52,12 @@
 .book-part-cover {
   mask-image: url('../icons/book-part-cover.svg');
 }
+.image-wrap-left {
+  content: url('../icons/image-wrap-left.svg');
+}
+.image-wrap-right {
+  content: url('../icons/image-wrap-right.svg');
+}
 .davek {
   mask-image: url('../icons/Davek.svg');
 }
diff --git a/client/icons/image-wrap-left.svg b/client/icons/image-wrap-left.svg
new file mode 100644
index 00000000..fe1024e4
--- /dev/null
+++ b/client/icons/image-wrap-left.svg
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   version="1.1"
+   x="0px"
+   y="0px"
+   viewBox="0 0 512.00006 512"
+   xml:space="preserve"
+   id="svg10"
+   sodipodi:docname="noun-wrap-image-left-212078.svg"
+   width="512.00006"
+   height="512"
+   inkscape:export-filename="image-wrap-right.svg"
+   inkscape:export-xdpi="300"
+   inkscape:export-ydpi="300"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"><defs
+     id="defs10" /><sodipodi:namedview
+     id="namedview10"
+     pagecolor="#ffffff"
+     bordercolor="#111111"
+     borderopacity="1"
+     inkscape:showpageshadow="0"
+     inkscape:pageopacity="0"
+     inkscape:pagecheckerboard="1"
+     inkscape:deskcolor="#d1d1d1" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="M 185.80018,144 H 32"
+     id="path11"
+     sodipodi:nodetypes="cc"
+     clip-path="none"
+     inkscape:export-filename="image-wrap-right.svg"
+     inkscape:export-xdpi="300"
+     inkscape:export-ydpi="300" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="M 185.80018,368 H 32"
+     id="path11-8"
+     sodipodi:nodetypes="cc"
+     clip-path="none" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="M 480.00007,32 H 32"
+     id="path11-8-2-67"
+     clip-path="none"
+     sodipodi:nodetypes="cc" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="M 480.00008,480 H 32"
+     id="path11-8-2-67-2"
+     clip-path="none"
+     sodipodi:nodetypes="cc" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="M 160.0001,255.98832 32,256.01162"
+     id="path11-0"
+     sodipodi:nodetypes="cc"
+     clip-path="none" /><path
+     id="path23"
+     style="opacity:0.922046;fill:#000000;fill-opacity:1;stroke-width:64;stroke-linecap:round;stroke-dasharray:none;paint-order:fill markers stroke"
+     d="m 416.00008,96 a 160,160 0 0 1 96,32.50977 v 254.98046 a 160,160 0 0 1 -96,32.50977 160,160 0 0 1 -160,-160 160,160 0 0 1 160,-160 z" /></svg>
diff --git a/client/icons/image-wrap-right.svg b/client/icons/image-wrap-right.svg
new file mode 100644
index 00000000..336a20b6
--- /dev/null
+++ b/client/icons/image-wrap-right.svg
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   version="1.1"
+   x="0px"
+   y="0px"
+   viewBox="0 0 512.00006 512"
+   xml:space="preserve"
+   id="svg10"
+   sodipodi:docname="noun-wrap-image-left-212078.svg"
+   width="512.00006"
+   height="512"
+   inkscape:export-filename="image-wrap-right.svg"
+   inkscape:export-xdpi="300"
+   inkscape:export-ydpi="300"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"><defs
+     id="defs10" /><sodipodi:namedview
+     id="namedview10"
+     pagecolor="#ffffff"
+     bordercolor="#111111"
+     borderopacity="1"
+     inkscape:showpageshadow="0"
+     inkscape:pageopacity="0"
+     inkscape:pagecheckerboard="1"
+     inkscape:deskcolor="#d1d1d1" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="M 326.1999,144 H 480.00008"
+     id="path11"
+     sodipodi:nodetypes="cc"
+     clip-path="none"
+     inkscape:export-filename="image-wrap-right.svg"
+     inkscape:export-xdpi="300"
+     inkscape:export-ydpi="300" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="M 326.1999,368 H 480.00008"
+     id="path11-8"
+     sodipodi:nodetypes="cc"
+     clip-path="none" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="M 32.00001,32 H 480.00008"
+     id="path11-8-2-67"
+     clip-path="none"
+     sodipodi:nodetypes="cc" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="M 32,480 H 480.00008"
+     id="path11-8-2-67-2"
+     clip-path="none"
+     sodipodi:nodetypes="cc" /><path
+     style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+     d="m 351.99998,255.98832 128.0001,0.0233"
+     id="path11-0"
+     sodipodi:nodetypes="cc"
+     clip-path="none" /><path
+     id="path23"
+     style="opacity:0.922046;fill:#000000;fill-opacity:1;stroke-width:64;stroke-linecap:round;stroke-dasharray:none;paint-order:fill markers stroke"
+     d="M 96,96 A 160,160 0 0 0 0,128.50977 V 383.49023 A 160,160 0 0 0 96,416 160,160 0 0 0 256,256 160,160 0 0 0 96,96 Z" /></svg>
diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js
index f5a4b9a5..24b74689 100644
--- a/shared/naturalcrit/markdown.js
+++ b/shared/naturalcrit/markdown.js
@@ -102,6 +102,20 @@ renderer.link = function (href, title, text) {
 	return out;
 };
 
+// Expose `src` attribute as `--HB_src` to make the URL accessible via CSS
+renderer.image = function (href, title, text) {
+	href = cleanUrl(href);
+	if (href === null)
+		return text;
+
+	let out = `<img src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
+	if (title)
+		out += ` title="${title}"`;
+
+	out += '>';
+	return out;
+}
+
 // Disable default reflink behavior, as it steps on our variables extension
 tokenizer.def = function () {
 	return undefined;
diff --git a/themes/V3/Blank/snippets.js b/themes/V3/Blank/snippets.js
index 8d45560c..8437dab2 100644
--- a/themes/V3/Blank/snippets.js
+++ b/themes/V3/Blank/snippets.js
@@ -153,6 +153,18 @@ module.exports = [
 				gen  : dedent`
 					![cat warrior](https://s-media-cache-ak0.pinimg.com/736x/4a/81/79/4a8179462cfdf39054a418efd4cb743e.jpg) {width:325px,mix-blend-mode:multiply}`
 			},
+			{
+				name : 'Image Wrap Left',
+				icon : 'fac image-wrap-left',
+				gen  : dedent`
+					![homebrewery_mug](http://i.imgur.com/hMna6G0.png) {width:280px,margin-right:-3cm,wrapLeft}`
+			},
+			{
+				name : 'Image Wrap Right',
+				icon : 'fac image-wrap-right',
+				gen  : dedent`
+					![homebrewery_mug](http://i.imgur.com/hMna6G0.png) {width:280px,margin-left:-3cm,wrapRight}`
+			},
 			{
 				name : 'Background Image',
 				icon : 'fas fa-tree',
diff --git a/themes/V3/Blank/style.less b/themes/V3/Blank/style.less
index 18a478cf..0f376634 100644
--- a/themes/V3/Blank/style.less
+++ b/themes/V3/Blank/style.less
@@ -156,6 +156,19 @@ body { counter-reset : page-numbers; }
 		break-inside                : avoid;
 	}
 
+	/* Wrap Text */
+	.wrapLeft {
+		shape-outside : var(--HB_src);
+		float         : right;
+		shape-margin  : 0.2cm;
+	}
+
+	.wrapRight {
+		shape-outside : var(--HB_src);
+		float         : left;
+		shape-margin  : 0.2cm;
+	}
+
 	/* Watermark */
 	.watermark {
 		position        : absolute;


Test patch:
diff --git a/tests/markdown/mustache-syntax.test.js b/tests/markdown/mustache-syntax.test.js
index 3f7f2529..51284ef2 100644
--- a/tests/markdown/mustache-syntax.test.js
+++ b/tests/markdown/mustache-syntax.test.js
@@ -322,9 +322,9 @@ describe('Injection: When an injection tag follows an element', ()=>{
 		});
 
 		it('Renders an image element with injected style', function() {
-			const source = '![alt text](http://i.imgur.com/hMna6G0.png){position:absolute}';
+			const source = '![alt text](https://i.imgur.com/hMna6G0.png){position:absolute}';
 			const rendered = Markdown.render(source).trimReturns();
-			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><img style="position:absolute;" src="http://i.imgur.com/hMna6G0.png" alt="alt text"></p>');
+			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute;" src="https://i.imgur.com/hMna6G0.png" alt="alt text"></p>');
 		});
 
 		it('Renders an element modified by only the first of two consecutive injections', function() {
@@ -343,19 +343,19 @@ describe('Injection: When an injection tag follows an element', ()=>{
 		it('Renders an image with added attributes', function() {
 			const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
 			const rendered = Markdown.render(source).trimReturns();
-			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`);
+			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`);
 		});
 
 		it('Renders an image with "=" in the url, and added attributes', function() {
 			const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png?auth=12345&height=1024) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
 			const rendered = Markdown.render(source).trimReturns();
-			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png?auth=12345&height=1024" alt="homebrew mug" a="b and c" d="e"></p>`);
+			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png?auth=12345&height=1024); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png?auth=12345&height=1024" alt="homebrew mug" a="b and c" d="e"></p>`);
 		});
 
 		it('Renders an image and added attributes with "=" in the value, ', function() {
 			const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e,otherUrl="url?auth=12345"}`;
 			const rendered = Markdown.render(source).trimReturns();
-			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`);
+			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`);
 		});
 	});
 
diff --git a/tests/markdown/variables.test.js b/tests/markdown/variables.test.js
index 2c8db375..bf778b14 100644
--- a/tests/markdown/variables.test.js
+++ b/tests/markdown/variables.test.js
@@ -315,21 +315,21 @@ describe('Normal Links and Images', ()=>{
 		const source = `![alt text](url)`;
 		const rendered = Markdown.render(source).trimReturns();
 		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
-			<p><img src="url" alt="alt text"></p>`.trimReturns());
+			<p><img src="url" alt="alt text" style="--HB_src:url(url);"></p>`.trimReturns());
 	});
 
 	it('Renders normal images with a title', function() {
 		const source = 'An image ![alt text](url "and title")!';
 		const rendered = Markdown.render(source).trimReturns();
 		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
-			<p>An image <img src="url" alt="alt text" title="and title">!</p>`.trimReturns());
+			<p>An image <img src="url" alt="alt text" style="--HB_src:url(url);" title="and title">!</p>`.trimReturns());
 	});
 
 	it('Applies curly injectors to images', function() {
 		const source = `![alt text](url){width:100px}`;
 		const rendered = Markdown.render(source).trimReturns();
 		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
-			<p><img style="width:100px;" src="url" alt="alt text"></p>`.trimReturns());
+			<p><img style="--HB_src:url(url); width:100px;" src="url" alt="alt text"></p>`.trimReturns());
 	});
 
 	it('Renders normal links', function() {