I'm trying to do extracting and replacing equivalents of JavaScript DocumentFragments through jsoup DOM model.
Does anyone have some ready to use code to emulate DOM Range selection and operations on it? I would like to select a range of text, which can possibly pass through multiple inline nodes (such as <a>, <span> etc.), start or end in the middle of such inline nodes etc. In JavaScript it's easy with Range operations, extracting a DocumentFragment form it, surrounding it etc. I guess JavaScript Range is splitting the inner nodes as needed to handle such extraction and insertion back correctly. How would I do this with jsoup in Java?
Edit: Just thinking out loud how to do this - probably would need to search for the "peak" element within my range, then go to both start and end of the range and "elevate" them to the "peak level" by jumping up to the parent if my start is the child no. 0, or else splitting the element children list just before the range start element... If there is such a code ready, I'd rather re-use it, else will have to write it from scratch.
Update Dec. 18, 2015: Posted my answer with the working code I developed, see below.
Two points:
JSoup offers some methods for manipulating text nodes as String object.
Java and its ecosystem offer powerful apis for manipulating String objects.
You may try to find your way with the two above options before writing DOM Range operations from scratch.
Here are some methods from the JSoup API:
Element#text()
Gets the combined unencoded text of this element as String.
Excerpt from API:
Given HTML <p>Hello <b>there</b> now! </p>, p.text() returns "Hello there now!"
Element#text(String) Replace the current text of this element with the passed unencoded one.
Element#ownText Gets the unencoded text of this element only without text of all children.
Excerpt from API:
For example, given HTML <p>Hello <b>there</b> now!</p>, p.ownText() returns "Hello now!", whereas p.text() returns "Hello there now!". Note that the text within the b element is not returned, as it is not a direct child of the p element.
You may find also useful these two recipes:
Extract attributes, text, and HTML from elements
Setting the text content of elements
Here is my promised code for wrapping an arbitrary range of DOM body into an arbitrary html tag for easy extraction, moving, replacement, copy/paste like operations etc.
Update Dec. 19, 2015 Added TextNode splitting in the middle of text by means of wrapRange() method variant with optional offsets into the text node where the range should start or end. Now arbitrary copy/paste/move within jsoup DOM model are possible.
TODO: (for myself or some other good soul)
Write a sample project demonstrating this, plus a number of test cases, and post to GitHub. No time for this now, but seems to work fine in my app (processing HTML code from web pages and ebooks for reading aloud with TTS - see #Voice Aloud Reader app in Google Play)
The RangeWrapper.java module:
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.parser.Tag;
import java.util.ArrayList;
/**
* Created by greg on 12/18/2015.
*/
public class RangeWrapper {
/**
* Wrap the supplied HTML around the "range" from startEl to endEl.*
* #param startEl the first element to be included into the range
* #param endEl the last element to be included into the range
* #param html HTML to wrap around this element, e.g.
* {#code <span class="head"></span>}. Can be arbitrarily deep.
* #return the wrapping element
*/
public static Element wrapRange(Node startEl, Node endEl, String html) {
if (startEl == endEl) { // special case
return (Element) startEl.wrap(html).parentNode();
}
int startDepth = NodeWalker.getNodeDepth(startEl);
int endDepth = NodeWalker.getNodeDepth(endEl);
int minDepth = getRangeMinDepth(startEl, endEl);
int n;
while (startDepth > minDepth) {
Element parent = (Element)startEl.parentNode();
if ((n = startEl.siblingIndex()) > 0) {
// splitting the parent
ArrayList<Node> children = new ArrayList<Node>(parent.childNodes());
Element parent2 = new Element(Tag.valueOf(parent.tagName()), parent.baseUri(), parent.attributes());
parent.after(parent2);
for (int i = n; i < children.size(); i++)
parent2.appendChild(children.get(i));
startEl = parent2;
} else {
startEl = parent;
}
startDepth--;
}
while (endDepth > minDepth) {
Element parent = (Element)endEl.parentNode();
if ((n = endEl.siblingIndex()) < parent.children().size()-1) {
// splitting the parent
ArrayList<Node> children = new ArrayList<Node>(parent.childNodes());
Element parent2 = new Element(Tag.valueOf(parent.tagName()), parent.baseUri(), parent.attributes());
parent.before(parent2);
for (int i = 0; i <= n; i++)
parent2.appendChild(children.get(i));
endEl = parent2;
} else {
endEl = parent;
}
endDepth--;
}
// Now startEl and endEl are on the same depth == minDepth.
// Wrap the range with our html string
Element range = (Element) startEl.wrap(html).parentNode();
Node nextToAppend;
do {
nextToAppend = range.nextSibling();
// If nextToAppend is null, something is really wrong...
// Commented out to let it crash and investigate,
// so far it did not happen.
//if (nextToAppend == null)
// break;
range.appendChild(nextToAppend);
} while (nextToAppend != endEl);
return range;
}
/**
* Wrap the supplied HTML around the "range" from startEl to endEl.*
* #param startEl the first element to be included into the range
* #param stOffset if startEl is TextNode, split at this offset
* and include only the tail. Otherwise ignored.
* #param endEl the last element to be included into the range
* #param endOffset if endEl is a Text node, split at this offset
* and include only the head. Otherwise ignored.
* #param html HTML to wrap around this element, e.g. {#code <span class="head"></span>}. Can be arbitrarily deep.
* #return the wrapping element
*/
public static Element wrapRange(Node startEl, int stOffset, Node endEl, int endOffset, String html) {
if (stOffset > 0 && startEl instanceof TextNode) {
TextNode tn = (TextNode) startEl;
if (endOffset < tn.getWholeText().length()-1) {
startEl = tn.splitText(stOffset); // Splits tn and adds tail to DOM, returns tail
}
}
if (endOffset > 0 && endEl instanceof TextNode) {
TextNode tn = (TextNode) endEl;
if (endOffset < tn.getWholeText().length()-1) {
tn.splitText(stOffset); // Splits tn and adds tail to DOM, we take head == original endEl
}
}
return wrapRange(startEl, endEl, html);
}
/**
* Calculate the depth of the range between the two given nodes, relative to body.
* The body has depth 0.
* #param startNode the first element to be included into the range
* #param endNode the last element to be included into the range
* #return minimum depth found in the range
*/
public static int getRangeMinDepth(final Node startNode, final Node endNode) {
class DepthVisitor implements NodeWalker.NodeWalkVisitor {
private int _minDepth = Integer.MAX_VALUE;
public boolean head(Node node, int depth) {
if (depth < _minDepth)
_minDepth = depth;
return true;
}
public boolean tail(Node node, int depth) {return true;}
int getMinDepth() { return _minDepth; }
};
DepthVisitor visitor = new DepthVisitor();
NodeWalker nw = new NodeWalker(visitor);
nw.walk(startNode, endNode);
return visitor.getMinDepth();
}
}
...and the NodeWalker.java the above code uses, adapted from NodeTraversor and NodeVisitor classes in jsoup package:
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.select.NodeVisitor;
/**
* Depth-first node traversor. Use to iterate through all nodes under and including the specified root node.
* <p>
* This implementation does not use recursion, so a deep DOM does not risk blowing the stack.
* </p>
*/
public class NodeWalker {
private NodeWalkVisitor visitor;
/**
* Create a new traversor.
* #param visitor a class implementing the {#link NodeVisitor} interface, to be called when visiting each node.
*/
public NodeWalker(NodeWalkVisitor visitor) {
this.visitor = visitor;
}
/**
* Start a depth-first traverse of the whole body and all of its descendants.
* #param startNode the arbitrary start point node point within body to traverse from.
* #param endNode the arbitrary end point node point within body where we stop traverse.
* Can be null, in which case we walk until the end of the body.
*/
public void walk(Node startNode, Node endNode) {
Node node = startNode;
int depth = getNodeDepth(startNode); // let's calulate depth relative to body, body is depth 0
while (node != null) {
if (!visitor.head(node, depth))
break;
if (node.childNodeSize() > 0) {
node = node.childNode(0);
depth++;
} else {
while (node.nextSibling() == null && depth > 0) {
if (!visitor.tail(node, depth) || node == endNode)
return;
node = node.parentNode();
depth--;
}
if (!visitor.tail(node, depth) || node == endNode)
break;
node = node.nextSibling();
}
}
}
// The walkBack() was not needed, but leaving it here, may be useful for something...
// /**
// * Start a depth-first backward traverse of the whole body and all of its descendants.
// * #param startNode the arbitrary start point node point within body to traverse from.
// * #param endNode the arbitrary end point node point within body where we stop traverse.
// * Can be null, in which case we walk until the end of the body.
// */
// public void walkBack(Node startNode, Node endNode) {
// Node node = startNode;
// int depth = getNodeDepth(startNode); // let's calulate depth relative to body, body is depth 0
//
// while (node != null) {
// if (!visitor.tail(node, depth))
// break;
// if (node.childNodeSize() > 0) {
// node = node.childNode(node.childNodeSize() - 1);
// depth++;
// } else {
// while (node.previousSibling() == null && depth > 0) {
// if (!visitor.head(node, depth) || node == endNode)
// return;
// node = node.parentNode();
// depth--;
// }
// if (!visitor.head(node, depth) || node == endNode)
// break;
// node = node.previousSibling();
// }
// }
// }
/**
* Calculate the depth of the given node relative to body. The body has depth 0.
* #param givenNode the node within the body to calculate depth for.
* #return the depth of the givenNode
*/
public static int getNodeDepth(Node givenNode) {
Node node = givenNode;
int depth = 0; // let's calulate depth relative to body, body is depth 0
if (!(node instanceof Element) || !"body".equals(((Element) node).tagName())) {
do {
depth++;
node = (Element)node.parentNode();
} while (node != null && !"body".equals(((Element) node).tagName()));
}
return depth;
}
public interface NodeWalkVisitor {
/**
* Callback for when a node is first visited.
*
* #param node the node being visited.
* #param depth the depth of the node, relative to the root node. E.g., the root node has depth 0, and a child node
* of that will have depth 1.
* #return true to continue walk, false to abort
*/
boolean head(Node node, int depth);
/**
* Callback for when a node is last visited, after all of its descendants have been visited.
*
* #param node the node being visited.
* #param depth the depth of the node, relative to the root node. E.g., the root node has depth 0, and a child node
* of that will have depth 1.
* #return true to continue walk, false to abort
*/
boolean tail(Node node, int depth);
}
}
Greg
Related
The TSref entry for slide explains:
Up to Version 9 of TYPO3 the sliding stopped when reaching a folder.
Beginning with TYPO3 10 this is not longer the case. See
$cObj->checkPid_badDoktypeList.
Ok, this variable is still 255 (formerly directly, now via constant PageRepository::DOKTYPE_RECYCLER).
What exactly should I see there that will help me? Or better, how to get content sliding still working like before?
You have to extend the ContentObjectRenderer class and overwrite the getSlidePids method with your own extension.
In the boot function of ext_localconf.php:
$GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::class] = [
'className' => \YourVendor\YourExtensionKey\ContentObject\ContentObjectRenderer::class
];
Then you have to create your own "Classes/ContentObject/ContentObjectRenderer.php" with:
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace YourVendor\YourExtension\ContentObject;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
class ContentObjectRenderer extends \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer
{
/**
* Returns all parents of the given PID (Page UID) list
*
* #param string $pidList A list of page Content-Element PIDs (Page UIDs) / stdWrap
* #param array $pidConf stdWrap array for the list
* #return string A list of PIDs
* #internal
*/
public function getSlidePids($pidList, $pidConf)
{
// todo: phpstan states that $pidConf always exists and is not nullable. At the moment, this is a false positive
// as null can be passed into this method via $pidConf. As soon as more strict types are used, this isset
// check must be replaced with a more appropriate check like empty or count.
$pidList = isset($pidConf) ? trim($this->stdWrap($pidList, $pidConf)) : trim($pidList);
if ($pidList === '') {
$pidList = 'this';
}
$tsfe = $this->getTypoScriptFrontendController();
$listArr = null;
if (trim($pidList)) {
$listArr = GeneralUtility::intExplode(',', str_replace('this', (string)$tsfe->contentPid, $pidList));
$listArr = $this->checkPidArray($listArr);
}
$pidList = [];
if (is_array($listArr) && !empty($listArr)) {
foreach ($listArr as $uid) {
$page = $tsfe->sys_page->getPage($uid);
if($page['doktype'] == PageRepository::DOKTYPE_SYSFOLDER)
break;
if (!$page['is_siteroot']) {
$pidList[] = $page['pid'];
}
}
}
return implode(',', $pidList);
}
}
I don't want to use the default header in my FCE's, but only custom flux fields. In the backend list views my FCE's are shown as "[no title]" because the default header is not filled. This leads to much confusion for editors.
How can I define one of my custom flux fields to be used as title for the FCE in TYPO3 Backend list views etc.?
You can't just use a field from the flexform, because all fields from the FCE are stored in the same field in the database (pi_flexform).
What you can do is to render the content element title with a user function. It is registered with a line like this in the TCA config:
$GLOBALS['TCA']['tt_content']['ctrl']['label_userFunc'] = 'Vendor\\Extkey\\Utility\\ContentElementLabelRenderer->getContentElementTitle';
The user function itself could look like this:
<?php
namespace Vendor\Extkey\Utility;
/**
* This class renders a human readable title for FCEs,
* so one is able to find a content element by its headline.
*/
class ContentElementLabelRenderer implements \TYPO3\CMS\Core\SingletonInterface {
/**
* #var \TYPO3\CMS\Extbase\Service\FlexFormService
* #inject
*/
protected $flexFormService = null;
/**
* Returns the content element title for a given content element
*/
public function getContentElementTitle(&$params) {
if (null === $this->flexFormService) {
$this->flexFormService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance('TYPO3\\CMS\\Extbase\\Service\\FlexFormService');
}
if (ctype_digit($params['row']['uid']) && 'fluidcontent_content' === $params['row']['CType']) {
// If this is a FCE, parse the flexform and template name and generate the
// title in a template specific way.
$row = $params['row'];
$additionalRowData = $GLOBALS['TYPO3_DB']->exec_SELECTgetSingleRow('pi_flexform, tx_fed_fcefile', 'tt_content', 'uid = ' . $row['uid']);
$flexFormContent = $this->flexFormService->convertFlexFormContentToArray($additionalRowData['pi_flexform']);
$lastColonPosition = strrpos($additionalRowData['tx_fed_fcefile'], ':');
$contentElementType = (FALSE === $lastColonPosition) ? 'invalidtype' : substr($additionalRowData['tx_fed_fcefile'], $lastColonPosition + 1);
switch ($contentElementType) {
case 'Image.html':
$params['title'] = 'Image: "' . ($flexFormContent['title'] ?: $flexFormContent['subtitle']) . '"';
break;
default:
$params['title'] = 'Unknown content element type';
break;
}
}
else {
// If this is not a FCEm, print out "normal"
// title. Not the real thing, but comes pretty close, hopefully.
$params['title'] = $params['row']['header'] ?: ($params['row']['subheader'] ?: $params['row']['bodytext']);
}
}
}
This produces a maintainance problem though: Every time you add or change a content element, you have to update this file.
I am working on a special template for the news extension tx_news in Typo3.
I am completely new to Typo3 and especially Fluid.
What I want is an output of exactly 4 news items, but each of these items must have an image.
What I need is programming logic, something like:
If the newsItem has an image, and less than 4 items have been rendered so far, then render. Else don't do anything.
I read this question and answer:
TYPO3 Fluid complex if conditions
so I suspect I need something like a viewhelper.
So far my templates has this code to output items:
<f:for each="{news}" as="newsItem" iteration="iterator">
<f:if condition="{newsItem.falMedia}">
<f:if condition="{iterator.cycle}<=4">
<f:render partial="List/TeaserItem" arguments="{newsItem: newsItem,settings:settings,iterator:iterator, contentObjectData:contentObjectData}" />
</f:if>
</f:if>
</f:for>
But this will of course stop after iterating through news 4 times. So if one entry without image didn't get rendered, I will only have three items output.
I'd need an if condition kind of like this:
if ({newsItem.falMedia} && {iterator.cycle}<=4){
render image }
else {iterator.cycle--}
but I can't figure out how to pass the iterator variable of my for-loop to the new viewhelper, and especially to pass it back to the for-loop.
In short words this kind of logic isn't possible in Fluid - reason is simple -it's template engine.
You need to create your own extension and create a ViewHelper in it, which will take the collection of News will check if it has required settings (falMedia existing in this case) and will return limited array which you can iterate. Indeed, reusing f:for will be fastest solution.
I'm afraid, that's only way.
Here's the sample (compare it to original f:for viewhelper):
<?php
namespace TYPO3\CMS\Fluid\ViewHelpers;
class ForNewsWithMediaViewHelper extends \TYPO3\CMS\Fluid\Core\ViewHelper\AbstractViewHelper {
/**
* Iterates through elements of $each and renders child nodes
*
* #param array $each The array or \TYPO3\CMS\Extbase\Persistence\ObjectStorage to iterated over
* #param string $as The name of the iteration variable
* #param string $key The name of the variable to store the current array key
* #param boolean $reverse If enabled, the iterator will start with the last element and proceed reversely
* #param string $iteration The name of the variable to store iteration information (index, cycle, isFirst, isLast, isEven, isOdd)
* #param int $limit Limit of the news items to show
* #return string Rendered string
* #api
*/
public function render($each, $as, $key = '', $reverse = FALSE, $iteration = NULL, $limit = NULL) {
return self::renderStatic($this->arguments, $this->buildRenderChildrenClosure(), $this->renderingContext, $limit);
}
/**
* #param array $arguments
* #param \Closure $renderChildrenClosure
* #param \TYPO3\CMS\Fluid\Core\Rendering\RenderingContextInterface $renderingContext
* #param int $limit Limit of the news items to show
* #return string
* #throws \TYPO3\CMS\Fluid\Core\ViewHelper\Exception
*/
static public function renderStatic(array $arguments, \Closure $renderChildrenClosure, \TYPO3\CMS\Fluid\Core\Rendering\RenderingContextInterface $renderingContext, $limit = NULL) {
$templateVariableContainer = $renderingContext->getTemplateVariableContainer();
if ($arguments['each'] === NULL) {
return '';
}
if (is_object($arguments['each']) && !$arguments['each'] instanceof \Traversable) {
throw new \TYPO3\CMS\Fluid\Core\ViewHelper\Exception('ForViewHelper only supports arrays and objects implementing \Traversable interface', 1248728393);
}
if ($arguments['reverse'] === TRUE) {
// array_reverse only supports arrays
if (is_object($arguments['each'])) {
$arguments['each'] = iterator_to_array($arguments['each']);
}
$arguments['each'] = array_reverse($arguments['each']);
}
$iterationData = array(
'index' => 0,
'cycle' => 1,
'total' => count($arguments['each'])
);
$limitCycle = 1;
$output = '';
/**
* #type $singleElement Tx_News_Domain_Model_News
*/
foreach ($arguments['each'] as $keyValue => $singleElement) {
if (is_null($singleElement->getFalMedia())
|| !is_null($limit) && $limitCycle > $limit
) {
continue;
}
$limitCycle++;
$templateVariableContainer->add($arguments['as'], $singleElement);
if ($arguments['key'] !== '') {
$templateVariableContainer->add($arguments['key'], $keyValue);
}
if ($arguments['iteration'] !== NULL) {
$iterationData['isFirst'] = $iterationData['cycle'] === 1;
$iterationData['isLast'] = $iterationData['cycle'] === $iterationData['total'];
$iterationData['isEven'] = $iterationData['cycle'] % 2 === 0;
$iterationData['isOdd'] = !$iterationData['isEven'];
$templateVariableContainer->add($arguments['iteration'], $iterationData);
$iterationData['index']++;
$iterationData['cycle']++;
}
$output .= $renderChildrenClosure();
$templateVariableContainer->remove($arguments['as']);
if ($arguments['key'] !== '') {
$templateVariableContainer->remove($arguments['key']);
}
if ($arguments['iteration'] !== NULL) {
$templateVariableContainer->remove($arguments['iteration']);
}
}
return $output;
}
}
So you can use it in your view as:
<f:forNewsWithMedia each="{news}" as="newsItem" iteration="iterator" limit="4">
<f:render partial="List/TeaserItem" arguments="{newsItem: newsItem,settings:settings,iterator:iterator, contentObjectData:contentObjectData}" />
</f:forNewsWithMedia>
I following code in my html:
Sign up
It is not working then Disconnect or similar plugin installed in Chrome or other browser. How to solve this issue?
I have used decoded version of Universal Analytics code and put calling hitCallback there:
<!-- Google Analytics -->
<script>
/**
* Creates a temporary global ga object and loads analy tics.js.
* Paramenters o, a, and m are all used internally. They could have been declared using 'var',
* instead they are declared as parameters to save 4 bytes ('var ').
*
* #param {Window} i The global context object.
* #param {Document} s The DOM document object.
* #param {string} o Must be 'script'.
* #param {string} g URL of the analytics.js script. Inherits protocol from page.
* #param {string} r Global name of analytics object. Defaults to 'ga'.
* #param {DOMElement?} a Async script tag.
* #param {DOMElement?} m First script tag in document.
*/
(function(window, document, strScript, url, variableName, scriptElement, firstScript) {
window['GoogleAnalyticsObject'] = variableName; // Acts as a pointer to support renaming.
// Creates an initial ga() function. The queued commands will be executed once analytics.js loads.
window[variableName] = window[variableName] || function() {
(window[variableName].q = window[variableName].q || []).push(arguments);
// If user uses Disconnect, Ghostery, DoNotTrackMe or similar plugin we shoud make hitCallback to work
if(typeof arguments[2] == "object" && typeof arguments[2].hitCallback == "function") {
arguments[2].hitCallback();
} else if (typeof arguments[5] == "object" && typeof arguments[5].hitCallback == "function") {
arguments[5].hitCallback();
}
};
// Sets the time (as an integer) this tag was executed. Used for timing hits.
window[variableName].l = 1 * new Date();
// Insert the script tag asynchronously. Inserts above current tag to prevent blocking in
// addition to using the async attribute.
scriptElement = document.createElement(strScript),
firstScript = document.getElementsByTagName(strScript)[0];
scriptElement.async = 1;
scriptElement.src = url;
firstScript.parentNode.insertBefore(scriptElement, firstScript)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-XXXXXXXX-1', 'auto'); // Creates the tracker with default parameters.
ga('send', 'pageview'); // Sends a pageview hit.
</script>
<!-- End Google Analytics -->
The following article discusses this problem in detail:
http://veithen.github.io/2015/01/24/outbound-link-tracking.html
I recently received an emailer from Onnit Labs that included a Countdown Module Timer inside the emailer using a gif image. The emailer can be viewed here: https://www.onnit.com/emails/lastchance-historic/
The Image can be seen here:
I looked into it, and it seems you can keep sending new frames to an animated GIF using gifsockets, as a GIF doesn't specify how many frames it has when loaded in the browser. Here it is on github: http://github.com/videlalvaro/gifsockets
I thought this was pretty interesting and a cool effect indeed. Does anyone have any other insights on how this could be accomplished? It seems as though the one they're using at Onnit seems to change the countdown according to date appended at the end of URL or image.
onnit.com/emails/_modules/timer/?end=2012-12-27+00:00:00&dark=1
I'm trying to accomplish the same thing to send in an email, but I am a little stumped.
While maybe gifsockets would work (I haven't tried that before...), there is no network traffic while I am looking at the image other than the initial image load. I am also seeing it it jump from 41 to 42 again. A Reload took it down to 39.
It appears to be just a script that generates 60 frames of animation and sends them to the user. This could probably be done in any language.
Here is how it is done in php:
http://seanja.com/secret/countdown/
I found http://sendtric.com/ which is free and very easy to integrate.
You could try http://makedreamprofits.com/pt/. Instead of supplying additional content to a gif, this countdown is broken into separate images and can count for up to 20 mins without increasing much traffic.
P.S. Gmail is precaching images, so, supplying it endlessly with new frames is not possible.
I really appreciated Sean Ja's answer. (He deserves more upvotes.) And then I wanted to make the code more readable and configurable (and support text on a transparent gif and automatically center the text):
use Carbon\Carbon;
class CountdownGifHelper {
const DELAY = 100; /* Why was this labeled as 'milliseconds' when it seems like a value of 100 here causes 1 frame to be shown per second? */
const MAX_FRAMES = 120;
/**
*
* #param string $bgImg
* #param \DateInterval $interval
* #param array $fontArr
* #param array $frames
* #param array $delays
* #param string $format
*/
public function addFrame($bgImg, $interval, $fontArr, &$frames, &$delays, $format) {
$image = imagecreatefrompng($bgImg); //Each frame needs to start by creating a new image because otherwise the new numbers would draw on top of old ones. Here, it doesn't really matter what the PNG is (other than for size) because it's about to get filled with a new color.
$text = $interval->format($format);
ob_start();
imageSaveAlpha($image, true);
$backgroundColor = $fontArr['backgroundColor'];
imagefill($image, 0, 0, $backgroundColor); //https://stackoverflow.com/a/17016252/470749 was a helpful hint
imagecolortransparent($image, $backgroundColor);
$this->insertCenteredText($image, $fontArr, $text);
//imagettftext($image, $font['size'], $font['angle'], $font['x-offset'], $font['y-offset'], $font['color'], $font['file'], $text);//this was the old way
imagegif($image); //The image format will be GIF87a unless the image has been made transparent with imagecolortransparent(), in which case the image format will be GIF89a.
$frames[] = ob_get_contents();
ob_end_clean();
$delays[] = self::DELAY;
}
/**
*
* #param resource $image
* #param array $fontArray
* #param string $text
*/
public function insertCenteredText(&$image, $fontArray, $text) {
$image_width = imagesx($image);
$image_height = imagesy($image);
$text_box = imagettfbbox($fontArray['size'], $fontArray['angle'], $fontArray['file'], $text); // Get Bounding Box Size
$text_width = $text_box[2] - $text_box[0];
$text_height = $text_box[7] - $text_box[1];
// Calculate coordinates of the text https://stackoverflow.com/a/14517450/470749
$x = ($image_width / 2) - ($text_width / 2);
$y = ($image_height / 2) - ($text_height / 2);
imagettftext($image, $fontArray['size'], $fontArray['angle'], $x, $y, $fontArray['color'], $fontArray['file'], $text);
}
/**
*
* #param int $timestamp
* #param string $bgImg
* #param array $fontArray
* #return string [can be used by Laravel response()->make($gifString, 200, $headers)]
*/
public function getAnimatedGif($timestamp, $bgImg, $fontArray) {
$future_date = Carbon::createFromTimestamp($timestamp);
$time_now = time();
$moment = new \DateTime(date('r', $time_now));
$frames = [];
$delays = [];
for ($i = 0; $i <= self::MAX_FRAMES; $i++) {
$interval = date_diff($future_date, $moment);
if ($future_date < $moment) {
$this->addFrame($bgImg, $interval, $fontArray, $frames, $delays, '00 : 00 : 00');
$loops = 1; //stay stuck on this frame
break;
} else {
$this->addFrame($bgImg, $interval, $fontArray, $frames, $delays, '%H : %I : %S');
$loops = 0; //infinite loop
}
$moment->modify('+1 second');
}
$animatedGif = new \App\Helpers\AnimatedGif($frames, $delays, $loops, 0, 0, 0);
return $animatedGif->getAnimation();
}
/**
* ONEDAY allow config via params
* #param resource $image
* #return array
*/
public function getFontArray($image) {
$fontArr = [
'file' => resource_path('assets/fonts/Kanit-Regular.ttf'),
'size' => 30,
//'x-offset' => 5,
//'y-offset' => 30,
'color' => imagecolorallocate($image, 90, 90, 90), //gray
'backgroundColor' => imagecolorallocate($image, 0, 0, 0), //white. Must match the arguments provided to AnimatedGif (such as 0,0,0).
'angle' => 0,
];
return $fontArr;
}
}